diff --git a/meta/runtime.yml b/meta/runtime.yml index 17bc2976..6a8be343 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -4,3 +4,4 @@ action_groups: api: - api - api_facts + - api_find_and_modify diff --git a/plugins/module_utils/_api_data.py b/plugins/module_utils/_api_data.py new file mode 100644 index 00000000..ab771d08 --- /dev/null +++ b/plugins/module_utils/_api_data.py @@ -0,0 +1,1798 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2022, Felix Fontein (@felixfontein) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# The data inside here is private to this collection. If you use this from outside the collection, +# you are on your own. There can be random changes to its format even in bugfix releases! + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +class APIData(object): + def __init__(self, primary_keys=None, + stratify_keys=None, + has_identifier=False, + single_value=False, + unknown_mechanism=False, + fully_understood=False, + fixed_entries=False, + fields=None): + if sum([primary_keys is not None, stratify_keys is not None, has_identifier, single_value, unknown_mechanism]) > 1: + raise ValueError('primary_keys, stratify_keys, has_identifier, single_value, and unknown_mechanism are mutually exclusive') + if unknown_mechanism and fully_understood: + raise ValueError('unknown_mechanism and fully_understood cannot be combined') + self.primary_keys = primary_keys + self.stratify_keys = stratify_keys + self.has_identifier = has_identifier + self.single_value = single_value + self.unknown_mechanism = unknown_mechanism + self.fully_understood = fully_understood + self.fixed_entries = fixed_entries + if fixed_entries and primary_keys is None: + raise ValueError('fixed_entries can only be used with primary_keys') + if fields is None: + raise ValueError('fields must be provided') + self.fields = fields + if primary_keys: + for pk in primary_keys: + if pk not in fields: + raise ValueError('Primary key {pk} must be in fields!'.format(pk=pk)) + if stratify_keys: + for sk in stratify_keys: + if sk not in fields: + raise ValueError('Stratify key {sk} must be in fields!'.format(sk=sk)) + + +class KeyInfo(object): + def __init__(self, _dummy=None, can_disable=False, remove_value=None, default=None, required=False, automatically_computed_from=None): + if _dummy is not None: + raise ValueError('KeyInfo() does not have positional arguments') + if sum([required, default is not None, automatically_computed_from is not None, can_disable]) > 1: + raise ValueError('required, default, automatically_computed_from, and can_disable are mutually exclusive') + if not can_disable and remove_value is not None: + raise ValueError('remove_value can only be specified if can_disable=True') + self.can_disable = can_disable + self.remove_value = remove_value + self.automatically_computed_from = automatically_computed_from + self.default = default + self.required = required + + +def split_path(path): + parts = path.split() + if len(parts) == 1 and parts[0] == '': + parts = [] + return parts + + +def join_path(path): + return ' '.join(path) + + +# How to obtain this information: +# 1. Run `/export verbose` in the CLI; +# 2. All attributes listed there go into the `fields` list; +# attributes which can have a `!` ahead should have `canDisable=True` +# 3. All bold attributes go into the `primary_keys` list -- this is not always true! + +PATHS = { + ('interface', 'bridge'): APIData( + # fully_understood=True, + primary_keys=('name', ), + fields={ + 'admin-mac': KeyInfo(), + 'ageing-time': KeyInfo(default='5m'), + 'arp': KeyInfo(default='enabled'), + 'arp-timeout': KeyInfo(default='auto'), + 'auto-mac': KeyInfo(default=False), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'dhcp-snooping': KeyInfo(default=False), + 'disabled': KeyInfo(default=False), + 'fast-forward': KeyInfo(default=True), + 'forward-delay': KeyInfo(default='15s'), + 'igmp-snooping': KeyInfo(default=False), + 'max-message-age': KeyInfo(default='20s'), + 'mtu': KeyInfo(default='auto'), + 'name': KeyInfo(), + 'priority': KeyInfo(default='0x8000'), + 'protocol-mode': KeyInfo(default='rstp'), + 'transmit-hold-count': KeyInfo(default=6), + 'vlan-filtering': KeyInfo(default=False), + }, + ), + ('interface', 'ethernet'): APIData( + fixed_entries=True, + fully_understood=True, + primary_keys=('default-name', ), + fields={ + 'default-name': KeyInfo(), + 'advertise': KeyInfo(), + 'arp': KeyInfo(default='enabled'), + 'arp-timeout': KeyInfo(default='auto'), + 'auto-negotiation': KeyInfo(default=True), + 'bandwidth': KeyInfo(default='unlimited/unlimited'), + 'disabled': KeyInfo(default=False), + 'full-duplex': KeyInfo(default=True), + 'l2mtu': KeyInfo(default=1598), + 'loop-protect': KeyInfo(default='default'), + 'loop-protect-disable-time': KeyInfo(default='5m'), + 'loop-protect-send-interval': KeyInfo(default='5s'), + 'mac-address': KeyInfo(), + 'mtu': KeyInfo(default=1500), + 'name': KeyInfo(), + 'orig-mac-address': KeyInfo(), + 'rx-flow-control': KeyInfo(default='off'), + 'sfp-rate-select': KeyInfo(default='high'), + 'sfp-shutdown-temperature': KeyInfo(default='95C'), + 'speed': KeyInfo(), + 'tx-flow-control': KeyInfo(default='off'), + }, + ), + ('interface', 'list'): APIData( + primary_keys=('name', ), + fields={ + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'exclude': KeyInfo(), + 'include': KeyInfo(), + 'name': KeyInfo(), + }, + ), + ('interface', 'lte', 'apn'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'add-default-route': KeyInfo(), + 'apn': KeyInfo(), + 'default-route-distance': KeyInfo(), + 'name': KeyInfo(), + 'use-peer-dns': KeyInfo(), + }, + ), + ('interface', 'wireless', 'security-profiles'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'authentication-types': KeyInfo(), + 'disable-pmkid': KeyInfo(), + 'eap-methods': KeyInfo(), + 'group-ciphers': KeyInfo(), + 'group-key-update': KeyInfo(), + 'interim-update': KeyInfo(), + 'management-protection': KeyInfo(), + 'management-protection-key': KeyInfo(), + 'mode': KeyInfo(), + 'mschapv2-password': KeyInfo(), + 'mschapv2-username': KeyInfo(), + 'name': KeyInfo(), + 'radius-called-format': KeyInfo(), + 'radius-eap-accounting': KeyInfo(), + 'radius-mac-accounting': KeyInfo(), + 'radius-mac-authentication': KeyInfo(), + 'radius-mac-caching': KeyInfo(), + 'radius-mac-format': KeyInfo(), + 'radius-mac-mode': KeyInfo(), + 'static-algo-0': KeyInfo(), + 'static-algo-1': KeyInfo(), + 'static-algo-2': KeyInfo(), + 'static-algo-3': KeyInfo(), + 'static-key-0': KeyInfo(), + 'static-key-1': KeyInfo(), + 'static-key-2': KeyInfo(), + 'static-key-3': KeyInfo(), + 'static-sta-private-algo': KeyInfo(), + 'static-sta-private-key': KeyInfo(), + 'static-transmit-key': KeyInfo(), + 'supplicant-identity': KeyInfo(), + 'tls-certificate': KeyInfo(), + 'tls-mode': KeyInfo(), + 'unicast-ciphers': KeyInfo(), + 'wpa-pre-shared-key': KeyInfo(), + 'wpa2-pre-shared-key': KeyInfo(), + }, + ), + ('ip', 'hotspot', 'profile'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'dns-name': KeyInfo(), + 'hotspot-address': KeyInfo(), + 'html-directory': KeyInfo(), + 'html-directory-override': KeyInfo(), + 'http-cookie-lifetime': KeyInfo(), + 'http-proxy': KeyInfo(), + 'login-by': KeyInfo(), + 'name': KeyInfo(), + 'rate-limit': KeyInfo(), + 'smtp-server': KeyInfo(), + 'split-user-domain': KeyInfo(), + 'use-radius': KeyInfo(), + }, + ), + ('ip', 'hotspot', 'user', 'profile'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'add-mac-cookie': KeyInfo(), + 'address-list': KeyInfo(), + 'idle-timeout': KeyInfo(), + 'insert-queue-before': KeyInfo(can_disable=True), + 'keepalive-timeout': KeyInfo(), + 'mac-cookie-timeout': KeyInfo(), + 'name': KeyInfo(), + 'parent-queue': KeyInfo(can_disable=True), + 'queue-type': KeyInfo(can_disable=True), + 'shared-users': KeyInfo(), + 'status-autorefresh': KeyInfo(), + 'transparent-proxy': KeyInfo(), + }, + ), + ('ip', 'ipsec', 'mode-config'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'name': KeyInfo(), + 'responder': KeyInfo(), + 'use-responder-dns': KeyInfo(), + }, + ), + ('ip', 'ipsec', 'policy', 'group'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'name': KeyInfo(), + }, + ), + ('ip', 'ipsec', 'profile'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'dh-group': KeyInfo(), + 'dpd-interval': KeyInfo(), + 'dpd-maximum-failures': KeyInfo(), + 'enc-algorithm': KeyInfo(), + 'hash-algorithm': KeyInfo(), + 'lifetime': KeyInfo(), + 'name': KeyInfo(), + 'nat-traversal': KeyInfo(), + 'proposal-check': KeyInfo(), + }, + ), + ('ip', 'ipsec', 'proposal'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'auth-algorithms': KeyInfo(), + 'disabled': KeyInfo(), + 'enc-algorithms': KeyInfo(), + 'lifetime': KeyInfo(), + 'name': KeyInfo(), + 'pfs-group': KeyInfo(), + }, + ), + ('ip', 'pool'): APIData( + fully_understood=True, + primary_keys=('name', ), + fields={ + 'name': KeyInfo(), + 'ranges': KeyInfo(), + }, + ), + ('ip', 'dhcp-server'): APIData( + fully_understood=True, + primary_keys=('name', ), + fields={ + 'address-pool': KeyInfo(required=True), + 'authoritative': KeyInfo(default=True), + 'disabled': KeyInfo(default=False), + 'interface': KeyInfo(required=True), + 'lease-script': KeyInfo(default=''), + 'lease-time': KeyInfo(default='10m'), + 'name': KeyInfo(), + 'use-radius': KeyInfo(default=False), + }, + ), + ('routing', 'ospf', 'instance'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'disabled': KeyInfo(), + 'distribute-default': KeyInfo(), + 'domain-id': KeyInfo(can_disable=True), + 'domain-tag': KeyInfo(can_disable=True), + 'in-filter': KeyInfo(), + 'metric-bgp': KeyInfo(), + 'metric-connected': KeyInfo(), + 'metric-default': KeyInfo(), + 'metric-other-ospf': KeyInfo(), + 'metric-rip': KeyInfo(), + 'metric-static': KeyInfo(), + 'mpls-te-area': KeyInfo(can_disable=True), + 'mpls-te-router-id': KeyInfo(can_disable=True), + 'name': KeyInfo(), + 'out-filter': KeyInfo(), + 'redistribute-bgp': KeyInfo(), + 'redistribute-connected': KeyInfo(), + 'redistribute-other-ospf': KeyInfo(), + 'redistribute-rip': KeyInfo(), + 'redistribute-static': KeyInfo(), + 'router-id': KeyInfo(), + 'routing-table': KeyInfo(can_disable=True), + 'use-dn': KeyInfo(can_disable=True), + }, + ), + ('routing', 'ospf', 'area'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'area-id': KeyInfo(), + 'disabled': KeyInfo(), + 'instance': KeyInfo(), + 'name': KeyInfo(), + 'type': KeyInfo(), + }, + ), + ('routing', 'ospf-v3', 'instance'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'disabled': KeyInfo(), + 'distribute-default': KeyInfo(), + 'metric-bgp': KeyInfo(), + 'metric-connected': KeyInfo(), + 'metric-default': KeyInfo(), + 'metric-other-ospf': KeyInfo(), + 'metric-rip': KeyInfo(), + 'metric-static': KeyInfo(), + 'name': KeyInfo(), + 'redistribute-bgp': KeyInfo(), + 'redistribute-connected': KeyInfo(), + 'redistribute-other-ospf': KeyInfo(), + 'redistribute-rip': KeyInfo(), + 'redistribute-static': KeyInfo(), + 'router-id': KeyInfo(), + }, + ), + ('routing', 'ospf-v3', 'area'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'area-id': KeyInfo(), + 'disabled': KeyInfo(), + 'instance': KeyInfo(), + 'name': KeyInfo(), + 'type': KeyInfo(), + }, + ), + ('snmp', 'community'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'addresses': KeyInfo(), + 'authentication-password': KeyInfo(), + 'authentication-protocol': KeyInfo(), + 'disabled': KeyInfo(), + 'encryption-password': KeyInfo(), + 'encryption-protocol': KeyInfo(), + 'name': KeyInfo(), + 'read-access': KeyInfo(), + 'security': KeyInfo(), + 'write-access': KeyInfo(), + }, + ), + ('caps-man', 'aaa'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'called-format': KeyInfo(default='mac:ssid'), + 'interim-update': KeyInfo(default='disabled'), + 'mac-caching': KeyInfo(default='disabled'), + 'mac-format': KeyInfo(default='XX:XX:XX:XX:XX:XX'), + 'mac-mode': KeyInfo(default='as-username'), + }, + ), + ('caps-man', 'manager', 'interface'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'disabled': KeyInfo(), + 'forbid': KeyInfo(), + 'interface': KeyInfo(), + }, + ), + ('certificate', 'settings'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'crl-download': KeyInfo(default=False), + 'crl-store': KeyInfo(default='ram'), + 'crl-use': KeyInfo(default=False), + }, + ), + ('interface', 'bridge', 'port'): APIData( + fully_understood=True, + primary_keys=('interface', ), + fields={ + 'auto-isolate': KeyInfo(default=False), + 'bpdu-guard': KeyInfo(default=False), + 'bridge': KeyInfo(required=True), + 'broadcast-flood': KeyInfo(default=True), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'disabled': KeyInfo(default=False), + 'edge': KeyInfo(default='auto'), + 'fast-leave': KeyInfo(default=False), + 'frame-types': KeyInfo(default='admit-all'), + 'horizon': KeyInfo(default='none'), + 'hw': KeyInfo(default=True), + 'ingress-filtering': KeyInfo(default=False), + 'interface': KeyInfo(), + 'internal-path-cost': KeyInfo(default=10), + 'learn': KeyInfo(default='auto'), + 'multicast-router': KeyInfo(default='temporary-query'), + 'path-cost': KeyInfo(default=10), + 'point-to-point': KeyInfo(default='auto'), + 'priority': KeyInfo(default='0x80'), + 'pvid': KeyInfo(default=1), + 'restricted-role': KeyInfo(default=False), + 'restricted-tcn': KeyInfo(default=False), + 'tag-stacking': KeyInfo(default=False), + 'trusted': KeyInfo(default=False), + 'unknown-multicast-flood': KeyInfo(default=True), + 'unknown-unicast-flood': KeyInfo(default=True), + }, + ), + ('interface', 'bridge', 'port-controller'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'bridge': KeyInfo(default='none'), + 'cascade-ports': KeyInfo(default=''), + 'switch': KeyInfo(default='none'), + }, + ), + ('interface', 'bridge', 'port-extender'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'control-ports': KeyInfo(default=''), + 'excluded-ports': KeyInfo(default=''), + 'switch': KeyInfo(default='none'), + }, + ), + ('interface', 'bridge', 'settings'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'allow-fast-path': KeyInfo(default=True), + 'use-ip-firewall': KeyInfo(default=False), + 'use-ip-firewall-for-pppoe': KeyInfo(default=False), + 'use-ip-firewall-for-vlan': KeyInfo(default=False), + }, + ), + ('ip', 'firewall', 'connection', 'tracking'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'enabled': KeyInfo(default='auto'), + 'generic-timeout': KeyInfo(default='10m'), + 'icmp-timeout': KeyInfo(default='10s'), + 'loose-tcp-tracking': KeyInfo(default=True), + 'tcp-close-timeout': KeyInfo(default='10s'), + 'tcp-close-wait-timeout': KeyInfo(default='10s'), + 'tcp-established-timeout': KeyInfo(default='1d'), + 'tcp-fin-wait-timeout': KeyInfo(default='10s'), + 'tcp-last-ack-timeout': KeyInfo(default='10s'), + 'tcp-max-retrans-timeout': KeyInfo(default='5m'), + 'tcp-syn-received-timeout': KeyInfo(default='5s'), + 'tcp-syn-sent-timeout': KeyInfo(default='5s'), + 'tcp-time-wait-timeout': KeyInfo(default='10s'), + 'tcp-unacked-timeout': KeyInfo(default='5m'), + 'udp-stream-timeout': KeyInfo(default='3m'), + 'udp-timeout': KeyInfo(default='10s'), + }, + ), + ('ip', 'neighbor', 'discovery-settings'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'discover-interface-list': KeyInfo(), + 'lldp-med-net-policy-vlan': KeyInfo(default='disabled'), + 'protocol': KeyInfo(default='cdp,lldp,mndp'), + }, + ), + ('ip', 'settings'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'accept-redirects': KeyInfo(default=False), + 'accept-source-route': KeyInfo(default=False), + 'allow-fast-path': KeyInfo(default=True), + 'arp-timeout': KeyInfo(default='30s'), + 'icmp-rate-limit': KeyInfo(default=10), + 'icmp-rate-mask': KeyInfo(default='0x1818'), + 'ip-forward': KeyInfo(default=True), + 'max-neighbor-entries': KeyInfo(default=8192), + 'route-cache': KeyInfo(default=True), + 'rp-filter': KeyInfo(default=False), + 'secure-redirects': KeyInfo(default=True), + 'send-redirects': KeyInfo(default=True), + 'tcp-syncookies': KeyInfo(default=False), + }, + ), + ('ipv6', 'settings'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'accept-redirects': KeyInfo(default='yes-if-forwarding-disabled'), + 'accept-router-advertisements': KeyInfo(default='yes-if-forwarding-disabled'), + 'forward': KeyInfo(default=True), + 'max-neighbor-entries': KeyInfo(default=8192), + }, + ), + ('interface', 'detect-internet'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'detect-interface-list': KeyInfo(default='none'), + 'internet-interface-list': KeyInfo(default='none'), + 'lan-interface-list': KeyInfo(default='none'), + 'wan-interface-list': KeyInfo(default='none'), + }, + ), + ('interface', 'l2tp-server', 'server'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'allow-fast-path': KeyInfo(default=False), + 'authentication': KeyInfo(default='pap,chap,mschap1,mschap2'), + 'caller-id-type': KeyInfo(default='ip-address'), + 'default-profile': KeyInfo(default='default-encryption'), + 'enabled': KeyInfo(default=False), + 'ipsec-secret': KeyInfo(default=''), + 'keepalive-timeout': KeyInfo(default=30), + 'max-mru': KeyInfo(default=1450), + 'max-mtu': KeyInfo(default=1450), + 'max-sessions': KeyInfo(default='unlimited'), + 'mrru': KeyInfo(default='disabled'), + 'one-session-per-host': KeyInfo(default=False), + 'use-ipsec': KeyInfo(default=False), + }, + ), + ('interface', 'ovpn-server', 'server'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'auth': KeyInfo(), + 'cipher': KeyInfo(), + 'default-profile': KeyInfo(default='default'), + 'enabled': KeyInfo(default=False), + 'keepalive-timeout': KeyInfo(default=60), + 'mac-address': KeyInfo(), + 'max-mtu': KeyInfo(default=1500), + 'mode': KeyInfo(default='ip'), + 'netmask': KeyInfo(default=24), + 'port': KeyInfo(default=1194), + 'require-client-certificate': KeyInfo(default=False), + }, + ), + ('interface', 'pptp-server', 'server'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'authentication': KeyInfo(default='mschap1,mschap2'), + 'default-profile': KeyInfo(default='default-encryption'), + 'enabled': KeyInfo(default=False), + 'keepalive-timeout': KeyInfo(default=30), + 'max-mru': KeyInfo(default=1450), + 'max-mtu': KeyInfo(default=1450), + 'mrru': KeyInfo(default='disabled'), + }, + ), + ('interface', 'sstp-server', 'server'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'authentication': KeyInfo(default='pap,chap,mschap1,mschap2'), + 'certificate': KeyInfo(default='none'), + 'default-profile': KeyInfo(default='default'), + 'enabled': KeyInfo(default=False), + 'force-aes': KeyInfo(default=False), + 'keepalive-timeout': KeyInfo(default=60), + 'max-mru': KeyInfo(default=1500), + 'max-mtu': KeyInfo(default=1500), + 'mrru': KeyInfo(default='disabled'), + 'pfs': KeyInfo(default=False), + 'port': KeyInfo(default=443), + 'tls-version': KeyInfo(default='any'), + 'verify-client-certificate': KeyInfo(default='no'), + }, + ), + ('interface', 'wireless', 'align'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'active-mode': KeyInfo(default=True), + 'audio-max': KeyInfo(default=-20), + 'audio-min': KeyInfo(default=-100), + 'audio-monitor': KeyInfo(default='00:00:00:00:00:00'), + 'filter-mac': KeyInfo(default='00:00:00:00:00:00'), + 'frame-size': KeyInfo(default=300), + 'frames-per-second': KeyInfo(default=25), + 'receive-all': KeyInfo(default=False), + 'ssid-all': KeyInfo(default=False), + }, + ), + ('interface', 'wireless', 'cap'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'bridge': KeyInfo(default='none'), + 'caps-man-addresses': KeyInfo(default=''), + 'caps-man-certificate-common-names': KeyInfo(default=''), + 'caps-man-names': KeyInfo(default=''), + 'certificate': KeyInfo(default='none'), + 'discovery-interfaces': KeyInfo(default=''), + 'enabled': KeyInfo(default=False), + 'interfaces': KeyInfo(default=''), + 'lock-to-caps-man': KeyInfo(default=False), + 'static-virtual': KeyInfo(default=False), + }, + ), + ('interface', 'wireless', 'sniffer'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'channel-time': KeyInfo(default='200ms'), + 'file-limit': KeyInfo(default=10), + 'file-name': KeyInfo(default=''), + 'memory-limit': KeyInfo(default=10), + 'multiple-channels': KeyInfo(default=False), + 'only-headers': KeyInfo(default=False), + 'receive-errors': KeyInfo(default=False), + 'streaming-enabled': KeyInfo(default=False), + 'streaming-max-rate': KeyInfo(default=0), + 'streaming-server': KeyInfo(default='0.0.0.0'), + }, + ), + ('interface', 'wireless', 'snooper'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'channel-time': KeyInfo(default='200ms'), + 'multiple-channels': KeyInfo(default=True), + 'receive-errors': KeyInfo(default=False), + }, + ), + ('ip', 'accounting'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'account-local-traffic': KeyInfo(default=False), + 'enabled': KeyInfo(default=False), + 'threshold': KeyInfo(default=256), + }, + ), + ('ip', 'accounting', 'web-access'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'accessible-via-web': KeyInfo(default=False), + 'address': KeyInfo(default='0.0.0.0/0'), + }, + ), + ('ip', 'address'): APIData( + fully_understood=True, + primary_keys=('address', 'interface', ), + fields={ + 'address': KeyInfo(), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'disabled': KeyInfo(default=False), + 'interface': KeyInfo(), + 'network': KeyInfo(automatically_computed_from=('address', )), + }, + ), + ('ip', 'cloud'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'ddns-enabled': KeyInfo(default=False), + 'ddns-update-interval': KeyInfo(default='none'), + 'update-time': KeyInfo(default=True), + }, + ), + ('ip', 'cloud', 'advanced'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'use-local-address': KeyInfo(default=False), + }, + ), + ('ip', 'dhcp-client'): APIData( + fully_understood=True, + primary_keys=('interface', ), + fields={ + 'add-default-route': KeyInfo(default=True), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'default-route-distance': KeyInfo(default=1), + 'dhcp-options': KeyInfo(default='hostname,clientid'), + 'disabled': KeyInfo(default=False), + 'interface': KeyInfo(), + 'use-peer-dns': KeyInfo(default=True), + 'use-peer-ntp': KeyInfo(default=True), + }, + ), + ('ip', 'dhcp-server', 'config'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'accounting': KeyInfo(default=True), + 'interim-update': KeyInfo(default='0s'), + 'store-leases-disk': KeyInfo(default='5m'), + }, + ), + ('ip', 'dhcp-server', 'lease'): APIData( + fully_understood=True, + primary_keys=('server', 'address', ), + fields={ + 'address': KeyInfo(), + 'address-lists': KeyInfo(default=''), + 'always-broadcast': KeyInfo(), + 'client-id': KeyInfo(can_disable=True, remove_value=''), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'dhcp-option': KeyInfo(default=''), + 'disabled': KeyInfo(default=False), + 'insert-queue-before': KeyInfo(can_disable=True), + 'mac-address': KeyInfo(can_disable=True, remove_value=''), + 'server': KeyInfo(), + }, + ), + ('ip', 'dhcp-server', 'network'): APIData( + fully_understood=True, + primary_keys=('address', ), + fields={ + 'address': KeyInfo(), + 'caps-manager': KeyInfo(default=''), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'dhcp-option': KeyInfo(default=''), + 'dns-server': KeyInfo(default=''), + 'gateway': KeyInfo(automatically_computed_from=('address', )), + 'netmask': KeyInfo(automatically_computed_from=('address', )), + 'ntp-server': KeyInfo(default=''), + 'wins-server': KeyInfo(default=''), + }, + ), + ('ip', 'dns'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'allow-remote-requests': KeyInfo(), + 'cache-max-ttl': KeyInfo(default='1w'), + 'cache-size': KeyInfo(default='2048KiB'), + 'max-concurrent-queries': KeyInfo(default=100), + 'max-concurrent-tcp-sessions': KeyInfo(default=20), + 'max-udp-packet-size': KeyInfo(default=4096), + 'query-server-timeout': KeyInfo(default='2s'), + 'query-total-timeout': KeyInfo(default='10s'), + 'servers': KeyInfo(default=''), + 'use-doh-server': KeyInfo(default=''), + 'verify-doh-cert': KeyInfo(default=False), + }, + ), + ('ip', 'dns', 'static'): APIData( + fully_understood=True, + stratify_keys=('name', ), + fields={ + 'address': KeyInfo(), + 'cname': KeyInfo(), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'disabled': KeyInfo(default=False), + 'forward-to': KeyInfo(), + 'mx-exchange': KeyInfo(), + 'mx-preference': KeyInfo(), + 'name': KeyInfo(required=True), + 'ns': KeyInfo(), + 'srv-port': KeyInfo(), + 'srv-priority': KeyInfo(), + 'srv-target': KeyInfo(), + 'srv-weight': KeyInfo(), + 'text': KeyInfo(), + 'ttl': KeyInfo(default='1d'), + 'type': KeyInfo(), + }, + ), + ('ip', 'firewall', 'address-list'): APIData( + fully_understood=True, + primary_keys=('address', 'list', ), + fields={ + 'address': KeyInfo(), + 'disabled': KeyInfo(default=False), + 'list': KeyInfo(), + }, + ), + ('ip', 'firewall', 'filter'): APIData( + fully_understood=True, + stratify_keys=('chain', ), + fields={ + 'action': KeyInfo(), + 'chain': KeyInfo(), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'connection-bytes': KeyInfo(can_disable=True), + 'connection-limit': KeyInfo(can_disable=True), + 'connection-mark': KeyInfo(can_disable=True), + 'connection-nat-state': KeyInfo(can_disable=True), + 'connection-rate': KeyInfo(can_disable=True), + 'connection-state': KeyInfo(can_disable=True), + 'connection-type': KeyInfo(can_disable=True), + 'content': KeyInfo(can_disable=True), + 'disabled': KeyInfo(), + 'dscp': KeyInfo(can_disable=True), + 'dst-address': KeyInfo(can_disable=True), + 'dst-address-list': KeyInfo(can_disable=True), + 'dst-address-type': KeyInfo(can_disable=True), + 'dst-limit': KeyInfo(can_disable=True), + 'dst-port': KeyInfo(can_disable=True), + 'fragment': KeyInfo(can_disable=True), + 'hotspot': KeyInfo(can_disable=True), + 'icmp-options': KeyInfo(can_disable=True), + 'in-bridge-port': KeyInfo(can_disable=True), + 'in-bridge-port-list': KeyInfo(can_disable=True), + 'in-interface': KeyInfo(can_disable=True), + 'in-interface-list': KeyInfo(can_disable=True), + 'ingress-priority': KeyInfo(can_disable=True), + 'ipsec-policy': KeyInfo(can_disable=True), + 'ipv4-options': KeyInfo(can_disable=True), + 'layer7-protocol': KeyInfo(can_disable=True), + 'limit': KeyInfo(can_disable=True), + 'log': KeyInfo(), + 'log-prefix': KeyInfo(), + 'nth': KeyInfo(can_disable=True), + 'out-bridge-port': KeyInfo(can_disable=True), + 'out-bridge-port-list': KeyInfo(can_disable=True), + 'out-interface': KeyInfo(can_disable=True), + 'out-interface-list': KeyInfo(can_disable=True), + 'p2p': KeyInfo(can_disable=True), + 'packet-mark': KeyInfo(can_disable=True), + 'packet-size': KeyInfo(can_disable=True), + 'per-connection-classifier': KeyInfo(can_disable=True), + 'port': KeyInfo(can_disable=True), + 'priority': KeyInfo(can_disable=True), + 'protocol': KeyInfo(can_disable=True), + 'psd': KeyInfo(can_disable=True), + 'random': KeyInfo(can_disable=True), + 'routing-mark': KeyInfo(can_disable=True), + 'routing-table': KeyInfo(can_disable=True), + 'src-address': KeyInfo(can_disable=True), + 'src-address-list': KeyInfo(can_disable=True), + 'src-address-type': KeyInfo(can_disable=True), + 'src-mac-address': KeyInfo(can_disable=True), + 'src-port': KeyInfo(can_disable=True), + 'tcp-flags': KeyInfo(can_disable=True), + 'tcp-mss': KeyInfo(can_disable=True), + 'time': KeyInfo(can_disable=True), + 'tls-host': KeyInfo(can_disable=True), + 'ttl': KeyInfo(can_disable=True), + }, + ), + ('ip', 'firewall', 'nat'): APIData( + fully_understood=True, + stratify_keys=('chain', ), + fields={ + 'action': KeyInfo(), + 'chain': KeyInfo(), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'dst-address': KeyInfo(can_disable=True), + 'dst-port': KeyInfo(can_disable=True), + 'out-interface': KeyInfo(can_disable=True), + 'out-interface-list': KeyInfo(can_disable=True), + 'protocol': KeyInfo(can_disable=True), + 'to-addresses': KeyInfo(can_disable=True), + 'to-ports': KeyInfo(can_disable=True), + }, + ), + ('ip', 'hotspot', 'user'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'disabled': KeyInfo(), + 'name': KeyInfo(), + }, + ), + ('ip', 'ipsec', 'settings'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'accounting': KeyInfo(default=True), + 'interim-update': KeyInfo(default='0s'), + 'xauth-use-radius': KeyInfo(default=False), + }, + ), + ('ip', 'proxy'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'always-from-cache': KeyInfo(default=False), + 'anonymous': KeyInfo(default=False), + 'cache-administrator': KeyInfo(default='webmaster'), + 'cache-hit-dscp': KeyInfo(default=4), + 'cache-on-disk': KeyInfo(default=False), + 'cache-path': KeyInfo(default='web-proxy'), + 'enabled': KeyInfo(default=False), + 'max-cache-object-size': KeyInfo(default='2048KiB'), + 'max-cache-size': KeyInfo(default='unlimited'), + 'max-client-connections': KeyInfo(default=600), + 'max-fresh-time': KeyInfo(default='3d'), + 'max-server-connections': KeyInfo(default=600), + 'parent-proxy': KeyInfo(default='::'), + 'parent-proxy-port': KeyInfo(default=0), + 'port': KeyInfo(default=8080), + 'serialize-connections': KeyInfo(default=False), + 'src-address': KeyInfo(default='::'), + }, + ), + ('ip', 'smb'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'allow-guests': KeyInfo(default=True), + 'comment': KeyInfo(default='MikrotikSMB'), + 'domain': KeyInfo(default='MSHOME'), + 'enabled': KeyInfo(default=False), + 'interfaces': KeyInfo(default='all'), + }, + ), + ('ip', 'smb', 'shares'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'directory': KeyInfo(), + 'disabled': KeyInfo(), + 'max-sessions': KeyInfo(), + 'name': KeyInfo(), + }, + ), + ('ip', 'smb', 'users'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'disabled': KeyInfo(), + 'name': KeyInfo(), + 'password': KeyInfo(), + 'read-only': KeyInfo(), + }, + ), + ('ip', 'socks'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'auth-method': KeyInfo(default='none'), + 'connection-idle-timeout': KeyInfo(default='2m'), + 'enabled': KeyInfo(default=False), + 'max-connections': KeyInfo(default=200), + 'port': KeyInfo(default=1080), + 'version': KeyInfo(default=4), + }, + ), + ('ip', 'ssh'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'allow-none-crypto': KeyInfo(default=False), + 'always-allow-password-login': KeyInfo(default=False), + 'forwarding-enabled': KeyInfo(default=False), + 'host-key-size': KeyInfo(default=2048), + 'strong-crypto': KeyInfo(default=False), + }, + ), + ('ip', 'tftp', 'settings'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'max-block-size': KeyInfo(default=4096), + }, + ), + ('ip', 'traffic-flow'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'active-flow-timeout': KeyInfo(default='30m'), + 'cache-entries': KeyInfo(default='32k'), + 'enabled': KeyInfo(default=False), + 'inactive-flow-timeout': KeyInfo(default='15s'), + 'interfaces': KeyInfo(default='all'), + 'packet-sampling': KeyInfo(default=False), + 'sampling-interval': KeyInfo(default=0), + 'sampling-space': KeyInfo(default=0), + }, + ), + ('ip', 'traffic-flow', 'ipfix'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'bytes': KeyInfo(default=True), + 'dst-address': KeyInfo(default=True), + 'dst-address-mask': KeyInfo(default=True), + 'dst-mac-address': KeyInfo(default=True), + 'dst-port': KeyInfo(default=True), + 'first-forwarded': KeyInfo(default=True), + 'gateway': KeyInfo(default=True), + 'icmp-code': KeyInfo(default=True), + 'icmp-type': KeyInfo(default=True), + 'igmp-type': KeyInfo(default=True), + 'in-interface': KeyInfo(default=True), + 'ip-header-length': KeyInfo(default=True), + 'ip-total-length': KeyInfo(default=True), + 'ipv6-flow-label': KeyInfo(default=True), + 'is-multicast': KeyInfo(default=True), + 'last-forwarded': KeyInfo(default=True), + 'nat-dst-address': KeyInfo(default=True), + 'nat-dst-port': KeyInfo(default=True), + 'nat-events': KeyInfo(default=False), + 'nat-src-address': KeyInfo(default=True), + 'nat-src-port': KeyInfo(default=True), + 'out-interface': KeyInfo(default=True), + 'packets': KeyInfo(default=True), + 'protocol': KeyInfo(default=True), + 'src-address': KeyInfo(default=True), + 'src-address-mask': KeyInfo(default=True), + 'src-mac-address': KeyInfo(default=True), + 'src-port': KeyInfo(default=True), + 'sys-init-time': KeyInfo(default=True), + 'tcp-ack-num': KeyInfo(default=True), + 'tcp-flags': KeyInfo(default=True), + 'tcp-seq-num': KeyInfo(default=True), + 'tcp-window-size': KeyInfo(default=True), + 'tos': KeyInfo(default=True), + 'ttl': KeyInfo(default=True), + 'udp-length': KeyInfo(default=True), + }, + ), + ('ip', 'upnp'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'allow-disable-external-interface': KeyInfo(default=False), + 'enabled': KeyInfo(default=False), + 'show-dummy-rule': KeyInfo(default=True), + }, + ), + ('ipv6', 'dhcp-client'): APIData( + fully_understood=True, + primary_keys=('interface', 'request'), + fields={ + 'add-default-route': KeyInfo(default=False), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'default-route-distance': KeyInfo(default=1), + 'dhcp-options': KeyInfo(default=''), + 'disabled': KeyInfo(default=False), + 'interface': KeyInfo(), + 'pool-name': KeyInfo(required=True), + 'pool-prefix-length': KeyInfo(default=64), + 'prefix-hint': KeyInfo(default='::/0'), + 'request': KeyInfo(), + 'use-peer-dns': KeyInfo(default=True), + }, + ), + ('ipv6', 'firewall', 'address-list'): APIData( + fully_understood=True, + primary_keys=('address', 'list', ), + fields={ + 'address': KeyInfo(), + 'disabled': KeyInfo(default=False), + 'dynamic': KeyInfo(default=False), + 'list': KeyInfo(), + }, + ), + ('ipv6', 'firewall', 'filter'): APIData( + fully_understood=True, + stratify_keys=('chain', ), + fields={ + 'action': KeyInfo(), + 'chain': KeyInfo(), + 'comment': KeyInfo(can_disable=True, remove_value=''), + 'connection-bytes': KeyInfo(can_disable=True), + 'connection-limit': KeyInfo(can_disable=True), + 'connection-mark': KeyInfo(can_disable=True), + 'connection-rate': KeyInfo(can_disable=True), + 'connection-state': KeyInfo(can_disable=True), + 'connection-type': KeyInfo(can_disable=True), + 'content': KeyInfo(can_disable=True), + 'disabled': KeyInfo(), + 'dscp': KeyInfo(can_disable=True), + 'dst-address': KeyInfo(can_disable=True), + 'dst-address-list': KeyInfo(can_disable=True), + 'dst-address-type': KeyInfo(can_disable=True), + 'dst-limit': KeyInfo(can_disable=True), + 'dst-port': KeyInfo(can_disable=True), + 'headers': KeyInfo(can_disable=True), + 'hop-limit': KeyInfo(can_disable=True), + 'icmp-options': KeyInfo(can_disable=True), + 'in-bridge-port': KeyInfo(can_disable=True), + 'in-bridge-port-list': KeyInfo(can_disable=True), + 'in-interface': KeyInfo(can_disable=True), + 'in-interface-list': KeyInfo(can_disable=True), + 'ingress-priority': KeyInfo(can_disable=True), + 'ipsec-policy': KeyInfo(can_disable=True), + 'limit': KeyInfo(can_disable=True), + 'log': KeyInfo(), + 'log-prefix': KeyInfo(), + 'nth': KeyInfo(can_disable=True), + 'out-bridge-port': KeyInfo(can_disable=True), + 'out-bridge-port-list': KeyInfo(can_disable=True), + 'out-interface': KeyInfo(can_disable=True), + 'out-interface-list': KeyInfo(can_disable=True), + 'packet-mark': KeyInfo(can_disable=True), + 'packet-size': KeyInfo(can_disable=True), + 'per-connection-classifier': KeyInfo(can_disable=True), + 'port': KeyInfo(can_disable=True), + 'priority': KeyInfo(can_disable=True), + 'protocol': KeyInfo(can_disable=True), + 'random': KeyInfo(can_disable=True), + 'src-address': KeyInfo(can_disable=True), + 'src-address-list': KeyInfo(can_disable=True), + 'src-address-type': KeyInfo(can_disable=True), + 'src-mac-address': KeyInfo(can_disable=True), + 'src-port': KeyInfo(can_disable=True), + 'tcp-flags': KeyInfo(can_disable=True), + 'tcp-mss': KeyInfo(can_disable=True), + 'time': KeyInfo(can_disable=True), + }, + ), + ('ipv6', 'nd'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'advertise-dns': KeyInfo(), + 'advertise-mac-address': KeyInfo(), + 'disabled': KeyInfo(), + 'hop-limit': KeyInfo(), + 'interface': KeyInfo(), + 'managed-address-configuration': KeyInfo(), + 'mtu': KeyInfo(), + 'other-configuration': KeyInfo(), + 'ra-delay': KeyInfo(), + 'ra-interval': KeyInfo(), + 'ra-lifetime': KeyInfo(), + 'reachable-time': KeyInfo(), + 'retransmit-interval': KeyInfo(), + }, + ), + ('ipv6', 'nd', 'prefix', 'default'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'autonomous': KeyInfo(default=True), + 'preferred-lifetime': KeyInfo(default='1w'), + 'valid-lifetime': KeyInfo(default='4w2d'), + }, + ), + ('ipv6', 'route'): APIData( + fields={ + 'bgp-as-path': KeyInfo(can_disable=True), + 'bgp-atomic-aggregate': KeyInfo(can_disable=True), + 'bgp-communities': KeyInfo(can_disable=True), + 'bgp-local-pref': KeyInfo(can_disable=True), + 'bgp-med': KeyInfo(can_disable=True), + 'bgp-origin': KeyInfo(can_disable=True), + 'bgp-prepend': KeyInfo(can_disable=True), + 'check-gateway': KeyInfo(can_disable=True), + 'disabled': KeyInfo(), + 'distance': KeyInfo(), + 'dst-address': KeyInfo(), + 'gateway': KeyInfo(), + 'route-tag': KeyInfo(can_disable=True), + 'scope': KeyInfo(), + 'target-scope': KeyInfo(), + }, + ), + ('mpls', ): APIData( + single_value=True, + fully_understood=True, + fields={ + 'allow-fast-path': KeyInfo(default=True), + 'dynamic-label-range': KeyInfo(default='16-1048575'), + 'propagate-ttl': KeyInfo(default=True), + }, + ), + ('mpls', 'interface'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'disabled': KeyInfo(), + 'interface': KeyInfo(), + 'mpls-mtu': KeyInfo(), + }, + ), + ('mpls', 'ldp'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'distribute-for-default-route': KeyInfo(default=False), + 'enabled': KeyInfo(default=False), + 'hop-limit': KeyInfo(default=255), + 'loop-detect': KeyInfo(default=False), + 'lsr-id': KeyInfo(default='0.0.0.0'), + 'path-vector-limit': KeyInfo(default=255), + 'transport-address': KeyInfo(default='0.0.0.0'), + 'use-explicit-null': KeyInfo(default=False), + }, + ), + ('port', 'firmware'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'directory': KeyInfo(default='firmware'), + 'ignore-directip-modem': KeyInfo(default=False), + }, + ), + ('ppp', 'aaa'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'accounting': KeyInfo(default=True), + 'interim-update': KeyInfo(default='0s'), + 'use-circuit-id-in-nas-port-id': KeyInfo(default=False), + 'use-radius': KeyInfo(default=False), + }, + ), + ('radius', 'incoming'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'accept': KeyInfo(default=False), + 'port': KeyInfo(default=3799), + }, + ), + ('routing', 'bfd', 'interface'): APIData( + unknown_mechanism=True, + # primary_keys=('default', ), + fields={ + 'default': KeyInfo(), + 'disabled': KeyInfo(), + 'interface': KeyInfo(), + 'interval': KeyInfo(), + 'min-rx': KeyInfo(), + 'multiplier': KeyInfo(), + }, + ), + ('routing', 'mme'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'bidirectional-timeout': KeyInfo(default=2), + 'gateway-class': KeyInfo(default='none'), + 'gateway-keepalive': KeyInfo(default='1m'), + 'gateway-selection': KeyInfo(default='no-gateway'), + 'origination-interval': KeyInfo(default='5s'), + 'preferred-gateway': KeyInfo(default='0.0.0.0'), + 'timeout': KeyInfo(default='1m'), + 'ttl': KeyInfo(default=50), + }, + ), + ('routing', 'rip'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'distribute-default': KeyInfo(default='never'), + 'garbage-timer': KeyInfo(default='2m'), + 'metric-bgp': KeyInfo(default=1), + 'metric-connected': KeyInfo(default=1), + 'metric-default': KeyInfo(default=1), + 'metric-ospf': KeyInfo(default=1), + 'metric-static': KeyInfo(default=1), + 'redistribute-bgp': KeyInfo(default=False), + 'redistribute-connected': KeyInfo(default=False), + 'redistribute-ospf': KeyInfo(default=False), + 'redistribute-static': KeyInfo(default=False), + 'routing-table': KeyInfo(default='main'), + 'timeout-timer': KeyInfo(default='3m'), + 'update-timer': KeyInfo(default='30s'), + }, + ), + ('routing', 'ripng'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'distribute-default': KeyInfo(default='never'), + 'garbage-timer': KeyInfo(default='2m'), + 'metric-bgp': KeyInfo(default=1), + 'metric-connected': KeyInfo(default=1), + 'metric-default': KeyInfo(default=1), + 'metric-ospf': KeyInfo(default=1), + 'metric-static': KeyInfo(default=1), + 'redistribute-bgp': KeyInfo(default=False), + 'redistribute-connected': KeyInfo(default=False), + 'redistribute-ospf': KeyInfo(default=False), + 'redistribute-static': KeyInfo(default=False), + 'timeout-timer': KeyInfo(default='3m'), + 'update-timer': KeyInfo(default='30s'), + }, + ), + ('snmp', ): APIData( + single_value=True, + fully_understood=True, + fields={ + 'contact': KeyInfo(default=''), + 'enabled': KeyInfo(default=False), + 'engine-id': KeyInfo(default=''), + 'location': KeyInfo(default=''), + 'src-address': KeyInfo(default='::'), + 'trap-community': KeyInfo(default='public'), + 'trap-generators': KeyInfo(default='temp-exception'), + 'trap-target': KeyInfo(default=''), + 'trap-version': KeyInfo(default=1), + }, + ), + ('system', 'clock'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'time-zone-autodetect': KeyInfo(default=True), + 'time-zone-name': KeyInfo(default='manual'), + }, + ), + ('system', 'clock', 'manual'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'dst-delta': KeyInfo(default='00:00'), + 'dst-end': KeyInfo(default='jan/01/1970 00:00:00'), + 'dst-start': KeyInfo(default='jan/01/1970 00:00:00'), + 'time-zone': KeyInfo(default='+00:00'), + }, + ), + ('system', 'identity'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'name': KeyInfo(default='Mikrotik'), + }, + ), + ('system', 'leds', 'settings'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'all-leds-off': KeyInfo(default='never'), + }, + ), + ('system', 'note'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'note': KeyInfo(default=''), + 'show-at-login': KeyInfo(default=True), + }, + ), + ('system', 'ntp', 'client'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'enabled': KeyInfo(default=False), + 'primary-ntp': KeyInfo(default='0.0.0.0'), + 'secondary-ntp': KeyInfo(default='0.0.0.0'), + 'server-dns-names': KeyInfo(default=''), + }, + ), + ('system', 'package', 'update'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'channel': KeyInfo(default='stable'), + }, + ), + ('system', 'routerboard', 'settings'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'auto-upgrade': KeyInfo(default=False), + 'baud-rate': KeyInfo(default=115200), + 'boot-delay': KeyInfo(default='2s'), + 'boot-device': KeyInfo(default='nand-if-fail-then-ethernet'), + 'boot-protocol': KeyInfo(default='bootp'), + 'enable-jumper-reset': KeyInfo(default=True), + 'enter-setup-on': KeyInfo(default='any-key'), + 'force-backup-booter': KeyInfo(default=False), + 'protected-routerboot': KeyInfo(default='disabled'), + 'reformat-hold-button': KeyInfo(default='20s'), + 'reformat-hold-button-max': KeyInfo(default='10m'), + 'silent-boot': KeyInfo(default=False), + }, + ), + ('system', 'upgrade', 'mirror'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'check-interval': KeyInfo(default='1d'), + 'enabled': KeyInfo(default=False), + 'primary-server': KeyInfo(default='0.0.0.0'), + 'secondary-server': KeyInfo(default='0.0.0.0'), + 'user': KeyInfo(default=''), + }, + ), + ('system', 'watchdog'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'auto-send-supout': KeyInfo(default=False), + 'automatic-supout': KeyInfo(default=True), + 'ping-start-after-boot': KeyInfo(default='5m'), + 'ping-timeout': KeyInfo(default='1m'), + 'watch-address': KeyInfo(default='none'), + 'watchdog-timer': KeyInfo(default=True), + }, + ), + ('tool', 'bandwidth-server'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'allocate-udp-ports-from': KeyInfo(default=2000), + 'authenticate': KeyInfo(default=True), + 'enabled': KeyInfo(default=True), + 'max-sessions': KeyInfo(default=100), + }, + ), + ('tool', 'e-mail'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'address': KeyInfo(default='0.0.0.0'), + 'from': KeyInfo(default='<>'), + 'password': KeyInfo(default=''), + 'port': KeyInfo(default=25), + 'start-tls': KeyInfo(default=False), + 'user': KeyInfo(default=''), + }, + ), + ('tool', 'graphing'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'page-refresh': KeyInfo(default=300), + 'store-every': KeyInfo(default='5min'), + }, + ), + ('tool', 'mac-server'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'allowed-interface-list': KeyInfo(), + }, + ), + ('tool', 'mac-server', 'mac-winbox'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'allowed-interface-list': KeyInfo(), + }, + ), + ('tool', 'mac-server', 'ping'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'enabled': KeyInfo(default=True), + }, + ), + ('tool', 'romon'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'enabled': KeyInfo(default=False), + 'id': KeyInfo(default='00:00:00:00:00:00'), + 'secrets': KeyInfo(default=''), + }, + ), + ('tool', 'romon', 'port'): APIData( + fields={ + 'cost': KeyInfo(), + 'disabled': KeyInfo(), + 'forbid': KeyInfo(), + 'interface': KeyInfo(), + 'secrets': KeyInfo(), + }, + ), + ('tool', 'sms'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'allowed-number': KeyInfo(default=''), + 'auto-erase': KeyInfo(default=False), + 'channel': KeyInfo(default=0), + 'port': KeyInfo(default='none'), + 'receive-enabled': KeyInfo(default=False), + 'secret': KeyInfo(default=''), + 'sim-pin': KeyInfo(default=''), + }, + ), + ('tool', 'sniffer'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'file-limit': KeyInfo(default='1000KiB'), + 'file-name': KeyInfo(default=''), + 'filter-cpu': KeyInfo(default=''), + 'filter-direction': KeyInfo(default='any'), + 'filter-interface': KeyInfo(default=''), + 'filter-ip-address': KeyInfo(default=''), + 'filter-ip-protocol': KeyInfo(default=''), + 'filter-ipv6-address': KeyInfo(default=''), + 'filter-mac-address': KeyInfo(default=''), + 'filter-mac-protocol': KeyInfo(default=''), + 'filter-operator-between-entries': KeyInfo(default='or'), + 'filter-port': KeyInfo(default=''), + 'filter-size': KeyInfo(default=''), + 'filter-stream': KeyInfo(default=False), + 'memory-limit': KeyInfo(default='100KiB'), + 'memory-scroll': KeyInfo(default=True), + 'only-headers': KeyInfo(default=False), + 'streaming-enabled': KeyInfo(default=False), + 'streaming-server': KeyInfo(default='0.0.0.0:37008'), + }, + ), + ('tool', 'traffic-generator'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'latency-distribution-max': KeyInfo(default='100us'), + 'measure-out-of-order': KeyInfo(default=True), + 'stats-samples-to-keep': KeyInfo(default=100), + 'test-id': KeyInfo(default=0), + }, + ), + ('user', 'aaa'): APIData( + single_value=True, + fully_understood=True, + fields={ + 'accounting': KeyInfo(default=True), + 'default-group': KeyInfo(default='read'), + 'exclude-groups': KeyInfo(default=''), + 'interim-update': KeyInfo(default='0s'), + 'use-radius': KeyInfo(default=False), + }, + ), + ('queue', 'interface'): APIData( + primary_keys=('name', ), + fully_understood=True, + fields={ + 'name': KeyInfo(required=True), + 'queue': KeyInfo(required=True), + }, + ), + ('interface', 'ethernet', 'switch'): APIData( + fixed_entries=True, + primary_keys=('name', ), + fully_understood=True, + fields={ + 'cpu-flow-control': KeyInfo(default=True), + 'mirror-source': KeyInfo(default='none'), + 'mirror-target': KeyInfo(default='none'), + 'name': KeyInfo(), + }, + ), + ('interface', 'ethernet', 'switch', 'port'): APIData( + fixed_entries=True, + primary_keys=('name', ), + fully_understood=True, + fields={ + 'default-vlan-id': KeyInfo(), + 'name': KeyInfo(), + 'vlan-header': KeyInfo(default='leave-as-is'), + 'vlan-mode': KeyInfo(default='disabled'), + }, + ), + ('ip', 'dhcp-client', 'option'): APIData( + fixed_entries=True, + primary_keys=('name', ), + fully_understood=True, + fields={ + 'code': KeyInfo(), + 'name': KeyInfo(), + 'value': KeyInfo(), + }, + ), + ('ppp', 'profile'): APIData( + has_identifier=True, + fields={ + 'address-list': KeyInfo(), + 'bridge': KeyInfo(can_disable=True), + 'bridge-horizon': KeyInfo(can_disable=True), + 'bridge-learning': KeyInfo(), + 'bridge-path-cost': KeyInfo(can_disable=True), + 'bridge-port-priority': KeyInfo(can_disable=True), + 'change-tcp-mss': KeyInfo(), + 'dns-server': KeyInfo(can_disable=True), + 'idle-timeout': KeyInfo(can_disable=True), + 'incoming-filter': KeyInfo(can_disable=True), + 'insert-queue-before': KeyInfo(can_disable=True), + 'interface-list': KeyInfo(can_disable=True), + 'local-address': KeyInfo(can_disable=True), + 'name': KeyInfo(), + 'on-down': KeyInfo(), + 'on-up': KeyInfo(), + 'only-one': KeyInfo(), + 'outgoing-filter': KeyInfo(can_disable=True), + 'parent-queue': KeyInfo(can_disable=True), + 'queue-type': KeyInfo(can_disable=True), + 'rate-limit': KeyInfo(can_disable=True), + 'remote-address': KeyInfo(can_disable=True), + 'session-timeout': KeyInfo(can_disable=True), + 'use-compression': KeyInfo(), + 'use-encryption': KeyInfo(), + 'use-ipv6': KeyInfo(), + 'use-mpls': KeyInfo(), + 'use-upnp': KeyInfo(), + 'wins-server': KeyInfo(can_disable=True), + }, + ), + ('queue', 'type'): APIData( + has_identifier=True, + fields={ + 'kind': KeyInfo(), + 'mq-pfifo-limit': KeyInfo(), + 'name': KeyInfo(), + 'pcq-burst-rate': KeyInfo(), + 'pcq-burst-threshold': KeyInfo(), + 'pcq-burst-time': KeyInfo(), + 'pcq-classifier': KeyInfo(), + 'pcq-dst-address-mask': KeyInfo(), + 'pcq-dst-address6-mask': KeyInfo(), + 'pcq-limit': KeyInfo(), + 'pcq-rate': KeyInfo(), + 'pcq-src-address-mask': KeyInfo(), + 'pcq-src-address6-mask': KeyInfo(), + 'pcq-total-limit': KeyInfo(), + 'pfifo-limit': KeyInfo(), + 'red-avg-packet': KeyInfo(), + 'red-burst': KeyInfo(), + 'red-limit': KeyInfo(), + 'red-max-threshold': KeyInfo(), + 'red-min-threshold': KeyInfo(), + 'sfq-allot': KeyInfo(), + 'sfq-perturb': KeyInfo(), + }, + ), + ('routing', 'bgp', 'instance'): APIData( + fixed_entries=True, + primary_keys=('name', ), + fully_understood=True, + fields={ + 'as': KeyInfo(), + 'client-to-client-reflection': KeyInfo(), + 'cluster-id': KeyInfo(can_disable=True), + 'confederation': KeyInfo(can_disable=True), + 'disabled': KeyInfo(), + 'ignore-as-path-len': KeyInfo(), + 'name': KeyInfo(), + 'out-filter': KeyInfo(), + 'redistribute-connected': KeyInfo(), + 'redistribute-ospf': KeyInfo(), + 'redistribute-other-bgp': KeyInfo(), + 'redistribute-rip': KeyInfo(), + 'redistribute-static': KeyInfo(), + 'router-id': KeyInfo(), + 'routing-table': KeyInfo(), + }, + ), + ('system', 'logging', 'action'): APIData( + has_identifier=True, + fields={ + 'bsd-syslog': KeyInfo(), + 'disk-file-count': KeyInfo(), + 'disk-file-name': KeyInfo(), + 'disk-lines-per-file': KeyInfo(), + 'disk-stop-on-full': KeyInfo(), + 'memory-lines': KeyInfo(), + 'memory-stop-on-full': KeyInfo(), + 'name': KeyInfo(), + 'remember': KeyInfo(), + 'remote': KeyInfo(), + 'remote-port': KeyInfo(), + 'src-address': KeyInfo(), + 'syslog-facility': KeyInfo(), + 'syslog-severity': KeyInfo(), + 'syslog-time-format': KeyInfo(), + 'target': KeyInfo(), + }, + ), + ('user', 'group'): APIData( + fixed_entries=True, + primary_keys=('name', ), + fully_understood=True, + fields={ + 'name': KeyInfo(), + 'policy': KeyInfo(), + 'skin': KeyInfo(default='default'), + }, + ), + ('caps-man', 'manager'): APIData( + single_value=True, + fields={ + 'ca-certificate': KeyInfo(default='none'), + 'certificate': KeyInfo(default='none'), + 'enabled': KeyInfo(default=False), + 'package-path': KeyInfo(default=''), + 'require-peer-certificate': KeyInfo(default=False), + 'upgrade-policy': KeyInfo(default='none'), + }, + ), + ('ip', 'firewall', 'service-port'): APIData( + primary_keys=('name', ), + fully_understood=True, + fields={ + 'disabled': KeyInfo(default=False), + 'name': KeyInfo(), + 'ports': KeyInfo(), + 'sip-direct-media': KeyInfo(), + 'sip-timeout': KeyInfo(), + }, + ), + ('ip', 'hotspot', 'service-port'): APIData( + fixed_entries=True, + primary_keys=('name', ), + fully_understood=True, + fields={ + 'disabled': KeyInfo(default=False), + 'name': KeyInfo(), + 'ports': KeyInfo(), + }, + ), + ('ip', 'ipsec', 'policy'): APIData( + has_identifier=True, + fields={ + 'disabled': KeyInfo(), + 'dst-address': KeyInfo(), + 'group': KeyInfo(), + 'proposal': KeyInfo(), + 'protocol': KeyInfo(), + 'src-address': KeyInfo(), + 'template': KeyInfo(), + }, + ), + ('ip', 'service'): APIData( + fixed_entries=True, + primary_keys=('name', ), + fully_understood=True, + fields={ + 'address': KeyInfo(), + 'certificate': KeyInfo(), + 'disabled': KeyInfo(default=False), + 'name': KeyInfo(), + 'port': KeyInfo(), + 'tls-version': KeyInfo(), + }, + ), + ('system', 'logging'): APIData( + has_identifier=True, + fields={ + 'action': KeyInfo(), + 'disabled': KeyInfo(), + 'prefix': KeyInfo(), + 'topics': KeyInfo(), + }, + ), + ('system', 'resource', 'irq'): APIData( + has_identifier=True, + fields={ + 'cpu': KeyInfo(), + }, + ), +} diff --git a/plugins/modules/api_find_and_modify.py b/plugins/modules/api_find_and_modify.py new file mode 100644 index 00000000..0d4a2828 --- /dev/null +++ b/plugins/modules/api_find_and_modify.py @@ -0,0 +1,312 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2022, Felix Fontein +# GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: api_find_and_modify +author: + - "Felix Fontein (@felixfontein)" +short_description: Find and modify information using the API +version_added: 2.1.0 +description: + - Allows to find entries for a path by conditions and modify the values of these entries. +notes: + - "If you want to change values based on their old values (like change all comments 'foo' to 'bar') and make sure that + there are at least N such values, you can use I(require_matches_min=N) together with I(allow_no_matches=true). + This will make the module fail if there are less than N such entries, but not if there is no match. The latter case + is needed for idempotency of the task: once the values have been changed, there should be no further match." + - Supports I(check_mode). +extends_documentation_fragment: + - community.routeros.api +options: + path: + description: + - Path to query. + - An example value is C(ip address). This is equivalent to running C(/ip address) in the RouterOS CLI. + required: true + type: str + find: + description: + - Fields to search for. + - The module will only consider entries in the given I(path) that match all fields provided here. + - Use YAML C(~), or prepend keys with C(!), to specify an unset value. + - Note that if the dictionary specified here is empty, every entry in the path will be matched. + required: true + type: dict + values: + description: + - On all entries matching the conditions in I(find), set the keys of this option to the values specified here. + - Use YAML C(~), or prepend keys with C(!), to specify to unset a value. + required: true + type: dict + require_matches_min: + description: + - Make sure that there are no less matches than this number. + - If there are less matches, fail instead of modifying anything. + type: int + default: 0 + require_matches_max: + description: + - Make sure that there are no more matches than this number. + - If there are more matches, fail instead of modifying anything. + - If not specified, there is no upper limit. + type: int + allow_no_matches: + description: + - Whether to allow that no match is found. + - If not specified, this value is induced from whether I(require_matches_min) is 0 or larger. + type: bool +seealso: + - module: community.routeros.api +''' + +EXAMPLES = ''' +--- +- name: Rename bridge from 'bridge' to 'my-bridge' + community.routeros.api_find_and_modify: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: interface bridge + find: + name: bridge + values: + name: my-bridge + +- name: Change IP address to 192.168.1.1 for interface bridge - assuming there is only one + community.routeros.api_find_and_modify: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: ip address + find: + interface: bridge + values: + address: "192.168.1.1/24" + # If there are zero entries, or more than one: fail! We expected that + # exactly one is configured. + require_matches_min: 1 + require_matches_max: 1 +''' + +RETURN = ''' +--- +old_data: + description: + - A list of all elements for the current path before a change was made. + sample: + - '.id': '*1' + actual-interface: bridge + address: "192.168.88.1/24" + comment: defconf + disabled: false + dynamic: false + interface: bridge + invalid: false + network: 192.168.88.0 + type: list + elements: dict + returned: success +new_data: + description: + - A list of all elements for the current path after a change was made. + sample: + - '.id': '*1' + actual-interface: bridge + address: "192.168.1.1/24" + comment: awesome + disabled: false + dynamic: false + interface: bridge + invalid: false + network: 192.168.1.0 + type: list + elements: dict + returned: success +match_count: + description: + - The number of entries that matched the criteria in I(find). + sample: 1 + type: int + returned: success +modify__count: + description: + - The number of entries that were modified. + sample: 1 + type: int + returned: success +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.routeros.plugins.module_utils.api import ( + api_argument_spec, + check_has_library, + create_api, +) + +from ansible_collections.community.routeros.plugins.module_utils._api_data import ( + split_path, +) + +try: + from librouteros.exceptions import LibRouterosError +except Exception: + # Handled in api module_utils + pass + + +def compose_api_path(api, path): + api_path = api.path() + for p in path: + api_path = api_path.join(p) + return api_path + + +DISABLED_MEANS_EMPTY_STRING = ('comment', ) + + +def main(): + module_args = dict( + path=dict(type='str', required=True), + find=dict(type='dict', required=True), + values=dict(type='dict', required=True), + require_matches_min=dict(type='int', default=0), + require_matches_max=dict(type='int'), + allow_no_matches=dict(type='bool'), + ) + module_args.update(api_argument_spec()) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + if module.params['allow_no_matches'] is None: + module.params['allow_no_matches'] = module.params['require_matches_min'] <= 0 + + find = module.params['find'] + for key, value in sorted(find.items()): + if key.startswith('!'): + key = key[1:] + if value not in (None, ''): + module.fail_json(msg='The value for "!{key}" in `find` must not be non-trivial!'.format(key=key)) + if key in find: + module.fail_json(msg='`find` must not contain both "{key}" and "!{key}"!'.format(key=key)) + values = module.params['values'] + for key, value in sorted(values.items()): + if key.startswith('!'): + key = key[1:] + if value not in (None, ''): + module.fail_json(msg='The value for "!{key}" in `values` must not be non-trivial!'.format(key=key)) + if key in values: + module.fail_json(msg='`values` must not contain both "{key}" and "!{key}"!'.format(key=key)) + + check_has_library(module) + api = create_api(module) + + path = split_path(module.params['path']) + + api_path = compose_api_path(api, path) + + old_data = list(api_path) + new_data = [entry.copy() for entry in old_data] + + # Find matching entries + matching_entries = [] + for index, entry in enumerate(new_data): + matches = True + for key, value in find.items(): + if key.startswith('!'): + # Allow to specify keys that should not be present by prepending '!' + key = key[1:] + value = None + current_value = entry.get(key) + if key in DISABLED_MEANS_EMPTY_STRING and value == '' and current_value is None: + current_value = value + if current_value != value: + matches = False + break + if matches: + matching_entries.append((index, entry)) + + # Check whether the correct amount of entries was found + if matching_entries: + if len(matching_entries) < module.params['require_matches_min']: + module.fail_json(msg='Found %d entries, but expected at least %d' % (len(matching_entries), module.params['require_matches_min'])) + if module.params['require_matches_max'] is not None and len(matching_entries) > module.params['require_matches_max']: + module.fail_json(msg='Found %d entries, but expected at most %d' % (len(matching_entries), module.params['require_matches_max'])) + elif not module.params['allow_no_matches']: + module.fail_json(msg='Found no entries, but allow_no_matches=false') + + # Identify entries to update + modifications = [] + for index, entry in matching_entries: + modification = {} + for key, value in values.items(): + if key.startswith('!'): + # Allow to specify keys to remove by prepending '!' + key = key[1:] + value = None + current_value = entry.get(key) + if key in DISABLED_MEANS_EMPTY_STRING and value == '' and current_value is None: + current_value = value + if current_value != value: + if value is None: + disable_key = '!%s' % key + if key in DISABLED_MEANS_EMPTY_STRING: + disable_key = key + modification[disable_key] = '' + entry.pop(key, None) + else: + modification[key] = value + entry[key] = value + if modification: + if '.id' in entry: + modification['.id'] = entry['.id'] + modifications.append(modification) + + # Apply changes + if not module.check_mode and modifications: + for modification in modifications: + try: + api_path.update(**modification) + except LibRouterosError as e: + module.fail_json( + msg='Error while modifying for .id={id}: {error}'.format( + id=modification['.id'], + error=to_native(e), + ) + ) + new_data = list(api_path) + + # Produce return value + more = {} + if module._diff: + # Only include the matching values + more['diff'] = { + 'before': { + 'values': [old_data[index] for index, entry in matching_entries], + }, + 'after': { + 'values': [entry for index, entry in matching_entries], + }, + } + module.exit_json( + changed=bool(modifications), + old_data=old_data, + new_data=new_data, + match_count=len(matching_entries), + modify_count=len(modifications), + **more + ) + + +if __name__ == '__main__': + main() diff --git a/tests/unit/plugins/module_utils/test__api_data.py b/tests/unit/plugins/module_utils/test__api_data.py new file mode 100644 index 00000000..5053ca9b --- /dev/null +++ b/tests/unit/plugins/module_utils/test__api_data.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Felix Fontein (@felixfontein) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import pytest + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.routeros.plugins.module_utils._api_data import ( + APIData, + KeyInfo, + split_path, + join_path, +) + + +def test_api_data_errors(): + with pytest.raises(ValueError) as exc: + APIData() + assert exc.value.args[0] == 'fields must be provided' + + values = [ + ('primary_keys', []), + ('stratify_keys', []), + ('has_identifier', True), + ('single_value', True), + ('unknown_mechanism', True), + ] + + for index, (param, param_value) in enumerate(values): + for param2, param2_value in values[index + 1:]: + with pytest.raises(ValueError) as exc: + APIData(**{param: param_value, param2: param2_value}) + assert exc.value.args[0] == 'primary_keys, stratify_keys, has_identifier, single_value, and unknown_mechanism are mutually exclusive' + + with pytest.raises(ValueError) as exc: + APIData(unknown_mechanism=True, fully_understood=True) + assert exc.value.args[0] == 'unknown_mechanism and fully_understood cannot be combined' + + with pytest.raises(ValueError) as exc: + APIData(unknown_mechanism=True, fixed_entries=True) + assert exc.value.args[0] == 'fixed_entries can only be used with primary_keys' + + with pytest.raises(ValueError) as exc: + APIData(primary_keys=['foo'], fields={}) + assert exc.value.args[0] == 'Primary key foo must be in fields!' + + with pytest.raises(ValueError) as exc: + APIData(stratify_keys=['foo'], fields={}) + assert exc.value.args[0] == 'Stratify key foo must be in fields!' + + +def test_key_info_errors(): + values = [ + ('required', True), + ('default', ''), + ('automatically_computed_from', ()), + ('can_disable', True), + ] + + for index, (param, param_value) in enumerate(values): + for param2, param2_value in values[index + 1:]: + with pytest.raises(ValueError) as exc: + KeyInfo(**{param: param_value, param2: param2_value}) + assert exc.value.args[0] == 'required, default, automatically_computed_from, and can_disable are mutually exclusive' + + with pytest.raises(ValueError) as exc: + KeyInfo('foo') + assert exc.value.args[0] == 'KeyInfo() does not have positional arguments' + + with pytest.raises(ValueError) as exc: + KeyInfo(remove_value='') + assert exc.value.args[0] == 'remove_value can only be specified if can_disable=True' + + +SPLITTED_PATHS = [ + ('', [], ''), + (' ip ', ['ip'], 'ip'), + ('ip', ['ip'], 'ip'), + (' ip \t\n\raddress ', ['ip', 'address'], 'ip address'), +] + + +@pytest.mark.parametrize("joined_input, splitted, joined_output", SPLITTED_PATHS) +def test_join_split_path(joined_input, splitted, joined_output): + assert split_path(joined_input) == splitted + assert join_path(splitted) == joined_output diff --git a/tests/unit/plugins/modules/fake_api.py b/tests/unit/plugins/modules/fake_api.py index cef65867..edfa28a9 100644 --- a/tests/unit/plugins/modules/fake_api.py +++ b/tests/unit/plugins/modules/fake_api.py @@ -17,6 +17,8 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type +from ansible_collections.community.routeros.plugins.module_utils._api_data import PATHS + class FakeLibRouterosError(Exception): def __init__(self, message): @@ -125,3 +127,110 @@ def __init__(self, *args): def str_return(self): return repr(self.args) + + +def _normalize_entry(entry, path_info): + for key, data in path_info.fields.items(): + if key not in entry and data.default is not None: + entry[key] = data.default + if data.can_disable: + if key in entry and entry[key] in (None, data.remove_value): + del entry[key] + if ('!%s' % key) in entry: + entry.pop(key, None) + del entry['!%s' % key] + + +def massage_expected_result_data(values, path, keep_all=False): + path_info = PATHS[path] + values = [entry.copy() for entry in values] + for entry in values: + _normalize_entry(entry, path_info) + if not keep_all: + for key in list(entry): + if key == '.id' or key in path_info.fields: + continue + del entry[key] + return values + + +class Path(object): + def __init__(self, path, initial_values, read_only=False): + self._path = path + self._path_info = PATHS[path] + self._values = [entry.copy() for entry in initial_values] + for entry in self._values: + _normalize_entry(entry, self._path_info) + self._new_id_counter = 0 + self._read_only = read_only + + def __iter__(self): + return [entry.copy() for entry in self._values].__iter__() + + def _find_id(self, id, required=False): + for index, entry in enumerate(self._values): + if entry['.id'] == id: + return index + if required: + raise FakeLibRouterosError('Cannot find key "%s"' % id) + return None + + def add(self, **kwargs): + if self._path_info.fixed_entries or self._path_info.single_value: + raise Exception('Cannot add entries') + if self._read_only: + raise Exception('Modifying read-only path: add %s' % repr(kwargs)) + if '.id' in kwargs: + raise Exception('Trying to create new entry with ".id" field: %s' % repr(kwargs)) + self._new_id_counter += 1 + id = '*NEW%d' % self._new_id_counter + entry = { + '.id': id, + } + entry.update(kwargs) + _normalize_entry(entry, self._path_info) + self._values.append(entry) + return id + + def remove(self, *args): + if self._path_info.fixed_entries or self._path_info.single_value: + raise Exception('Cannot remove entries') + if self._read_only: + raise Exception('Modifying read-only path: remove %s' % repr(args)) + for id in args: + index = self._find_id(id, required=True) + del self._values[index] + + def update(self, **kwargs): + if self._read_only: + raise Exception('Modifying read-only path: update %s' % repr(kwargs)) + if self._path_info.single_value: + index = 0 + else: + index = self._find_id(kwargs['.id'], required=True) + entry = self._values[index] + entry.update(kwargs) + _normalize_entry(entry, self._path_info) + + def __call__(self, command, *args, **kwargs): + if self._read_only: + raise Exception('Modifying read-only path: "%s" %s %s' % (command, repr(args), repr(kwargs))) + if command != 'move': + raise FakeLibRouterosError('Unsupported command "%s"' % command) + if self._path_info.fixed_entries or self._path_info.single_value: + raise Exception('Cannot move entries') + yield None # make sure that nothing happens if the result isn't consumed + source_index = self._find_id(kwargs.pop('numbers'), required=True) + entry = self._values.pop(source_index) + dest_index = self._find_id(kwargs.pop('destination'), required=True) + self._values.insert(dest_index, entry) + + +def create_fake_path(path, initial_values, read_only=False): + def create(api, called_path): + called_path = tuple(called_path) + if path != called_path: + raise AssertionError('Expected {path}, got {called_path}'.format(path=path, called_path=called_path)) + return Path(path, initial_values, read_only=read_only) + + return create diff --git a/tests/unit/plugins/modules/test_api_find_and_modify.py b/tests/unit/plugins/modules/test_api_find_and_modify.py new file mode 100644 index 00000000..5655d028 --- /dev/null +++ b/tests/unit/plugins/modules/test_api_find_and_modify.py @@ -0,0 +1,666 @@ +# 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 + +import json +import pytest + +from ansible_collections.community.routeros.tests.unit.compat.mock import patch, MagicMock +from ansible_collections.community.routeros.tests.unit.plugins.modules.fake_api import ( + FakeLibRouterosError, fake_ros_api, massage_expected_result_data, create_fake_path, +) +from ansible_collections.community.routeros.tests.unit.plugins.modules.utils import set_module_args, AnsibleExitJson, AnsibleFailJson, ModuleTestCase +from ansible_collections.community.routeros.plugins.module_utils._api_data import PATHS +from ansible_collections.community.routeros.plugins.modules import api_find_and_modify + + +START_IP_DNS_STATIC = [ + { + '.id': '*1', + 'comment': 'defconf', + 'name': 'router', + 'address': '192.168.88.1', + 'dynamic': False, + }, + { + '.id': '*A', + 'name': 'router', + 'text': 'Router Text Entry', + 'dynamic': False, + }, + { + '.id': '*7', + 'comment': '', + 'name': 'foo', + 'address': '192.168.88.2', + 'dynamic': False, + }, +] + +START_IP_DNS_STATIC_OLD_DATA = massage_expected_result_data(START_IP_DNS_STATIC, ('ip', 'dns', 'static'), keep_all=True) + +START_IP_FIREWALL_FILTER = [ + { + '.id': '*2', + 'action': 'accept', + 'chain': 'input', + 'comment': 'defconf', + 'protocol': 'icmp', + }, + { + '.id': '*3', + 'action': 'accept', + 'chain': 'input', + 'comment': 'defconf', + 'connection-state': 'established', + }, + { + '.id': '*4', + 'action': 'accept', + 'chain': 'input', + 'comment': 'defconf', + 'connection-state': 'related', + }, + { + '.id': '*7', + 'action': 'drop', + 'chain': 'input', + 'comment': 'defconf', + 'in-interface': 'wan', + }, + { + '.id': '*8', + 'action': 'accept', + 'chain': 'forward', + 'comment': 'defconf', + 'connection-state': 'established', + }, + { + '.id': '*9', + 'action': 'accept', + 'chain': 'forward', + 'comment': 'defconf', + 'connection-state': 'related', + }, + { + '.id': '*A', + 'action': 'drop', + 'chain': 'forward', + 'comment': 'defconf', + 'connection-status': 'invalid', + }, +] + +START_IP_FIREWALL_FILTER_OLD_DATA = massage_expected_result_data(START_IP_FIREWALL_FILTER, ('ip', 'firewall', 'filter'), keep_all=True) + + +class TestRouterosApiFindAndModifyModule(ModuleTestCase): + + def setUp(self): + super(TestRouterosApiFindAndModifyModule, self).setUp() + self.module = api_find_and_modify + self.module.LibRouterosError = FakeLibRouterosError + self.module.connect = MagicMock(new=fake_ros_api) + self.module.check_has_library = MagicMock() + self.patch_create_api = patch( + 'ansible_collections.community.routeros.plugins.modules.api_find_and_modify.create_api', + MagicMock(new=fake_ros_api)) + self.patch_create_api.start() + self.config_module_args = { + 'username': 'admin', + 'password': 'pаss', + 'hostname': '127.0.0.1', + } + + def tearDown(self): + self.patch_create_api.stop() + + def test_module_fail_when_required_args_missing(self): + with self.assertRaises(AnsibleFailJson) as exc: + set_module_args({}) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['failed'], True) + + def test_invalid_disabled_and_enabled_option_in_find(self): + with self.assertRaises(AnsibleFailJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + 'comment': 'foo', + '!comment': None, + }, + 'values': { + 'comment': 'bar', + }, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['failed'], True) + self.assertEqual(result['msg'], '`find` must not contain both "comment" and "!comment"!') + + def test_invalid_disabled_option_invalid_value_in_find(self): + with self.assertRaises(AnsibleFailJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + '!comment': 'gone', + }, + 'values': { + 'comment': 'bar', + }, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['failed'], True) + self.assertEqual(result['msg'], 'The value for "!comment" in `find` must not be non-trivial!') + + def test_invalid_disabled_and_enabled_option_in_values(self): + with self.assertRaises(AnsibleFailJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': {}, + 'values': { + 'comment': 'foo', + '!comment': None, + }, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['failed'], True) + self.assertEqual(result['msg'], '`values` must not contain both "comment" and "!comment"!') + + def test_invalid_disabled_option_invalid_value_in_values(self): + with self.assertRaises(AnsibleFailJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': {}, + 'values': { + '!comment': 'gone', + }, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['failed'], True) + self.assertEqual(result['msg'], 'The value for "!comment" in `values` must not be non-trivial!') + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'dns', 'static'), START_IP_DNS_STATIC, read_only=True)) + def test_change_invalid_zero(self): + with self.assertRaises(AnsibleFailJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + 'name': 'bam', + }, + 'values': { + 'name': 'baz', + }, + 'require_matches_min': 10, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['failed'], True) + self.assertEqual(result['msg'], 'Found no entries, but allow_no_matches=false') + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'dns', 'static'), START_IP_DNS_STATIC, read_only=True)) + def test_change_invalid_too_few(self): + with self.assertRaises(AnsibleFailJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + 'name': 'router', + }, + 'values': { + 'name': 'foobar', + }, + 'require_matches_min': 10, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['failed'], True) + self.assertEqual(result['msg'], 'Found 2 entries, but expected at least 10') + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'dns', 'static'), START_IP_DNS_STATIC, read_only=True)) + def test_change_invalid_too_many(self): + with self.assertRaises(AnsibleFailJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + 'name': 'router', + }, + 'values': { + 'name': 'foobar', + }, + 'require_matches_max': 1, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['failed'], True) + self.assertEqual(result['msg'], 'Found 2 entries, but expected at most 1') + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'dns', 'static'), START_IP_DNS_STATIC, read_only=True)) + def test_change_idempotent_zero_matches_1(self): + with self.assertRaises(AnsibleExitJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + 'name': 'baz', + }, + 'values': { + 'name': 'bam', + }, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], False) + self.assertEqual(result['old_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['new_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['match_count'], 0) + self.assertEqual(result['modify_count'], 0) + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'dns', 'static'), START_IP_DNS_STATIC, read_only=True)) + def test_change_idempotent_zero_matches_2(self): + with self.assertRaises(AnsibleExitJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + 'name': 'baz', + }, + 'values': { + 'name': 'bam', + }, + 'require_matches_min': 2, + 'allow_no_matches': True, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], False) + self.assertEqual(result['old_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['new_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['match_count'], 0) + self.assertEqual(result['modify_count'], 0) + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'dns', 'static'), START_IP_DNS_STATIC, read_only=True)) + def test_idempotent_1(self): + with self.assertRaises(AnsibleExitJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + }, + 'values': { + }, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], False) + self.assertEqual(result['old_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['new_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['match_count'], 3) + self.assertEqual(result['modify_count'], 0) + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'dns', 'static'), START_IP_DNS_STATIC, read_only=True)) + def test_idempotent_2(self): + with self.assertRaises(AnsibleExitJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + 'name': 'foo', + }, + 'values': { + 'comment': None, + }, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], False) + self.assertEqual(result['old_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['new_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['match_count'], 1) + self.assertEqual(result['modify_count'], 0) + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'dns', 'static'), START_IP_DNS_STATIC)) + def test_change(self): + with self.assertRaises(AnsibleExitJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + 'name': 'foo', + }, + 'values': { + 'comment': 'bar', + }, + '_ansible_diff': True, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], True) + self.assertEqual(result['old_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['new_data'], [ + { + '.id': '*1', + 'comment': 'defconf', + 'name': 'router', + 'address': '192.168.88.1', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + { + '.id': '*A', + 'name': 'router', + 'text': 'Router Text Entry', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + { + '.id': '*7', + 'comment': 'bar', + 'name': 'foo', + 'address': '192.168.88.2', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + ]) + self.assertEqual(result['diff']['before']['values'], [ + { + '.id': '*7', + 'name': 'foo', + 'address': '192.168.88.2', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + ]) + self.assertEqual(result['diff']['after']['values'], [ + { + '.id': '*7', + 'comment': 'bar', + 'name': 'foo', + 'address': '192.168.88.2', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + ]) + self.assertEqual(result['match_count'], 1) + self.assertEqual(result['modify_count'], 1) + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'dns', 'static'), START_IP_DNS_STATIC)) + def test_change_remove_comment_1(self): + with self.assertRaises(AnsibleExitJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + }, + 'values': { + 'comment': None, + }, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], True) + self.assertEqual(result['old_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['new_data'], [ + { + '.id': '*1', + 'name': 'router', + 'address': '192.168.88.1', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + { + '.id': '*A', + 'name': 'router', + 'text': 'Router Text Entry', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + { + '.id': '*7', + 'name': 'foo', + 'address': '192.168.88.2', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + ]) + self.assertEqual('diff' in result, False) + self.assertEqual(result['match_count'], 3) + self.assertEqual(result['modify_count'], 1) + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'dns', 'static'), START_IP_DNS_STATIC)) + def test_change_remove_comment_2(self): + with self.assertRaises(AnsibleExitJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + }, + 'values': { + 'comment': '', + }, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], True) + self.assertEqual(result['old_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['new_data'], [ + { + '.id': '*1', + 'name': 'router', + 'address': '192.168.88.1', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + { + '.id': '*A', + 'name': 'router', + 'text': 'Router Text Entry', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + { + '.id': '*7', + 'name': 'foo', + 'address': '192.168.88.2', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + ]) + self.assertEqual(result['match_count'], 3) + self.assertEqual(result['modify_count'], 1) + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'dns', 'static'), START_IP_DNS_STATIC)) + def test_change_remove_comment_3(self): + with self.assertRaises(AnsibleExitJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dns static', + 'find': { + }, + 'values': { + '!comment': None, + }, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], True) + self.assertEqual(result['old_data'], START_IP_DNS_STATIC_OLD_DATA) + self.assertEqual(result['new_data'], [ + { + '.id': '*1', + 'name': 'router', + 'address': '192.168.88.1', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + { + '.id': '*A', + 'name': 'router', + 'text': 'Router Text Entry', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + { + '.id': '*7', + 'name': 'foo', + 'address': '192.168.88.2', + 'ttl': '1d', + 'disabled': False, + 'dynamic': False, + }, + ]) + self.assertEqual(result['match_count'], 3) + self.assertEqual(result['modify_count'], 1) + + @patch('ansible_collections.community.routeros.plugins.modules.api_find_and_modify.compose_api_path', + new=create_fake_path(('ip', 'firewall', 'filter'), START_IP_FIREWALL_FILTER)) + def test_change_remove_generic(self): + with self.assertRaises(AnsibleExitJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip firewall filter', + 'find': { + 'chain': 'input', + '!protocol': '', + }, + 'values': { + '!connection-state': None, + }, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], True) + self.assertEqual(result['old_data'], START_IP_FIREWALL_FILTER_OLD_DATA) + self.assertEqual(result['new_data'], [ + { + '.id': '*2', + 'action': 'accept', + 'chain': 'input', + 'comment': 'defconf', + 'protocol': 'icmp', + }, + { + '.id': '*3', + 'action': 'accept', + 'chain': 'input', + 'comment': 'defconf', + }, + { + '.id': '*4', + 'action': 'accept', + 'chain': 'input', + 'comment': 'defconf', + }, + { + '.id': '*7', + 'action': 'drop', + 'chain': 'input', + 'comment': 'defconf', + 'in-interface': 'wan', + }, + { + '.id': '*8', + 'action': 'accept', + 'chain': 'forward', + 'comment': 'defconf', + 'connection-state': 'established', + }, + { + '.id': '*9', + 'action': 'accept', + 'chain': 'forward', + 'comment': 'defconf', + 'connection-state': 'related', + }, + { + '.id': '*A', + 'action': 'drop', + 'chain': 'forward', + 'comment': 'defconf', + 'connection-status': 'invalid', + }, + ]) + self.assertEqual(result['match_count'], 3) + self.assertEqual(result['modify_count'], 2) diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt index f4f7b562..0b66e0a7 100644 --- a/tests/unit/requirements.txt +++ b/tests/unit/requirements.txt @@ -1 +1,2 @@ unittest2 ; python_version <= '2.6' +ordereddict ; python_version <= '2.6'