Repository: napalm-automation/napalm

Issue:
Cisco IOS - Support revert timer for both replace and merge configuration
### Description of Issue/Question

This is a pretty simple feature request for the Napalm Cisco IOS driver, and that is to support the revert timer for both replace and merge configurations.  For the replace method, the process is relatively straight forward as there is a built in command to replace the configuration which supports a revert timer.  For merge, the process gets more complicated as the configuration is merged using `copy [file] running-config`, but still manageable.

The process for a merge operation would be as follows:

1. Enter configuration terminal mode with "revert timer x" set, where x is the number of minutes to wait.
2. Exit configuration terminal mode
3. copy as normal to merge the configuration
4. commit the configuration

There was some work on this previously under PR #550 and there are custom tests available here: https://github.com/ktbyers/napalm_custom_test/tree/master/napalm_custom_test

I would be willing to take this up unless someone from the core team wants to.

### Did you follow the steps from https://github.com/napalm-automation/napalm#faq

- [X] Yes
- [] No


### Setup

### napalm version
(Paste verbatim output from `pip freeze | grep napalm` between quotes below)

```
napalm==3.3.1
```

### Network operating system version
(Paste verbatim output from `show version` - or equivalent - between quotes below)

```
Cisco IOS Software [Gibraltar], Catalyst L3 Switch Software (CAT9K_IOSXE), Version 16.12.4, RELEASE SOFTWARE (fc5)
BOOTLDR: System Bootstrap, Version 17.3.1r[FC3], RELEASE SOFTWARE (P)
```



Discussion:
Yep, have at it.  :-)

Resolving patch:
diff --git a/napalm/ios/ios.py b/napalm/ios/ios.py
--- a/napalm/ios/ios.py
+++ b/napalm/ios/ios.py
@@ -34,6 +34,7 @@
     MergeConfigException,
     ConnectionClosedException,
     CommandErrorException,
+    CommitConfirmException,
 )
 from napalm.base.helpers import (
     canonical_interface_name,
@@ -502,15 +503,38 @@ def commit_config(self, message="", revert_in=None):
 
         If merge operation, perform copy <file> running-config.
         """
-        if revert_in is not None:
-            raise NotImplementedError(
-                "Commit confirm has not been implemented on this platform."
-            )
+        CISCO_TIMER_MIN = 1
+        CISCO_TIMER_MAX = 120
+        ARCHIVE_DISABLED_MESSAGE = (
+            "For Cisco devices, revert_in requires 'archive' feature to be enabled."
+        )
+        revert_in_min = None
+
         if message:
             raise NotImplementedError(
                 "Commit message not implemented for this platform"
             )
 
+        if revert_in is not None:
+            if not self._check_archive_feature():
+                raise CommitConfirmException(ARCHIVE_DISABLED_MESSAGE)
+            elif not CISCO_TIMER_MIN * 60 <= revert_in <= CISCO_TIMER_MAX * 60:
+                msg = (
+                    "For Cisco IOS devices revert_in is rounded down to the nearest minute,"
+                    "pass revert_in as a multiple of 60 between {} and {}".format(
+                        CISCO_TIMER_MIN * 60, CISCO_TIMER_MAX * 60
+                    )
+                )
+                raise CommitConfirmException(msg)
+            else:
+                revert_in_min = int(revert_in / 60)
+
+        if self.has_pending_commit():
+            raise CommandErrorException(
+                "Configuration session already in progress, cannot "
+                "perform configuration actions"
+            )
+
         # Always generate a rollback config on commit
         self._gen_rollback_cfg()
 
@@ -520,8 +544,16 @@ def commit_config(self, message="", revert_in=None):
             cfg_file = self._gen_full_path(filename)
             if not self._check_file_exists(cfg_file):
                 raise ReplaceConfigException("Candidate config file does not exist")
-            if self.auto_rollback_on_error:
+            if revert_in_min and self.auto_rollback_on_error:
+                cmd = "configure replace {} force revert trigger error timer {}".format(
+                    cfg_file, revert_in_min
+                )
+            elif self.auto_rollback_on_error:
                 cmd = "configure replace {} force revert trigger error".format(cfg_file)
+            elif revert_in_min:
+                cmd = "configure replace {} force revert timer {}".format(
+                    cfg_file, revert_in_min
+                )
             else:
                 cmd = "configure replace {} force".format(cfg_file)
             output = self._commit_handler(cmd)
@@ -534,14 +566,29 @@ def commit_config(self, message="", revert_in=None):
                 msg = "Candidate config could not be applied\n{}".format(output)
                 raise ReplaceConfigException(msg)
             elif "%Please turn config archive on" in output:
-                msg = "napalm-ios replace() requires Cisco 'archive' feature to be enabled."
-                raise ReplaceConfigException(msg)
+                if revert_in_min:
+                    raise CommitConfirmException(ARCHIVE_DISABLED_MESSAGE)
+                else:
+                    msg = "napalm-ios replace() requires Cisco 'archive' feature to be enabled"
+                    raise ReplaceConfigException(msg)
         else:
             # Merge operation
             filename = self.merge_cfg
             cfg_file = self._gen_full_path(filename)
             if not self._check_file_exists(cfg_file):
                 raise MergeConfigException("Merge source config file does not exist")
+            if revert_in_min is not None:
+                # Enter config mode with a revert timer and exit config mode
+                try:
+                    self.device.config_mode(
+                        config_command="configure terminal revert timer {}".format(
+                            revert_in_min
+                        )
+                    )
+                    self.device.exit_config_mode()
+                except ValueError:
+                    raise MergeConfigException(ARCHIVE_DISABLED_MESSAGE)
+
             cmd = "copy {} running-config".format(cfg_file)
             output = self._commit_handler(cmd)
             if "Invalid input detected" in output:
@@ -553,8 +600,61 @@ def commit_config(self, message="", revert_in=None):
         # After a commit - we no longer know whether this is configured or not.
         self.prompt_quiet_configured = None
 
-        # Save config to startup (both replace and merge)
-        output += self.device.save_config()
+        if revert_in_min is None:
+            # Save config to startup (both replace and merge)
+            output += self.device.save_config()
+
+    def _check_archive_feature(self):
+        cmd = "show archive"
+        output = self.device.send_command(cmd)
+        if "Archive feature not enabled" in output:
+            return False
+        return True
+
+    def has_pending_commit(self):
+        pending_commits = self._get_pending_commits()
+        return bool(pending_commits)
+
+    def _get_pending_commits(self):
+        if self._check_archive_feature():
+            cmd = "show archive config rollback timer"
+            output = self.device.send_command(cmd)
+        else:
+            return {}
+        if "No Rollback Confirmed Change pending" in output:
+            return {}
+        match_strings = r"|".join(
+            [
+                r"Time configured.*?: (?P<configured>.*)",
+                r"Timer type: (?P<type>.*)",
+                r"Timer value: (?P<timer>.*)",
+                r"User: (?P<user>.*)",
+            ]
+        )
+        keys = ["configured", "type", "timer", "user"]
+        matches = re.finditer(match_strings, output)
+        pending_commits = {}
+        for match in matches:
+            for key in keys:
+                if match.groupdict().get(key):
+                    pending_commits.update({key: match.groupdict().get(key)})
+
+        return pending_commits
+
+    def confirm_commit(self):
+        """Send final commit to confirm an in-proces commit that requires confirmation."""
+        if self.has_pending_commit():
+            pending = self._get_pending_commits()
+            if pending.get("user") == self.username:
+                self.device.send_command("configure confirm")
+                self.device.save_config()
+            else:
+                raise CommitConfirmException(
+                    "Configuration session active but not owned by"
+                    " {} cannot confirm commit".format(self.username)
+                )
+        else:
+            raise CommitConfirmException("No pending configuration")
 
     def discard_config(self):
         """Discard loaded candidate configurations."""
@@ -572,18 +672,29 @@ def _discard_config(self):
 
     def rollback(self):
         """Rollback configuration to filename or to self.rollback_cfg file."""
-        filename = self.rollback_cfg
-        cfg_file = self._gen_full_path(filename)
-        if not self._check_file_exists(cfg_file):
-            raise ReplaceConfigException("Rollback config file does not exist")
-        cmd = "configure replace {} force".format(cfg_file)
-        self._commit_handler(cmd)
-
-        # After a rollback - we no longer know whether this is configured or not.
-        self.prompt_quiet_configured = None
+        if self.has_pending_commit():
+            if self._get_pending_commits().get("user") == self.username:
+                cmd = "configure revert now"
+                self._commit_handler(cmd)
+                self.device.save_config()
+            else:
+                raise CommitConfirmException(
+                    "Configuration session active but not owned by {} "
+                    "cannot rollback".format(self.username)
+                )
+        else:
+            filename = self.rollback_cfg
+            cfg_file = self._gen_full_path(filename)
+            if not self._check_file_exists(cfg_file):
+                raise ReplaceConfigException("Rollback config file does not exist")
+            cmd = "configure replace {} force".format(cfg_file)
+            self._commit_handler(cmd)
+
+            # After a rollback - we no longer know whether this is configured or not.
+            self.prompt_quiet_configured = None
 
-        # Save config to startup
-        self.device.save_config()
+            # Save config to startup
+            self.device.save_config()
 
     def _inline_tcl_xfer(
         self, source_file=None, source_config=None, dest_file=None, file_system=None


Test patch: