diff --git a/plugins/module_utils/network/vyos/facts/firewall_global/firewall_global.py b/plugins/module_utils/network/vyos/facts/firewall_global/firewall_global.py index bc908dcf..ace5af06 100644 --- a/plugins/module_utils/network/vyos/facts/firewall_global/firewall_global.py +++ b/plugins/module_utils/network/vyos/facts/firewall_global/firewall_global.py @@ -1,522 +1,524 @@ # # -*- coding: utf-8 -*- # Copyright 2019 Red Hat # GNU General Public License v3.0+ # (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) """ The vyos firewall_global fact class It is in this file the configuration is collected from the device for a given resource, parsed, and the facts tree is populated based on the configuration. """ from __future__ import absolute_import, division, print_function __metaclass__ = type from copy import deepcopy from re import M, findall, search from ansible_collections.ansible.netcommon.plugins.module_utils.network.common import utils from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.argspec.firewall_global.firewall_global import ( Firewall_globalArgs, ) class Firewall_globalFacts(object): """The vyos firewall_global fact class""" def __init__(self, module, subspec="config", options="options"): self._module = module self.argument_spec = Firewall_globalArgs.argument_spec spec = deepcopy(self.argument_spec) if subspec: if options: facts_argument_spec = spec[subspec][options] else: facts_argument_spec = spec[subspec] else: facts_argument_spec = spec self.generated_spec = utils.generate_dict(facts_argument_spec) def get_device_data(self, connection): return connection.get_config() def populate_facts(self, connection, ansible_facts, data=None): """Populate the facts for firewall_global :param connection: the device connection :param ansible_facts: Facts dictionary :param data: previously collected conf :rtype: dictionary :returns: facts """ if not data: # typically data is populated from the current device configuration # data = connection.get('show running-config | section ^interface') # using mock data instead data = self.get_device_data(connection) objs = {} firewalls = findall(r"^set firewall .*$", data, M) if firewalls: objs = self.render_config(firewalls) facts = {} params = utils.validate_config(self.argument_spec, {"config": objs}) facts["firewall_global"] = utils.remove_empties(params["config"]) ansible_facts["ansible_network_resources"].update(facts) return ansible_facts def render_config(self, conf): """ Render config as dictionary structure and delete keys from spec for null values :param spec: The facts tree, generated from the argspec :param conf: The configuration :rtype: dictionary :returns: The generated config """ conf = "\n".join( filter( lambda x: not ( - x.startswith("set firewall name") or x.startswith("set firewall ipv6 name") + x.startswith("set firewall name") + or x.startswith("set firewall ipv6-name") + or x.startswith("set firewall ipv6 name") ), conf, ), ) a_lst = [ "config_trap", "validation", "log_martians", "syn_cookies", "twa_hazards_protection", ] firewall = self.parse_attr(conf, a_lst) f_sub = { "ping": self.parse_ping(conf), "group": self.parse_group(conf), "route_redirects": self.route_redirects(conf), "state_policy": self.parse_state_policy(conf), "zone": self.parse_zone(conf), } firewall.update(f_sub) return firewall def route_redirects(self, conf): """ This function forms the regex to fetch the afi and invoke functions to fetch route redirects and source routes :param conf: configuration data. :return: generated rule list configuration. """ rr_lst = [] v6_attr = findall( r"^set firewall (?:global-options )?(?:ipv6-src-route|ipv6-receive-redirects) (\S+)", conf, M, ) if v6_attr: obj = self.parse_rr_attrib(conf, "ipv6") if obj: rr_lst.append(obj) v4_attr = findall( r"^set firewall (?:global-options )?(?:ip-src-route|receive-redirects|send-redirects) (\S+)", conf, M, ) if v4_attr: obj = self.parse_rr_attrib(conf, "ipv4") if obj: rr_lst.append(obj) return rr_lst def parse_rr_attrib(self, conf, attrib=None): """ This function fetches the 'ip_src_route' invoke function to parse icmp redirects. :param conf: configuration to be parsed. :param attrib: 'ipv4/ipv6'. :return: generated config dictionary. """ cfg_dict = self.parse_attr(conf, ["ip_src_route"], type=attrib) cfg_dict["icmp_redirects"] = self.parse_icmp_redirects(conf, attrib) cfg_dict["afi"] = attrib return cfg_dict def parse_icmp_redirects(self, conf, attrib=None): """ This function triggers the parsing of 'icmp_redirects' attributes. :param conf: configuration to be parsed. :param attrib: 'ipv4/ipv6'. :return: generated config dictionary. """ a_lst = ["send", "receive"] cfg_dict = self.parse_attr(conf, a_lst, type=attrib) return cfg_dict def parse_ping(self, conf): """ This function triggers the parsing of 'ping' attributes. :param conf: configuration to be parsed. :return: generated config dictionary. """ a_lst = ["all", "broadcast"] cfg_dict = self.parse_attr(conf, a_lst) return cfg_dict def parse_state_policy(self, conf): """ This function fetched the connecton type and invoke function to parse other state-policy attributes. :param conf: configuration data. :return: generated rule list configuration. """ sp_lst = [] policies = findall(r"^set firewall (?:global-options )?state-policy (\S+)", conf, M) policies = list(set(policies)) # remove redundancies if policies: rules_lst = [] for sp in set(policies): sp_regex = r"^set firewall (?:global-options )?state-policy %s .+$" % sp cfg = "\n".join(findall(sp_regex, conf, M)) obj = self.parse_policies(cfg, sp) obj["connection_type"] = sp if obj: rules_lst.append(obj) sp_lst = sorted(rules_lst, key=lambda i: i["connection_type"]) return sp_lst def parse_policies(self, conf, attrib=None): """ This function triggers the parsing of policy attributes action and log. :param conf: configuration :param attrib: connection type. :return: generated rule configuration dictionary. """ a_lst = ["action", "log", "log_level"] cfg_dict = self.parse_attr(conf, a_lst, match=attrib) return cfg_dict def parse_group(self, conf): """ This function triggers the parsing of 'group' attributes. :param conf: configuration. :return: generated config dictionary. """ cfg_dict = {} cfg_dict["port_group"] = self.parse_group_lst(conf, "port-group", False) cfg_dict["address_group"] = self.parse_group_lst( conf, "address-group", ) + self.parse_group_lst(conf, "ipv6-address-group") cfg_dict["network_group"] = self.parse_group_lst( conf, "network-group", ) + self.parse_group_lst(conf, "ipv6-network-group") return cfg_dict def parse_group_lst(self, conf, type, include_afi=True): """ This function fetches the name of group and invoke function to parse group attributes'. :param conf: configuration data. :param type: type of group. :param include_afi: if the afi should be included in the parsed object :return: generated group list configuration. """ g_lst = [] groups = findall(r"^set firewall group " + type + " (\\S+)", conf, M) if groups: rules_lst = [] for gr in set(groups): gr_regex = r"^set firewall group " + type + " %s .+$" % gr cfg = "\n".join(findall(gr_regex, conf, M)) if "ipv6" in type: # fmt: off obj = self.parse_groups(cfg, type[len("ipv6-"):], gr) # fmt: on if include_afi: obj["afi"] = "ipv6" else: obj = self.parse_groups(cfg, type, gr) if include_afi: obj["afi"] = "ipv4" obj["name"] = gr.strip("'") if obj: rules_lst.append(obj) g_lst = sorted(rules_lst, key=lambda i: i["name"]) return g_lst def parse_groups(self, conf, type, name): """ This function fetches the description and invoke the parsing of group members. :param conf: configuration. :param type: type of group. :param name: name of group. :return: generated configuration dictionary. """ a_lst = ["name", "description"] group = self.parse_attr(conf, a_lst) key = self.get_key(type) r_sub = {key[0]: self.parse_address_port_lst(conf, name, key[1])} group.update(r_sub) return group def parse_address_port_lst(self, conf, name, key): """ This function forms the regex to fetch the group members attributes. :param conf: configuration data. :param name: name of group. :param key: key value. :return: generated member list configuration. """ l_lst = [] attribs = findall(r"^.*" + name + " " + key + " (\\S+)", conf, M) if attribs: for attr in attribs: if key == "port": l_lst.append({"port": attr.strip("'")}) else: l_lst.append({"address": attr.strip("'")}) return l_lst def parse_attr(self, conf, attr_list, match=None, type=None): """ This function peforms the following: - Form the regex to fetch the required attribute config. - Type cast the output in desired format. :param conf: configuration. :param attr_list: list of attributes. :param match: parent node/attribute name. :return: generated config dictionary. """ config = {} for attrib in attr_list: regex = self.map_regex(attrib, type) if match: regex = match + " " + regex if conf: if self.is_bool(attrib): # fancy regex to make sure we don't get a substring out = search(r"^.*" + regex + r"( 'disable')?(?=\s|$)", conf, M) if out: if out.group(1): config[attrib] = False else: config[attrib] = True else: out = search(r"^.*" + regex + r" (.+)", conf, M) if out: val = out.group(1).strip("'") if self.is_num(attrib): val = int(val) config[attrib] = val return config def get_key(self, type): """ This function map the group type to member type :param type: :return: """ key = () if type == "port-group": key = ("members", "port") elif type == "address-group": key = ("members", "address") elif type == "network-group": key = ("members", "network") return key def map_regex(self, attrib, type=None): """ - This function construct the regex string. - replace the underscore with hyphen. :param attrib: attribute :return: regex string """ regex = attrib.replace("_", "-") if attrib == "all": regex = "all-ping" elif attrib == "disabled": regex = "disable" elif attrib == "broadcast": regex = "broadcast-ping" elif attrib == "send": if type == "ipv6": regex = "ipv6-send-redirects" else: regex = "send-redirects" elif attrib == "ip_src_route": if type == "ipv6": regex = "ipv6-src-route" elif attrib == "receive": if type == "ipv6": regex = "ipv6-receive-redirects" else: regex = "receive-redirects" return regex def is_num(self, attrib): """ This function looks for the attribute in predefined integer type set. :param attrib: attribute. :return: True/false. """ num_set = ("time", "code", "type", "count", "burst", "number") return True if attrib in num_set else False def get_src_route(self, attrib): """ This function looks for the attribute in predefined integer type set. :param attrib: attribute. :return: True/false. """ return "ipv6_src_route" if attrib == "ipv6" else "ip_src_route" def is_bool(self, attrib): """ This function looks for the attribute in predefined bool type set. :param attrib: attribute. :return: True/False """ bool_set = ( "all", "log", "send", "receive", "broadcast", "config_trap", "log_martians", "syn_cookies", "ip_src_route", "twa_hazards_protection", ) return True if attrib in bool_set else False def parse_zone(self, conf): """ This function triggers the parsing of 'zone' attributes. :param conf: configuration. :return: generated config dictionary. """ cfg_dict = {} KEY_MAP = { "interface": "interfaces", "intra-zone-filtering": "intra-zone-filtering", "from": "sources", } LIST_ATTRS = { "interfaces", "intra_zone_filtering", "sources", } for line in conf.splitlines(): m = search( r"^set firewall zone (?P\S+)\s+(?P[a-z-]+)(?:\s+(?P'[^']+'|[^\n]+))?$", line, ) if not m: continue zone_name = m.group("zone") raw_attr = m.group("attr").replace("-", "_") value = m.group("value") if value is None: value = True else: value = value.strip("'") # VyOS 1.5.0 GA wraps 'interface' under a new 'member' node: # "set firewall zone member interface ". Unwrap # it here so it lands in the same 'interfaces' list as the # pre-1.5.0 bare "interface " form. No version check # needed -- 1.4.x/1.5-rolling configs never emit 'member'. if raw_attr == "member" and isinstance(value, str) and value.startswith("interface "): raw_attr = "interface" value = value.split(None, 1)[1].strip("'") zone = cfg_dict.setdefault(zone_name, {"name": zone_name}) attr = KEY_MAP.get(raw_attr, raw_attr) if attr in LIST_ATTRS: if attr == "intra_zone_filtering": izf = zone.setdefault(attr, {}) izf_attr = self._parse_izf(value) for k, v in izf_attr.items(): if isinstance(v, dict): izf.setdefault(k, {}).update(v) else: izf[k] = v elif attr == "sources": self._parse_sources(zone, value) else: zone.setdefault(attr, []).append(value) else: zone[attr] = value return list(cfg_dict.values()) def _parse_izf(self, value): tokens = value.replace("'", "").split() result = {} key = tokens[0].replace("-", "_") if len(tokens) == 2: result[key] = tokens[1] elif len(tokens) >= 3: subkey = tokens[1].replace("-", "_") result[key] = {subkey: tokens[2]} return result def _parse_sources(self, zone, value): tokens = value.split() if len(tokens) < 1: return src_zone = tokens[0] sources = zone.setdefault("sources", []) entry = None for s in sources: if s.get("zone") == src_zone: entry = s break if entry is None: entry = {"zone": src_zone} sources.append(entry) if len(tokens) == 1: return if tokens[1] == "firewall" and len(tokens) >= 4: key = tokens[2].replace("-", "_") val = tokens[3].strip("'") firewall = entry.setdefault("firewall", {}) firewall[key] = val diff --git a/tests/integration/targets/vyos_firewall_global/tests/cli/_parsed_config_1_4.cfg b/tests/integration/targets/vyos_firewall_global/tests/cli/_parsed_config_1_4.cfg index 88553c00..882d9aaa 100644 --- a/tests/integration/targets/vyos_firewall_global/tests/cli/_parsed_config_1_4.cfg +++ b/tests/integration/targets/vyos_firewall_global/tests/cli/_parsed_config_1_4.cfg @@ -1,21 +1,21 @@ set firewall global-options all-ping 'enable' set firewall global-options broadcast-ping 'enable' set firewall group address-group MGMT-HOSTS address '192.0.1.1' set firewall group address-group MGMT-HOSTS address '192.0.1.3' set firewall group address-group MGMT-HOSTS address '192.0.1.5' set firewall group address-group MGMT-HOSTS description 'This group has the Management hosts address list' set firewall group network-group MGMT description 'This group has the Management network addresses' set firewall group network-group MGMT network '192.0.1.0/24' set firewall global-options ip-src-route 'enable' set firewall global-options log-martians 'enable' set firewall global-options receive-redirects 'disable' set firewall global-options send-redirects 'enable' set firewall global-options source-validation 'strict' set firewall global-options state-policy established action 'accept' set firewall global-options state-policy established log 'enable' set firewall global-options state-policy invalid action 'reject' set firewall global-options syn-cookies 'enable' set firewall global-options twa-hazards-protection 'enable' -set firewall zone ZONE-TEST member interface 'eth0.1234' +set firewall zone ZONE-TEST interface 'eth0.1234' set firewall zone ZONE-TEST description 'zone-test test description' set firewall zone ZONE-TEST default-action 'drop' diff --git a/tests/unit/modules/network/vyos/fixtures/vyos_firewall_global_config_v14.cfg b/tests/unit/modules/network/vyos/fixtures/vyos_firewall_global_config_v14.cfg index 42bea69b..f58f166c 100644 --- a/tests/unit/modules/network/vyos/fixtures/vyos_firewall_global_config_v14.cfg +++ b/tests/unit/modules/network/vyos/fixtures/vyos_firewall_global_config_v14.cfg @@ -1,27 +1,30 @@ set firewall group address-group A-EMPTY set firewall group address-group RND-HOSTS address 192.0.2.1 set firewall group address-group RND-HOSTS address 192.0.2.3 set firewall group address-group RND-HOSTS address 192.0.2.5 set firewall group address-group RND-HOSTS description 'This group has the Management hosts address lists' set firewall group address-group DELETE-HOSTS address 1.2.3.4 set firewall group address-group DELETE-HOSTS description 'The (single) last address from this group will be deleted in the tests' set firewall group ipv6-address-group LOCAL-v6 address ::1 set firewall group ipv6-address-group LOCAL-v6 address fdec:2503:89d6:59b3::1 set firewall group ipv6-address-group LOCAL-v6 description 'This group has the hosts address lists of this machine' set firewall group network-group RND network 192.0.2.0/24 set firewall group network-group RND description 'This group has the Management network addresses' set firewall group ipv6-network-group UNIQUE-LOCAL-v6 network fc00::/7 set firewall group ipv6-network-group UNIQUE-LOCAL-v6 description 'This group encompasses the ULA address space in IPv6' set firewall group port-group SSH port 22 set firewall group port-group SSH description 'This group has the ssh ports' set firewall global-options all-ping enable set firewall global-options state-policy related action 'accept' set firewall global-options state-policy related log-level 'alert' set firewall global-options ipv6-src-route 'enable' set firewall global-options send-redirects 'enable' set firewall zone ZONE-TEST interface 'eth0.1234' set firewall zone ZONE-TEST description 'zone-test test description' set firewall group address-group ZONE-TEST address '1.2.3.4' set firewall zone ZONE-IZF description 'zone for izf and sources delete path testing' set firewall zone ZONE-IZF intra-zone-filtering action 'drop' set firewall zone ZONE-IZF from zone-src firewall name 'existing-ruleset' +set firewall name TESTRULESET-V4 default-action drop +set firewall ipv6-name TESTRULESET-V6-LEGACY default-action drop +set firewall ipv6 name TESTRULESET-V6-1_4PLUS default-action drop diff --git a/tests/unit/modules/network/vyos/fixtures/vyos_firewall_global_config_v15.cfg b/tests/unit/modules/network/vyos/fixtures/vyos_firewall_global_config_v15.cfg index 83cfd2d0..23dd9777 100644 --- a/tests/unit/modules/network/vyos/fixtures/vyos_firewall_global_config_v15.cfg +++ b/tests/unit/modules/network/vyos/fixtures/vyos_firewall_global_config_v15.cfg @@ -1,8 +1,11 @@ # Intentionally minimal: represents a VyOS 1.5.0 device with one existing # zone already configured using the 1.5.0 'member interface' syntax. Used # to test parse_zone's unwrap logic in isolation. TestVyosFirewallRulesModule15's # other tests (e.g. test_01_merged) create a differently-named zone # (ZONE-15), so under 'merged' state this existing zone is never touched # and doesn't interfere. set firewall zone ZONE-15-EXISTING member interface eth2 set firewall zone ZONE-15-EXISTING description 'existing 1.5.0 zone for facts parsing test' +set firewall name TESTRULESET-V4 default-action drop +set firewall ipv6-name TESTRULESET-V6-LEGACY default-action drop +set firewall ipv6 name TESTRULESET-V6-1_4PLUS default-action drop diff --git a/tests/unit/modules/network/vyos/test_vyos_firewall_global14.py b/tests/unit/modules/network/vyos/test_vyos_firewall_global14.py index 295d6b5a..fc3db2d7 100644 --- a/tests/unit/modules/network/vyos/test_vyos_firewall_global14.py +++ b/tests/unit/modules/network/vyos/test_vyos_firewall_global14.py @@ -1,1007 +1,1019 @@ # (c) 2016 Red Hat Inc. # # This file is part of Ansible # # Ansible is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Ansible is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . # Make coding more python3-ish from __future__ import absolute_import, division, print_function __metaclass__ = type from unittest.mock import patch from ansible_collections.vyos.vyos.plugins.modules import vyos_firewall_global from ansible_collections.vyos.vyos.tests.unit.modules.utils import set_module_args from .vyos_module import TestVyosModule, load_fixture class TestVyosFirewallRulesModule14(TestVyosModule): module = vyos_firewall_global def setUp(self): super(TestVyosFirewallRulesModule14, self).setUp() self.mock_get_config = patch( "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.network.Config.get_config", ) self.get_config = self.mock_get_config.start() self.mock_load_config = patch( "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.network.Config.load_config", ) self.load_config = self.mock_load_config.start() self.mock_get_resource_connection_config = patch( "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.cfg.base.get_resource_connection", ) self.get_resource_connection_config = self.mock_get_resource_connection_config.start() self.mock_get_resource_connection_facts = patch( "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.facts.facts.get_resource_connection", ) self.get_resource_connection_facts = self.mock_get_resource_connection_facts.start() self.mock_execute_show_command = patch( "ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.firewall_global.firewall_global.Firewall_globalFacts.get_device_data", ) self.mock_get_os_version = patch( "ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.config.firewall_global.firewall_global.get_os_version", ) self.get_os_version = self.mock_get_os_version.start() self.get_os_version.return_value = "1.4" self.execute_show_command = self.mock_execute_show_command.start() self.maxDiff = None def tearDown(self): super(TestVyosFirewallRulesModule14, self).tearDown() self.mock_get_resource_connection_config.stop() self.mock_get_resource_connection_facts.stop() self.mock_get_config.stop() self.mock_load_config.stop() self.mock_execute_show_command.stop() self.mock_get_os_version.stop() def load_fixtures(self, commands=None, filename=None): def load_from_file(*args, **kwargs): return load_fixture("vyos_firewall_global_config_v14.cfg") self.execute_show_command.side_effect = load_from_file def test_vyos_firewall_global_set_01_merged(self): set_module_args( dict( config=dict( validation="strict", config_trap=True, log_martians=True, syn_cookies=True, twa_hazards_protection=True, ping=dict(all=True, broadcast=True), state_policy=[ dict( connection_type="established", action="accept", log=True, log_level="emerg", ), dict(connection_type="invalid", action="reject"), ], route_redirects=[ dict( afi="ipv4", ip_src_route=True, icmp_redirects=dict(send=True, receive=False), ), dict( afi="ipv6", ip_src_route=False, icmp_redirects=dict(receive=False), ), ], group=dict( address_group=[ dict( afi="ipv4", name="MGMT-HOSTS", description="This group has the Management hosts address lists", members=[ dict(address="192.0.1.1"), dict(address="192.0.1.3"), dict(address="192.0.1.5"), ], ), dict( afi="ipv6", name="GOOGLE-DNS-v6", members=[ dict(address="2001:4860:4860::8888"), dict(address="2001:4860:4860::8844"), ], ), ], network_group=[ dict( afi="ipv4", name="MGMT", description="This group has the Management network addresses", members=[dict(address="192.0.1.0/24")], ), dict( afi="ipv6", name="DOCUMENTATION-v6", description="IPv6 Addresses reserved for documentation per RFC 3849", members=[ dict(address="2001:0DB8::/32"), dict(address="3FFF:FFFF::/32"), ], ), ], port_group=[ dict( name="TELNET", description="This group has the telnet ports", members=[dict(port="23")], ), ], ), zone=[ dict( name="FZP-2", default_action="reject", default_log=True, description="This is the Firewall zone fzp2", interfaces=[ "eth2", "lo0", ], local_zone=True, sources=[ dict( zone="fzp5", firewall=dict( name="fzp1", ipv6_name="fzp1-v6", ), ), ], intra_zone_filtering=dict( action="accept", ), ), ], ), state="merged", ), ) commands = [ "set firewall group address-group MGMT-HOSTS address 192.0.1.1", "set firewall group address-group MGMT-HOSTS address 192.0.1.3", "set firewall group address-group MGMT-HOSTS address 192.0.1.5", "set firewall group address-group MGMT-HOSTS description 'This group has the Management hosts address lists'", "set firewall group address-group MGMT-HOSTS", "set firewall group ipv6-address-group GOOGLE-DNS-v6 address 2001:4860:4860::8888", "set firewall group ipv6-address-group GOOGLE-DNS-v6 address 2001:4860:4860::8844", "set firewall group ipv6-address-group GOOGLE-DNS-v6", "set firewall group network-group MGMT network 192.0.1.0/24", "set firewall group network-group MGMT description 'This group has the Management network addresses'", "set firewall group network-group MGMT", "set firewall group ipv6-network-group DOCUMENTATION-v6 network 2001:0DB8::/32", "set firewall group ipv6-network-group DOCUMENTATION-v6 network 3FFF:FFFF::/32", "set firewall group ipv6-network-group DOCUMENTATION-v6 description 'IPv6 Addresses reserved for documentation per RFC 3849'", "set firewall group ipv6-network-group DOCUMENTATION-v6", "set firewall group port-group TELNET port 23", "set firewall group port-group TELNET description 'This group has the telnet ports'", "set firewall group port-group TELNET", "set firewall global-options ip-src-route 'enable'", "set firewall global-options receive-redirects 'disable'", "set firewall global-options config-trap 'enable'", "set firewall global-options ipv6-src-route 'disable'", "set firewall global-options ipv6-receive-redirects 'disable'", "set firewall global-options state-policy established action 'accept'", "set firewall global-options state-policy established log", "set firewall global-options state-policy established log-level 'emerg'", "set firewall global-options state-policy invalid action 'reject'", "set firewall global-options broadcast-ping 'enable'", "set firewall global-options log-martians 'enable'", "set firewall global-options twa-hazards-protection 'enable'", "set firewall global-options syn-cookies 'enable'", "set firewall global-options source-validation 'strict'", "set firewall zone FZP-2 default-action 'reject'", "set firewall zone FZP-2 default-log", "set firewall zone FZP-2 description 'This is the Firewall zone fzp2'", "set firewall zone FZP-2 from fzp5 firewall ipv6-name fzp1-v6", "set firewall zone FZP-2 from fzp5 firewall name fzp1", "set firewall zone FZP-2 interface eth2", "set firewall zone FZP-2 interface lo0", "set firewall zone FZP-2 intra-zone-filtering action accept", "set firewall zone FZP-2 local-zone", ] self.execute_module(changed=True, commands=commands) def test_vyos_firewall_global_set_01_merged_idem(self): set_module_args( dict( config=dict( group=dict( address_group=[ dict( afi="ipv4", name="A-EMPTY", ), dict( afi="ipv4", name="RND-HOSTS", description="This group has the Management hosts address lists", members=[ dict(address="192.0.2.1"), dict(address="192.0.2.3"), dict(address="192.0.2.5"), ], ), dict( afi="ipv6", name="LOCAL-v6", description="This group has the hosts address lists of this machine", members=[ dict(address="::1"), dict(address="fdec:2503:89d6:59b3::1"), ], ), ], network_group=[ dict( afi="ipv4", name="RND", description="This group has the Management network addresses", members=[dict(address="192.0.2.0/24")], ), dict( afi="ipv6", name="UNIQUE-LOCAL-v6", description="This group encompasses the ULA address space in IPv6", members=[dict(address="fc00::/7")], ), ], port_group=[ dict( name="SSH", description="This group has the ssh ports", members=[dict(port="22")], ), ], ), ), state="merged", ), ) self.execute_module(changed=False, commands=[]) def test_vyos_firewall_global_set_01_replaced(self): set_module_args( dict( config=dict( state_policy=[ dict(connection_type="invalid", action="reject"), ], group=dict( address_group=[ dict( afi="ipv4", name="RND-HOSTS", description="This group has the Management hosts address lists", members=[ dict(address="192.0.2.1"), dict(address="192.0.2.7"), dict(address="192.0.2.9"), ], ), dict( afi="ipv4", name="DELETE-HOSTS", description="The (single) last address from this group will be deleted in the tests", # No members here ), dict( afi="ipv6", name="LOCAL-v6", description="This group has the hosts address lists of this machine", members=[ dict(address="::1"), dict(address="fdec:2503:89d6:59b3::2"), ], ), dict( afi="ipv4", name="ZONE-TEST", members=[ dict(address="1.2.3.4"), ], description="This is a new description for a address group name that is also in a zone", ), ], network_group=[ dict( afi="ipv4", name="RND", # Deleted the description here. members=[dict(address="192.0.2.0/24")], ), dict( afi="ipv6", name="UNIQUE-LOCAL-v6", description="This group encompasses the ULA address space in IPv6", members=[dict(address="fc00::/7")], ), ], port_group=[ dict( name="SSH", description="This group has the ssh ports", members=[dict(port="2222")], ), ], ), zone=[ dict( name="FZP-2", default_action="drop", default_log=True, description="This is the Firewall zone fzp2", interfaces=[ "eth2", "lo0", ], local_zone=True, sources=[ dict( zone="fzp5", firewall=dict( name="fzp1", ipv6_name="fzp1-v6", ), ), ], intra_zone_filtering=dict( action="accept", ), ), ], ), state="replaced", ), ) commands = [ "delete firewall group address-group A-EMPTY", "delete firewall group address-group RND-HOSTS address 192.0.2.3", "delete firewall group address-group RND-HOSTS address 192.0.2.5", "delete firewall group address-group DELETE-HOSTS address", "delete firewall global-options all-ping", "delete firewall global-options state-policy related", "delete firewall global-options ipv6-src-route", "delete firewall global-options send-redirects", "delete firewall zone ZONE-IZF", "set firewall global-options state-policy invalid action 'reject'", "set firewall group address-group RND-HOSTS address 192.0.2.7", "set firewall group address-group RND-HOSTS address 192.0.2.9", "set firewall group address-group ZONE-TEST description 'This is a new description for a address group name that is also in a zone'", "delete firewall zone ZONE-TEST", "delete firewall group network-group RND description", "delete firewall group ipv6-address-group LOCAL-v6 address fdec:2503:89d6:59b3::1", "set firewall group ipv6-address-group LOCAL-v6 address fdec:2503:89d6:59b3::2", "delete firewall group port-group SSH port 22", "set firewall group port-group SSH port 2222", "set firewall zone FZP-2 default-action 'drop'", "set firewall zone FZP-2 default-log", "set firewall zone FZP-2 description 'This is the Firewall zone fzp2'", "set firewall zone FZP-2 from fzp5 firewall ipv6-name fzp1-v6", "set firewall zone FZP-2 from fzp5 firewall name fzp1", "set firewall zone FZP-2 interface eth2", "set firewall zone FZP-2 interface lo0", "set firewall zone FZP-2 intra-zone-filtering action accept", "set firewall zone FZP-2 local-zone", ] self.execute_module(changed=True, commands=commands) def test_vyos_firewall_global_set_01_replaced_idem(self): set_module_args( dict( config=dict( ping=dict(all=True), route_redirects=[ dict(ip_src_route=True, afi="ipv6"), dict(icmp_redirects=dict(send=True), afi="ipv4"), ], state_policy=[ dict(connection_type="related", action="accept", log_level="alert"), ], group=dict( address_group=[ dict( afi="ipv4", name="A-EMPTY", ), dict( afi="ipv4", name="RND-HOSTS", description="This group has the Management hosts address lists", members=[ dict(address="192.0.2.1"), dict(address="192.0.2.3"), dict(address="192.0.2.5"), ], ), dict( afi="ipv4", name="DELETE-HOSTS", description="The (single) last address from this group will be deleted in the tests", members=[ dict(address="1.2.3.4"), ], ), dict( afi="ipv4", name="ZONE-TEST", members=[ dict(address="1.2.3.4"), ], ), dict( afi="ipv6", name="LOCAL-v6", description="This group has the hosts address lists of this machine", members=[ dict(address="::1"), dict(address="fdec:2503:89d6:59b3::1"), ], ), ], network_group=[ dict( afi="ipv4", name="RND", description="This group has the Management network addresses", members=[dict(address="192.0.2.0/24")], ), dict( afi="ipv6", name="UNIQUE-LOCAL-v6", description="This group encompasses the ULA address space in IPv6", members=[dict(address="fc00::/7")], ), ], port_group=[ dict( name="SSH", description="This group has the ssh ports", members=[dict(port="22")], ), ], ), zone=[ dict( name="ZONE-TEST", description="zone-test test description", interfaces=["eth0.1234"], ), dict( name="ZONE-IZF", description="zone for izf and sources delete path testing", intra_zone_filtering=dict(action="drop"), sources=[ dict( zone="zone-src", firewall=dict(name="existing-ruleset"), ), ], ), ], ), state="replaced", ), ) self.execute_module(changed=False, commands=[]) def test_vyos_firewall_global_set_02_replaced(self): set_module_args( dict( config=dict( state_policy=[ dict(connection_type="invalid", action="reject"), dict(connection_type="related", action="drop"), ], group=dict( address_group=[ dict( afi="ipv4", name="RND-HOSTS", description="This group has the Management hosts address lists", members=[ dict(address="192.0.2.1"), dict(address="192.0.2.7"), dict(address="192.0.2.9"), ], ), dict( afi="ipv6", name="LOCAL-v6", description="This group has the hosts address lists of this machine", members=[ dict(address="::1"), dict(address="fdec:2503:89d6:59b3::2"), ], ), dict( afi="ipv4", name="ZONE-TEST", members=[ dict(address="4.3.2.1"), ], description="This is a new description for a address group name that is also in a zone", ), ], network_group=[ dict( afi="ipv4", name="RND", description="This group has the Management network addresses", members=[dict(address="192.0.2.0/24")], ), dict( afi="ipv6", name="UNIQUE-LOCAL-v6", description="This group encompasses the ULA address space in IPv6", members=[dict(address="fc00::/7")], ), ], port_group=[ dict( name="SSH", description="This group has the ssh ports", members=[dict(port="2222")], ), ], ), zone=[ dict( name="ZONE-TEST", description="zone-test test description", interfaces=[ "lo", ], ), ], ), state="replaced", ), ) commands = [ "delete firewall group address-group A-EMPTY", "delete firewall group address-group RND-HOSTS address 192.0.2.3", "delete firewall group address-group RND-HOSTS address 192.0.2.5", "delete firewall group address-group ZONE-TEST address 1.2.3.4", "delete firewall zone ZONE-IZF", "delete firewall global-options all-ping", "delete firewall global-options ipv6-src-route", "delete firewall global-options send-redirects", "set firewall global-options state-policy related action 'drop'", "delete firewall global-options state-policy related log-level", "delete firewall group address-group DELETE-HOSTS", "set firewall global-options state-policy invalid action 'reject'", "set firewall group address-group RND-HOSTS address 192.0.2.7", "set firewall group address-group RND-HOSTS address 192.0.2.9", "set firewall group address-group ZONE-TEST address 4.3.2.1", "delete firewall zone ZONE-TEST interface eth0.1234", "set firewall zone ZONE-TEST interface lo", "set firewall group address-group ZONE-TEST description 'This is a new description for a address group name that is also in a zone'", "delete firewall group ipv6-address-group LOCAL-v6 address fdec:2503:89d6:59b3::1", "set firewall group ipv6-address-group LOCAL-v6 address fdec:2503:89d6:59b3::2", "delete firewall group port-group SSH port 22", "set firewall group port-group SSH port 2222", ] self.execute_module(changed=True, commands=commands) def test_vyos_firewall_global_set_01_deleted(self): set_module_args(dict(config=dict(), state="deleted")) commands = ["delete firewall"] self.execute_module(changed=True, commands=commands) def test_vyos_firewall_global_set_03_replaced_izf_action_change(self): set_module_args( dict( config=dict( ping=dict(all=True), route_redirects=[ dict(ip_src_route=True, afi="ipv6"), dict(icmp_redirects=dict(send=True), afi="ipv4"), ], state_policy=[ dict(connection_type="related", action="accept", log_level="alert"), ], group=dict( address_group=[ dict(afi="ipv4", name="A-EMPTY"), dict( afi="ipv4", name="RND-HOSTS", description="This group has the Management hosts address lists", members=[ dict(address="192.0.2.1"), dict(address="192.0.2.3"), dict(address="192.0.2.5"), ], ), dict( afi="ipv4", name="DELETE-HOSTS", description="The (single) last address from this group will be deleted in the tests", members=[dict(address="1.2.3.4")], ), dict( afi="ipv4", name="ZONE-TEST", members=[dict(address="1.2.3.4")], ), dict( afi="ipv6", name="LOCAL-v6", description="This group has the hosts address lists of this machine", members=[ dict(address="::1"), dict(address="fdec:2503:89d6:59b3::1"), ], ), ], network_group=[ dict( afi="ipv4", name="RND", description="This group has the Management network addresses", members=[dict(address="192.0.2.0/24")], ), dict( afi="ipv6", name="UNIQUE-LOCAL-v6", description="This group encompasses the ULA address space in IPv6", members=[dict(address="fc00::/7")], ), ], port_group=[ dict( name="SSH", description="This group has the ssh ports", members=[dict(port="22")], ), ], ), zone=[ dict( name="ZONE-TEST", description="zone-test test description", interfaces=["eth0.1234"], ), dict( name="ZONE-IZF", description="zone for izf and sources delete path testing", intra_zone_filtering=dict(action="accept"), # was 'drop' sources=[ dict( zone="zone-src", firewall=dict(name="existing-ruleset"), ), ], ), ], ), state="replaced", ), ) commands = [ "delete firewall zone ZONE-IZF intra-zone-filtering action", "set firewall zone ZONE-IZF intra-zone-filtering action accept", ] self.execute_module(changed=True, commands=commands) def test_vyos_firewall_global_set_04_replaced_sources_value_change(self): # ZONE-IZF.sources[0].firewall.name changes from 'existing-ruleset' # (fixture) to 'new-ruleset'. Exercises _render_sources' `not opr # and hfw` (delete) branch. Pre-fix this leaked the old value into # the delete command. set_module_args( dict( config=dict( ping=dict(all=True), route_redirects=[ dict(ip_src_route=True, afi="ipv6"), dict(icmp_redirects=dict(send=True), afi="ipv4"), ], state_policy=[ dict(connection_type="related", action="accept", log_level="alert"), ], group=dict( address_group=[ dict(afi="ipv4", name="A-EMPTY"), dict( afi="ipv4", name="RND-HOSTS", description="This group has the Management hosts address lists", members=[ dict(address="192.0.2.1"), dict(address="192.0.2.3"), dict(address="192.0.2.5"), ], ), dict( afi="ipv4", name="DELETE-HOSTS", description="The (single) last address from this group will be deleted in the tests", members=[dict(address="1.2.3.4")], ), dict( afi="ipv4", name="ZONE-TEST", members=[dict(address="1.2.3.4")], ), dict( afi="ipv6", name="LOCAL-v6", description="This group has the hosts address lists of this machine", members=[ dict(address="::1"), dict(address="fdec:2503:89d6:59b3::1"), ], ), ], network_group=[ dict( afi="ipv4", name="RND", description="This group has the Management network addresses", members=[dict(address="192.0.2.0/24")], ), dict( afi="ipv6", name="UNIQUE-LOCAL-v6", description="This group encompasses the ULA address space in IPv6", members=[dict(address="fc00::/7")], ), ], port_group=[ dict( name="SSH", description="This group has the ssh ports", members=[dict(port="22")], ), ], ), zone=[ dict( name="ZONE-TEST", description="zone-test test description", interfaces=["eth0.1234"], ), dict( name="ZONE-IZF", description="zone for izf and sources delete path testing", intra_zone_filtering=dict(action="drop"), sources=[ dict( zone="zone-src", firewall=dict(name="new-ruleset"), # was 'existing-ruleset' ), ], ), ], ), state="replaced", ), ) commands = [ "delete firewall zone ZONE-IZF from zone-src firewall name", "set firewall zone ZONE-IZF from zone-src firewall name new-ruleset", ] self.execute_module(changed=True, commands=commands) def test_vyos_firewall_global_set_05_replaced_sources_firewall_cleared(self): # ZONE-IZF.sources[0] keeps the 'zone-src' key but drops the # 'firewall' sub-dict entirely. Exercises _render_sources' `not opr # and not hfw` branch -- unreachable before the fix, since it was # guarded by `not have` instead of `not hfw`, and `have` can never # be empty here (we're already inside `if zone in have_index`). set_module_args( dict( config=dict( ping=dict(all=True), route_redirects=[ dict(ip_src_route=True, afi="ipv6"), dict(icmp_redirects=dict(send=True), afi="ipv4"), ], state_policy=[ dict(connection_type="related", action="accept", log_level="alert"), ], group=dict( address_group=[ dict(afi="ipv4", name="A-EMPTY"), dict( afi="ipv4", name="RND-HOSTS", description="This group has the Management hosts address lists", members=[ dict(address="192.0.2.1"), dict(address="192.0.2.3"), dict(address="192.0.2.5"), ], ), dict( afi="ipv4", name="DELETE-HOSTS", description="The (single) last address from this group will be deleted in the tests", members=[dict(address="1.2.3.4")], ), dict( afi="ipv4", name="ZONE-TEST", members=[dict(address="1.2.3.4")], ), dict( afi="ipv6", name="LOCAL-v6", description="This group has the hosts address lists of this machine", members=[ dict(address="::1"), dict(address="fdec:2503:89d6:59b3::1"), ], ), ], network_group=[ dict( afi="ipv4", name="RND", description="This group has the Management network addresses", members=[dict(address="192.0.2.0/24")], ), dict( afi="ipv6", name="UNIQUE-LOCAL-v6", description="This group encompasses the ULA address space in IPv6", members=[dict(address="fc00::/7")], ), ], port_group=[ dict( name="SSH", description="This group has the ssh ports", members=[dict(port="22")], ), ], ), zone=[ dict( name="ZONE-TEST", description="zone-test test description", interfaces=["eth0.1234"], ), dict( name="ZONE-IZF", description="zone for izf and sources delete path testing", intra_zone_filtering=dict(action="drop"), sources=[ dict(zone="zone-src"), # firewall dropped entirely ], ), ], ), state="replaced", ), ) commands = [ "delete firewall zone ZONE-IZF from zone-src", ] self.execute_module(changed=True, commands=commands) def test_vyos_firewall_global_set_06_replaced_sources_entry_removed(self): # ZONE-IZF's sources list drops the 'zone-src' entry entirely (not # just its firewall sub-dict). Exercises the new `elif not opr:` # branch -- previously this case emitted nothing at all. set_module_args( dict( config=dict( ping=dict(all=True), route_redirects=[ dict(ip_src_route=True, afi="ipv6"), dict(icmp_redirects=dict(send=True), afi="ipv4"), ], state_policy=[ dict(connection_type="related", action="accept", log_level="alert"), ], group=dict( address_group=[ dict(afi="ipv4", name="A-EMPTY"), dict( afi="ipv4", name="RND-HOSTS", description="This group has the Management hosts address lists", members=[ dict(address="192.0.2.1"), dict(address="192.0.2.3"), dict(address="192.0.2.5"), ], ), dict( afi="ipv4", name="DELETE-HOSTS", description="The (single) last address from this group will be deleted in the tests", members=[dict(address="1.2.3.4")], ), dict( afi="ipv4", name="ZONE-TEST", members=[dict(address="1.2.3.4")], ), dict( afi="ipv6", name="LOCAL-v6", description="This group has the hosts address lists of this machine", members=[ dict(address="::1"), dict(address="fdec:2503:89d6:59b3::1"), ], ), ], network_group=[ dict( afi="ipv4", name="RND", description="This group has the Management network addresses", members=[dict(address="192.0.2.0/24")], ), dict( afi="ipv6", name="UNIQUE-LOCAL-v6", description="This group encompasses the ULA address space in IPv6", members=[dict(address="fc00::/7")], ), ], port_group=[ dict( name="SSH", description="This group has the ssh ports", members=[dict(port="22")], ), ], ), zone=[ dict( name="ZONE-TEST", description="zone-test test description", interfaces=["eth0.1234"], ), dict( name="ZONE-IZF", description="zone for izf and sources delete path testing", intra_zone_filtering=dict(action="drop"), # sources omitted entirely ), ], ), state="replaced", ), ) commands = [ "delete firewall zone ZONE-IZF from zone-src", ] self.execute_module(changed=True, commands=commands) + + def test_vyos_firewall_global_ruleset_lines_filtered_from_facts(self): + # Confirms render_config's pre-filter strips all three ruleset-line + # prefixes (IPv4 name, legacy hyphenated ipv6-name, and 1.4+ + # space-separated "ipv6 name") before zone/global-options parsing + # runs, on a real 1.4.x-shaped fixture alongside existing zone data. + set_module_args(dict(config=dict(), state="gathered")) + result = self.execute_module(changed=False) + facts = result["gathered"] + self.assertNotIn("TESTRULESET-V4", str(facts)) + self.assertNotIn("TESTRULESET-V6-LEGACY", str(facts)) + self.assertNotIn("TESTRULESET-V6-1_4PLUS", str(facts)) diff --git a/tests/unit/modules/network/vyos/test_vyos_firewall_global15.py b/tests/unit/modules/network/vyos/test_vyos_firewall_global15.py index 575ad844..22ab5364 100644 --- a/tests/unit/modules/network/vyos/test_vyos_firewall_global15.py +++ b/tests/unit/modules/network/vyos/test_vyos_firewall_global15.py @@ -1,122 +1,132 @@ # (c) 2016 Red Hat Inc. # # This file is part of Ansible # # Ansible is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Ansible is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . # Make coding more python3-ish from __future__ import absolute_import, division, print_function __metaclass__ = type from unittest.mock import patch from ansible_collections.vyos.vyos.plugins.modules import vyos_firewall_global from ansible_collections.vyos.vyos.tests.unit.modules.utils import set_module_args from .vyos_module import TestVyosModule, load_fixture class TestVyosFirewallRulesModule15(TestVyosModule): module = vyos_firewall_global def setUp(self): super(TestVyosFirewallRulesModule15, self).setUp() self.mock_get_config = patch( "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.network.Config.get_config", ) self.get_config = self.mock_get_config.start() self.mock_load_config = patch( "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.network.Config.load_config", ) self.load_config = self.mock_load_config.start() self.mock_get_resource_connection_config = patch( "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.cfg.base.get_resource_connection", ) self.get_resource_connection_config = self.mock_get_resource_connection_config.start() self.mock_get_resource_connection_facts = patch( "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.facts.facts.get_resource_connection", ) self.get_resource_connection_facts = self.mock_get_resource_connection_facts.start() self.mock_execute_show_command = patch( "ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.firewall_global.firewall_global.Firewall_globalFacts.get_device_data", ) self.mock_get_os_version = patch( "ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.config.firewall_global.firewall_global.get_os_version", ) self.get_os_version = self.mock_get_os_version.start() self.get_os_version.return_value = "1.5" self.execute_show_command = self.mock_execute_show_command.start() self.maxDiff = None def tearDown(self): super(TestVyosFirewallRulesModule15, self).tearDown() self.mock_get_resource_connection_config.stop() self.mock_get_resource_connection_facts.stop() self.mock_get_config.stop() self.mock_load_config.stop() self.mock_execute_show_command.stop() self.mock_get_os_version.stop() def load_fixtures(self, commands=None, filename=None): def load_from_file(*args, **kwargs): return load_fixture("vyos_firewall_global_config_v15.cfg") self.execute_show_command.side_effect = load_from_file def test_vyos_firewall_global_set_01_merged_interface_uses_member_keyword(self): # On a 1.5.0 device (empty fixture, no existing zone), merging a # zone with an interface should render "member interface", not the # bare "interface" used on 1.4.x / 1.5-rolling. This is the sole # thing this class exists to prove right now -- the version-gate # added to _render_interfaces. set_module_args( dict( config=dict( zone=[ dict( name="ZONE-15", interfaces=["eth1"], ), ], ), state="merged", ), ) commands = [ "set firewall zone ZONE-15 default-action 'drop'", "set firewall zone ZONE-15 member interface eth1", ] self.execute_module(changed=True, commands=commands) def test_vyos_firewall_global_set_02_gathered_member_interface_parsed(self): # ZONE-15-EXISTING in the fixture uses 1.5.0's "member interface" # syntax. Before the parse_zone fix, this misparsed into a bogus # zone['member'] = "interface eth2" string field, and 'interfaces' # was missing entirely. After the fix, it should land in # 'interfaces' exactly like the pre-1.5.0 bare "interface" form. set_module_args(dict(config=dict(), state="gathered")) result = self.execute_module(changed=False) zones = result["gathered"]["zone"] zone = next(z for z in zones if z["name"] == "ZONE-15-EXISTING") self.assertEqual(zone["interfaces"], ["eth2"]) self.assertEqual(zone["description"], "existing 1.5.0 zone for facts parsing test") self.assertNotIn("member", zone) + + def test_vyos_firewall_global_ruleset_lines_filtered_from_facts(self): + # Same coverage as the 1.4 version, confirmed independently on the + # 1.5.0 fixture/version path. + set_module_args(dict(config=dict(), state="gathered")) + result = self.execute_module(changed=False) + facts = result["gathered"] + self.assertNotIn("TESTRULESET-V4", str(facts)) + self.assertNotIn("TESTRULESET-V6-LEGACY", str(facts)) + self.assertNotIn("TESTRULESET-V6-1_4PLUS", str(facts))