Repository: ansible/ansible

Issue:
ios_static_route support for vrf and name
<!---
Verify first that your issue/request is not already reported on GitHub.
THIS FORM WILL BE READ BY A MACHINE, COMPLETE ALL SECTIONS AS DESCRIBED.
Also test if the latest release, and devel branch are affected too.
ALWAYS add information AFTER (OUTSIDE) these html comments.
Otherwise it may end up being automatically closed by our bot. -->

##### SUMMARY
<!--- Explain the problem briefly -->
Add support to both route in VRF and name/description
##### ISSUE TYPE
 - Feature Idea

##### COMPONENT NAME
<!--- Insert, BELOW THIS COMMENT, the name of the module, plugin, task or feature.
Do not include extra details here, e.g. "vyos_command" not "the network module vyos_command" or the full path-->
ios_static_route
##### ANSIBLE VERSION
<!--- Paste, BELOW THIS COMMENT, verbatim output from "ansible --version" between quotes below -->
```
2.5.3
```

##### CONFIGURATION
<!--- If using Ansible 2.4 or above, paste, BELOW THIS COMMENT, the results of "ansible-config dump --only-changed"
Otherwise, mention any settings you have changed/added/removed in ansible.cfg
(or using the ANSIBLE_* environment variables).-->

##### OS / ENVIRONMENT
<!--- Mention, BELOW THIS COMMENT, the OS you are running Ansible from, and the OS you are
managing, or say "N/A" for anything that is not platform-specific.
Also mention the specific version of what you are trying to control,
e.g. if this is a network bug the version of firmware on the network device.-->

##### STEPS TO REPRODUCE
<!--- For bugs, show exactly how to reproduce the problem, using a minimal test-case.
For new features, show how the feature would be used. -->

<!--- Paste example playbooks or commands between quotes below -->
```yaml

```

<!--- You can also paste gist.github.com links for larger files -->

##### EXPECTED RESULTS
<!--- What did you expect to happen when running the steps above? -->
```
ip route vrf my-vrf  192.168.1.1 255.255.255.255 192.168.0.1 name my-test-route
```
##### ACTUAL RESULTS
<!--- What actually happened? If possible run with extra verbosity (-vvvv) -->

<!--- Paste verbatim command output between quotes below -->
```
ip route 192.168.1.1 255.255.255.255 192.168.0.1
```



