Repository: napalm-automation/napalm

Issue:
Change rollback behavior
Based on [this discussion](https://github.com/napalm-automation/napalm/discussions/2001).

Currently, calling `rollback()` on NXOS does nothing unless napalm is aware of making changes. However, in some cases it makes sense to call `rollback()` in a new napalm session.

Proposed behavior is that `rollback()` will become agnostic to any changes napalm is aware of, and will have the net effect of reverting to the most recent state. For platforms with a native rollback feature, this will revert to the most recent snapshot. Platforms that don't, such as IOS, will attempt to revert to the rollback state that napalm created in a previous session. If such a rollback state does not exist, an exception will raise.


Discussion:

Resolving patch:
diff --git a/napalm/nxos/nxos.py b/napalm/nxos/nxos.py
--- a/napalm/nxos/nxos.py
+++ b/napalm/nxos/nxos.py
@@ -191,6 +191,29 @@ def _send_command(
     ) -> Dict[str, Union[str, Dict[str, Any]]]:
         raise NotImplementedError
 
+    def _check_file_exists(self, cfg_file: str) -> bool:
+        """
+        Check that the file exists on remote device using full path.
+
+        cfg_file can be a full path, e.g.: bootflash:rollback_config.txt
+        or just a filename, e.g.: rollback_config.txt
+
+        For example
+        # dir rollback_config.txt
+            71803    Sep 06 14:13:33 2023  rollback_config.txt
+
+        Usage for bootflash://sup-local
+        6211682304 bytes used
+        110314684416 bytes free
+        116526366720 bytes total
+        """
+        cmd = f"dir {cfg_file}"
+        output = self._send_command(command=cmd, raw_text=True)
+        if "No such file or directory" in output:
+            return False
+        else:
+            return True
+
     def _commit_merge(self) -> None:
         try:
             output = self._send_config(self.merge_candidate)
@@ -918,10 +941,12 @@ def _load_cfg_from_checkpoint(self) -> None:
 
     def rollback(self) -> None:
         assert isinstance(self.device, NXOSDevice)
-        if self.changed:
-            self.device.rollback(self.rollback_cfg)
-            self._copy_run_start()
-            self.changed = False
+        if not self._check_file_exists(cfg_file=self.rollback_cfg):
+            msg = f"Rollback file '{self.rollback_cfg}' does not exist on device."
+            raise ReplaceConfigException(msg)
+        self.device.rollback(self.rollback_cfg)
+        self._copy_run_start()
+        self.changed = False
 
     def get_facts(self) -> models.FactsDict:
         facts: models.FactsDict = {}  # type: ignore
diff --git a/napalm/nxos_ssh/nxos_ssh.py b/napalm/nxos_ssh/nxos_ssh.py
--- a/napalm/nxos_ssh/nxos_ssh.py
+++ b/napalm/nxos_ssh/nxos_ssh.py
@@ -553,21 +553,24 @@ def _load_cfg_from_checkpoint(self):
             raise ReplaceConfigException(msg)
 
     def rollback(self):
-        if self.changed:
-            commands = [
-                "terminal dont-ask",
-                "rollback running-config file {}".format(self.rollback_cfg),
-                "no terminal dont-ask",
-            ]
-            result = self._send_command_list(
-                commands, expect_string=r"[#>]", read_timeout=90
-            )
-            if "completed" not in result.lower():
-                raise ReplaceConfigException(result)
-            # If hostname changes ensure Netmiko state is updated properly
-            self._netmiko_device.set_base_prompt()
-            self._copy_run_start()
-            self.changed = False
+        if not self._check_file_exists(self.rollback_cfg):
+            msg = f"Rollback file '{self.rollback_cfg}' does not exist on device."
+            raise ReplaceConfigException(msg)
+
+        commands = [
+            "terminal dont-ask",
+            "rollback running-config file {}".format(self.rollback_cfg),
+            "no terminal dont-ask",
+        ]
+        result = self._send_command_list(
+            commands, expect_string=r"[#>]", read_timeout=90
+        )
+        if "completed" not in result.lower():
+            raise ReplaceConfigException(result)
+        # If hostname changes ensure Netmiko state is updated properly
+        self._netmiko_device.set_base_prompt()
+        self._copy_run_start()
+        self.changed = False
 
     def _apply_key_map(self, key_map, table):
         new_dict = {}


Test patch: