Repository: ansible/ansible

Issue:
Add ability to apply commit labels on iosxr_config module
<!---
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 -->
Copy of https://github.com/ansible/proposals/issues/117
Currently, the IOSXR_CONFIG module allows for the addition of commit comments, but does not give the ability to add a commit label. Commit labels in many enterprise environments allow quick reference to various commits to allow for quicker rollbacks and less confusion when a rollback is needed.

When IOSXR_CONFIG makes a commit, it does not apply a commit label, leaving no tags for quick rollback. Rollbacks on IOS-XR devices are reference using a machine generated CommitID, but the ability to add a commit label allows the user to set a reference to the commit.

##### Solution proposal
Add an additional parameter to the IOSXR_CONFIG module that allows the addition of a commit label when the module commits a change.

On an IOS-XR device, a commit label is added using the following configuration command
```
!
conf t
! 
commit label <word>
!
```
```
    - iosxr_config
         commands:
         comment: This is the existing parameter that allows the addition of a commit comment
         label: This is the proposed parameter that would allow the addition of a commit label
```
##### 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-->
iosxr_config

##### ANSIBLE VERSION
<!--- Paste, BELOW THIS COMMENT, verbatim output from "ansible --version" between quotes below -->
```
$ ansible --version
ansible 2.5.3
  config file = None
  configured module search path = [u'/Users/james/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/local/lib/python2.7/site-packages/ansible
  executable location = /usr/local/bin/ansible
  python version = 2.7.15 (default, May  1 2018, 16:44:08) [GCC 4.2.1 Compatible Apple LLVM 9.1.0 (clang-902.0.39.1)]
```

##### 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.-->
N/A





Discussion:
@mfisher30 FYI
cc @gdpak @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/module_utils/network/iosxr/iosxr.py b/lib/ansible/module_utils/network/iosxr/iosxr.py
--- a/lib/ansible/module_utils/network/iosxr/iosxr.py
+++ b/lib/ansible/module_utils/network/iosxr/iosxr.py
@@ -27,6 +27,7 @@
 # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 #
 import json
+import re
 from difflib import Differ
 from copy import deepcopy
 from time import sleep
@@ -334,7 +335,8 @@ def discard_config(module):
     conn.discard_changes()
 
 
-def commit_config(module, comment=None, confirmed=False, confirm_timeout=None, persist=False, check=False):
+def commit_config(module, comment=None, confirmed=False, confirm_timeout=None,
+                  persist=False, check=False, label=None):
     conn = get_connection(module)
     reply = None
 
@@ -344,7 +346,7 @@ def commit_config(module, comment=None, confirmed=False, confirm_timeout=None, p
         if is_netconf(module):
             reply = conn.commit(confirmed=confirmed, timeout=confirm_timeout, persist=persist)
         elif is_cliconf(module):
-            reply = conn.commit(comment=comment)
+            reply = conn.commit(comment=comment, label=label)
 
     return reply
 
@@ -373,8 +375,18 @@ def get_config(module, config_filter=None, source='running'):
     return cfg
 
 
+def check_existing_commit_labels(conn, label):
+    out = conn.get(command='show configuration history detail | include %s' % label)
+    label_exist = re.search(label, out, re.M)
+    if label_exist:
+        return True
+    else:
+        return False
+
+
 def load_config(module, command_filter, commit=False, replace=False,
-                comment=None, admin=False, running=None, nc_get_filter=None):
+                comment=None, admin=False, running=None, nc_get_filter=None,
+                label=None):
 
     conn = get_connection(module)
 
@@ -404,6 +416,16 @@ def load_config(module, command_filter, commit=False, replace=False,
     elif is_cliconf(module):
         # to keep the pre-cliconf behaviour, make a copy, avoid adding commands to input list
         cmd_filter = deepcopy(command_filter)
+        # If label is present check if label already exist before entering
+        # config mode
+        if label:
+            old_label = check_existing_commit_labels(conn, label)
+            if old_label:
+                module.fail_json(
+                    msg='commit label {%s} is already used for'
+                    ' an earlier commit, please choose a different label'
+                    ' and rerun task' % label
+                )
         cmd_filter.insert(0, 'configure terminal')
         if admin:
             cmd_filter.insert(0, 'admin')
@@ -424,7 +446,7 @@ def load_config(module, command_filter, commit=False, replace=False,
             cmd.append('end')
             conn.edit_config(cmd)
         elif commit:
-            commit_config(module, comment=comment)
+            commit_config(module, comment=comment, label=label)
             conn.edit_config('end')
             if admin:
                 conn.edit_config('exit')
diff --git a/lib/ansible/modules/network/iosxr/iosxr_config.py b/lib/ansible/modules/network/iosxr/iosxr_config.py
--- a/lib/ansible/modules/network/iosxr/iosxr_config.py
+++ b/lib/ansible/modules/network/iosxr/iosxr_config.py
@@ -135,6 +135,14 @@
     type: bool
     default: 'no'
     version_added: "2.4"
+  label:
+    description:
+      - Allows a commit label to be specified to be included when the
+        configuration is committed. A valid label must begin with an alphabet
+        and not exceed 30 characters, only alphabets, digits, hyphens and
+        underscores are allowed. If the configuration is not changed or
+        committed, this argument is ignored.
+    version_added: "2.7"
 """
 
 EXAMPLES = """
@@ -239,6 +247,18 @@ def check_args(module, warnings):
     if module.params['comment']:
         if len(module.params['comment']) > 60:
             module.fail_json(msg='comment argument cannot be more than 60 characters')
+    if module.params['label']:
+        label = module.params['label']
+        if len(label) > 30:
+            module.fail_json(msg='label argument cannot be more than 30 characters')
+        if not label[0].isalpha():
+            module.fail_json(msg='label argument must begin with an alphabet')
+        valid_chars = re.match(r'[\w-]*$', label)
+        if not valid_chars:
+            module.fail_json(
+                msg='label argument must only contain alphabets,' +
+                'digits, underscores or hyphens'
+            )
     if module.params['force']:
         warnings.append('The force argument is deprecated, please use '
                         'match=none instead.  This argument will be '
@@ -340,6 +360,7 @@ def run(module, result):
     comment = module.params['comment']
     admin = module.params['admin']
     check_mode = module.check_mode
+    label = module.params['label']
 
     candidate_config = get_candidate(module)
     running_config = get_running_config(module)
@@ -376,7 +397,11 @@ def run(module, result):
             result['commands'] = commands
 
         commit = not check_mode
-        diff = load_config(module, commands, commit=commit, replace=replace_config, comment=comment, admin=admin)
+        diff = load_config(
+            module, commands, commit=commit,
+            replace=replace_config, comment=comment, admin=admin,
+            label=label
+        )
         if diff:
             result['diff'] = dict(prepared=diff)
 
@@ -405,7 +430,8 @@ def main():
         config=dict(),
         backup=dict(type='bool', default=False),
         comment=dict(default=DEFAULT_COMMIT_COMMENT),
-        admin=dict(type='bool', default=False)
+        admin=dict(type='bool', default=False),
+        label=dict()
     )
 
     argument_spec.update(iosxr_argument_spec)
diff --git a/lib/ansible/plugins/cliconf/iosxr.py b/lib/ansible/plugins/cliconf/iosxr.py
--- a/lib/ansible/plugins/cliconf/iosxr.py
+++ b/lib/ansible/plugins/cliconf/iosxr.py
@@ -89,9 +89,13 @@ def edit_config(self, commands=None):
     def get(self, command=None, prompt=None, answer=None, sendonly=False, newline=True):
         return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly, newline=newline)
 
-    def commit(self, comment=None):
-        if comment:
+    def commit(self, comment=None, label=None):
+        if comment and label:
+            command = 'commit label {0} comment {1}'.format(label, comment)
+        elif comment:
             command = 'commit comment {0}'.format(comment)
+        elif label:
+            command = 'commit label {0}'.format(label)
         else:
             command = 'commit'
         self.send_command(command)


Test patch:
diff --git a/test/integration/targets/iosxr_config/tests/cli/commit_label.yaml b/test/integration/targets/iosxr_config/tests/cli/commit_label.yaml
new file mode 100644
--- /dev/null
+++ b/test/integration/targets/iosxr_config/tests/cli/commit_label.yaml
@@ -0,0 +1,70 @@
+---
+- debug: msg="START cli/commit_label.yaml on connection={{ ansible_connection }}"
+
+- name: setup
+  iosxr_config:
+    commands:
+      - no description
+      - no shutdown
+    parents:
+      - interface Loopback999
+    match: none
+
+- name: get a unique and valid label
+  set_fact:
+    label: "ansible_{{ 1001 | random | to_uuid | truncate(20, true, '_') }}"
+
+- name: configure device with a label and a comment
+  iosxr_config:
+    src: basic/config.j2
+    comment: "this is sensible commit message"
+    label: "{{ label }}"
+  register: result
+
+- assert:
+    that:
+      - "result.changed == true"
+
+- name: setup
+  iosxr_config:
+    commands:
+      - no description
+      - no shutdown
+    parents:
+      - interface Loopback999
+    match: none
+
+- name: Try to commit with old label, fail with a msg that label is alreay used
+  iosxr_config:
+    src: basic/config.j2
+    label: "{{ label }}"
+  register: result
+  ignore_errors: true
+
+- assert:
+    that:
+      - "result.changed == false"
+      - "'already used' in result.msg"
+
+- name: setup
+  iosxr_config:
+    commands:
+      - no description
+      - no shutdown
+    parents:
+      - interface Loopback999
+    match: none
+
+- name: Try to commit with invalid chars($) in label
+  iosxr_config:
+    src: basic/config.j2
+    label: 'ansible_iosxr_config_$'
+  register: result
+  ignore_errors: true
+
+- assert:
+    that:
+      - "result.changed == false"
+      - "'only contain alphabets' in result.msg"
+
+- debug: msg="END cli/commit_label.yaml on connection={{ ansible_connection }}"