Discussion:
Files identified in the description:
* [lib/ansible/modules/network/ios/ios_static_route.py](https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/network/ios/ios_static_route.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @privateip @rcarrillocruz
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/lib/ansible/modules/network/ios/ios_static_route.py b/lib/ansible/modules/network/ios/ios_static_route.py
--- a/lib/ansible/modules/network/ios/ios_static_route.py
+++ b/lib/ansible/modules/network/ios/ios_static_route.py
@@ -35,8 +35,6 @@
     IP routes on Cisco IOS network devices.
 notes:
   - Tested against IOS 15.6
-requirements:
-  - Python >= 3.3 or C(ipaddress) python package
 options:
   prefix:
     description:
@@ -47,9 +45,31 @@
   next_hop:
     description:
       - Next hop IP of the static route.
+  vrf:
+    description:
+      - VRF of the static route.
+    version_added: "2.8"
+  interface:
+    description:
+      - Interface of the static route.
+    version_added: "2.8"
+  name:
+    description:
+      - Name of the static route
+    aliases: ['description']
+    version_added: "2.8"
   admin_distance:
     description:
       - Admin distance of the static route.
+    default: 1
+  tag:
+    description:
+      - Set tag of the static route.
+    version_added: "2.8"
+  track:
+    description:
+      - Tracked item to depend on for the static route.
+    version_added: "2.8"
   aggregate:
     description: List of static route definitions.
   state:
@@ -67,6 +87,22 @@
     mask: 255.255.255.0
     next_hop: 10.0.0.1
 
+- name: configure black hole in vrf blue depending on tracked item 10
+  ios_static_route:
+    prefix: 192.168.2.0
+    mask: 255.255.255.0
+    vrf: blue
+    interface: null0
+    track: 10
+
+- name: configure ultimate route with name and tag
+  ios_static_route:
+    prefix: 192.168.2.0
+    mask: 255.255.255.0
+    interface: GigabitEthernet1
+    name: hello world
+    tag: 100
+
 - name: remove configuration
   ios_static_route:
     prefix: 192.168.2.0
@@ -80,7 +116,7 @@
       - { prefix: 172.16.32.0, mask: 255.255.255.0, next_hop: 10.0.0.8 }
       - { prefix: 172.16.33.0, mask: 255.255.255.0, next_hop: 10.0.0.8 }
 
-- name: Add static route aggregates
+- name: Remove static route aggregates
   ios_static_route:
     aggregate:
       - { prefix: 172.16.32.0, mask: 255.255.255.0, next_hop: 10.0.0.8 }
@@ -97,55 +133,54 @@
     - ip route 192.168.2.0 255.255.255.0 10.0.0.1
 """
 from copy import deepcopy
-import re
+from re import findall
 
-from ansible.module_utils._text import to_text
 from ansible.module_utils.basic import AnsibleModule
-from ansible.module_utils.connection import ConnectionError
-from ansible.module_utils.network.common.utils import remove_default_spec
-from ansible.module_utils.network.ios.ios import get_config, load_config, run_commands
+from ansible.module_utils.network.common.utils import remove_default_spec, validate_ip_address
+from ansible.module_utils.network.ios.ios import get_config, load_config
 from ansible.module_utils.network.ios.ios import ios_argument_spec, check_args
 
-try:
-    from ipaddress import ip_network
-    HAS_IPADDRESS = True
-except ImportError:
-    HAS_IPADDRESS = False
 
-
-def map_obj_to_commands(want, have, module):
+def map_obj_to_commands(want, have):
     commands = list()
 
     for w in want:
+        state = w['state']
+        del w['state']
         # Try to match an existing config with the desired config
         for h in have:
-            for key in ['prefix', 'mask', 'next_hop']:
-                # If any key doesn't match, skip to the next set
-                if w[key] != h[key]:
-                    break
-            # If all keys match, don't execute final else
-            else:
+            diff = list(set(w.items()) ^ set(h.items()))
+            if not diff:
+                break
+            # if route is present with name or name already starts with wanted name it will not change
+            elif len(diff) == 2 and diff[0][0] == diff[1][0] == 'name' and (not w['name'] or h['name'].startswith(w['name'])):
                 break
         # If no matches found, clear `h`
         else:
             h = None
 
+        command = 'ip route'
         prefix = w['prefix']
         mask = w['mask']
-        next_hop = w['next_hop']
-        admin_distance = w.get('admin_distance')
-        if not admin_distance and h:
-            w['admin_distance'] = admin_distance = h['admin_distance']
-        state = w['state']
-        del w['state']
-
-        if state == 'absent' and w in have:
-            commands.append('no ip route %s %s %s' % (prefix, mask, next_hop))
-        elif state == 'present' and w not in have:
-            if admin_distance:
-                commands.append('ip route %s %s %s %s' % (prefix, mask, next_hop, admin_distance))
-            else:
-                commands.append('ip route %s %s %s' % (prefix, mask, next_hop))
+        vrf = w.get('vrf')
+        if vrf:
+            command = ' '.join((command, 'vrf', vrf, prefix, mask))
+        else:
+            command = ' '.join((command, prefix, mask))
+
+        for key in ['interface', 'next_hop', 'admin_distance', 'tag', 'name', 'track']:
+            if w.get(key):
+                if key == 'name' and len(w.get(key).split()) > 1:
+                    command = ' '.join((command, key, '"%s"' % w.get(key)))  # name with multiple words needs to be quoted
+                elif key in ('name', 'tag', 'track'):
+                    command = ' '.join((command, key, w.get(key)))
+                else:
+                    command = ' '.join((command, w.get(key)))
+
+        if state == 'absent' and h:
+            commands.append('no %s' % command)
+        elif state == 'present' and not h:
+            commands.append(command)
 
     return commands
 
@@ -153,56 +188,43 @@ def map_obj_to_commands(want, have, module):
 def map_config_to_obj(module):
     obj = []
 
-    try:
-        out = run_commands(module, 'show ip static route')[0]
-        match = re.search(r'.*Static local RIB for default\s*(.*)$', out, re.DOTALL)
+    out = get_config(module, flags='| include ip route')
 
-        if match and match.group(1):
-            for r in match.group(1).splitlines():
-                splitted_line = r.split()
+    for line in out.splitlines():
+        splitted_line = findall(r'[^"\s]\S*|".+?"', line)  # Split by whitespace but do not split quotes, needed for name parameter
 
-                code = splitted_line[0]
-
-                if code != 'M':
-                    continue
-
-                cidr = ip_network(to_text(splitted_line[1]))
-                prefix = str(cidr.network_address)
-                mask = str(cidr.netmask)
-                next_hop = splitted_line[4]
-                admin_distance = splitted_line[2][1]
-
-                obj.append({
-                    'prefix': prefix, 'mask': mask, 'next_hop': next_hop,
-                    'admin_distance': admin_distance
-                })
-
-    except ConnectionError:
-        out = get_config(module, flags='| include ip route')
-
-        for line in out.splitlines():
-            splitted_line = line.split()
-            if len(splitted_line) not in (5, 6):
-                continue
-
-            prefix = splitted_line[2]
-            mask = splitted_line[3]
-            next_hop = splitted_line[4]
-            if len(splitted_line) == 6:
-                admin_distance = splitted_line[5]
+        if splitted_line[2] == 'vrf':
+            route = {'vrf': splitted_line[3]}
+            del splitted_line[:4]  # Removes the words ip route vrf vrf_name
+        else:
+            route = {}
+            del splitted_line[:2]  # Removes the words ip route
+
+        prefix = splitted_line[0]
+        mask = splitted_line[1]
+        route.update({'prefix': prefix, 'mask': mask, 'admin_distance': '1'})
+
+        next_word = None
+        for word in splitted_line[2:]:
+            if next_word:
+                route[next_word] = word.strip('"')  # Remove quotes which is needed for name
+                next_word = None
+            elif validate_ip_address(word):
+                route.update(next_hop=word)
+            elif word.isdigit():
+                route.update(admin_distance=word)
+            elif word in ('tag', 'name', 'track'):
+                next_word = word
             else:
-                admin_distance = '1'
+                route.update(interface=word)
 
-            obj.append({
-                'prefix': prefix, 'mask': mask, 'next_hop': next_hop,
-                'admin_distance': admin_distance
-            })
+        obj.append(route)
 
     return obj
 
 
 def map_params_to_obj(module, required_together=None):
-    keys = ['prefix', 'mask', 'next_hop', 'admin_distance', 'state']
+    keys = ['prefix', 'mask', 'state', 'next_hop', 'vrf', 'interface', 'name', 'admin_distance', 'track', 'tag']
     obj = []
 
     aggregate = module.params.get('aggregate')
@@ -213,21 +235,16 @@ def map_params_to_obj(module, required_together=None):
                 if route.get(key) is None:
                     route[key] = module.params.get(key)
 
+            route = dict((k, v) for k, v in route.items() if v is not None)
             module._check_required_together(required_together, route)
             obj.append(route)
     else:
         module._check_required_together(required_together, module.params)
-        obj.append({
-            'prefix': module.params['prefix'].strip(),
-            'mask': module.params['mask'].strip(),
-            'next_hop': module.params['next_hop'].strip(),
-            'admin_distance': module.params.get('admin_distance'),
-            'state': module.params['state'],
-        })
-
-    for route in obj:
-        if route['admin_distance']:
-            route['admin_distance'] = str(route['admin_distance'])
+        route = dict()
+        for key in keys:
+            if module.params.get(key) is not None:
+                route[key] = module.params.get(key)
+        obj.append(route)
 
     return obj
 
@@ -239,7 +256,12 @@ def main():
         prefix=dict(type='str'),
         mask=dict(type='str'),
         next_hop=dict(type='str'),
-        admin_distance=dict(type='int'),
+        vrf=dict(type='str'),
+        interface=dict(type='str'),
+        name=dict(type='str', aliases=['description']),
+        admin_distance=dict(type='str', default='1'),
+        track=dict(type='str'),
+        tag=dict(tag='str'),
         state=dict(default='present', choices=['present', 'absent'])
     )
 
@@ -257,7 +279,7 @@ def main():
     argument_spec.update(ios_argument_spec)
 
     required_one_of = [['aggregate', 'prefix']]
-    required_together = [['prefix', 'mask', 'next_hop']]
+    required_together = [['prefix', 'mask']]
     mutually_exclusive = [['aggregate', 'prefix']]
 
     module = AnsibleModule(argument_spec=argument_spec,
@@ -265,9 +287,6 @@ def main():
                            mutually_exclusive=mutually_exclusive,
                            supports_check_mode=True)
 
-    if not HAS_IPADDRESS:
-        module.fail_json(msg="ipaddress python package is required")
-
     warnings = list()
     check_args(module, warnings)
 
@@ -277,7 +296,7 @@ def main():
     want = map_params_to_obj(module, required_together=required_together)
     have = map_config_to_obj(module)
 
-    commands = map_obj_to_commands(want, have, module)
+    commands = map_obj_to_commands(want, have)
     result['commands'] = commands
 
     if commands:


Test patch: