Repository: hyperledger/aries-cloudagent-python

Issue:
Add mechanism for marking an Upgrade as offline only
As of release 0.8.1, we are making upgrades automatic. When you deploy a version of ACA-Py (as stated in `version.py`) that is higher than what is stored in the ACA-Py secure storage database, an upgrade is automatically run, based on the contents of the [Upgrade Definition YML]() file. However, in some cases, an upgrade may be particularly risky, and we want to make sure it is run only offline.  For example, if the upgrade has side effects that it must only run exactly once, we don't want to an automated upgrade in a Kubernetes environment where the upgrade might be run more than once.

To prevent that, please add a parameter that is optional but that can be added to YML upgrade definitions that prevents an "auto-update".  I think there should be two flavours of the parameter -- one that triggers and error and stops the operation, and one that triggers only an entry in the log and continues with processing.   The "error and stop" can be used when the upgrade **MUST** be run before the new version can be deployed.  The "log" can be used when the upgrade must be run offline, but is not needed for the new version to be run.  In the latter case, the version in storage MUST NOT be updated to the new current ACA-Py version -- it needs to be left as is.


Discussion:

Resolving patch:
diff --git a/aries_cloudagent/commands/upgrade.py b/aries_cloudagent/commands/upgrade.py
--- a/aries_cloudagent/commands/upgrade.py
+++ b/aries_cloudagent/commands/upgrade.py
@@ -6,6 +6,7 @@
 import yaml
 
 from configargparse import ArgumentParser
+from enum import Enum
 from packaging import version as package_version
 from typing import (
     Callable,
@@ -15,6 +16,7 @@
     Union,
     Mapping,
     Any,
+    Tuple,
 )
 
 from ..core.profile import Profile
@@ -36,6 +38,21 @@
 LOGGER = logging.getLogger(__name__)
 
 
+class ExplicitUpgradeOption(Enum):
+    """Supported explicit upgrade codes."""
+
+    ERROR_AND_STOP = "critical"
+    LOG_AND_PROCEED = "warning"
+
+    @classmethod
+    def get(cls, code: str) -> "ExplicitUpgradeOption":
+        """Get ExplicitUpgradeOption for code."""
+        for option in ExplicitUpgradeOption:
+            if code == option.value:
+                return option
+        return None
+
+
 class UpgradeError(BaseError):
     """Base exception for upgrade related errors."""
 
@@ -82,6 +99,14 @@ def setup_version_upgrade_config(self, path: str):
                     config_key_set.remove("resave_records")
                 except KeyError:
                     pass
+                if "explicit_upgrade" in provided_config:
+                    version_config_dict[version][
+                        "explicit_upgrade"
+                    ] = provided_config.get("explicit_upgrade")
+                try:
+                    config_key_set.remove("explicit_upgrade")
+                except KeyError:
+                    pass
                 for executable in config_key_set:
                     version_config_dict[version][executable] = (
                         provided_config.get(executable) or False
@@ -103,6 +128,28 @@ def init_argument_parser(parser: ArgumentParser):
     return arg.load_argument_groups(parser, *arg.group.get_registered(arg.CAT_UPGRADE))
 
 
+def explicit_upgrade_required_check(
+    to_apply_version_list: List,
+    upgrade_config: dict,
+) -> Tuple[bool, List, Optional[str]]:
+    """Check if explicit upgrade is required."""
+    to_skip_versions = []
+    for version in to_apply_version_list:
+        if "explicit_upgrade" in upgrade_config.get(version):
+            exp_upg_flag = upgrade_config.get(version).get("explicit_upgrade")
+            if (
+                ExplicitUpgradeOption.get(exp_upg_flag)
+                is ExplicitUpgradeOption.ERROR_AND_STOP
+            ):
+                return True, [], version
+            elif (
+                ExplicitUpgradeOption.get(exp_upg_flag)
+                is ExplicitUpgradeOption.LOG_AND_PROCEED
+            ):
+                to_skip_versions.append(version)
+    return False, to_skip_versions, None
+
+
 def get_upgrade_version_list(
     from_version: str,
     sorted_version_list: Optional[List] = None,
@@ -222,6 +269,31 @@ async def upgrade(
             sorted_version_list=sorted_versions_found_in_config,
             from_version=upgrade_from_version,
         )
+        # Perform explicit upgrade check if the function was called during startup
+        if profile:
+            (
+                explicit_flag,
+                to_skip_explicit_versions,
+                explicit_upg_ver,
+            ) = explicit_upgrade_required_check(
+                to_apply_version_list=upgrade_version_in_config,
+                upgrade_config=upgrade_configs,
+            )
+            if explicit_flag:
+                raise UpgradeError(
+                    "Explicit upgrade flag with critical value found "
+                    f"for {explicit_upg_ver} config. Please use ACA-Py "
+                    "upgrade command to complete the process and proceed."
+                )
+            if len(to_skip_explicit_versions) >= 1:
+                LOGGER.warning(
+                    "Explicit upgrade flag with warning value found "
+                    f"for {str(to_skip_explicit_versions)} versions. "
+                    "Proceeding with ACA-Py startup. You can apply "
+                    "the explicit upgrades using the ACA-Py upgrade "
+                    "command later."
+                )
+                return
         to_update_flag = False
         if upgrade_from_version == upgrade_to_version:
             LOGGER.info(


Test patch:
diff --git a/aries_cloudagent/commands/tests/test_upgrade.py b/aries_cloudagent/commands/tests/test_upgrade.py
--- a/aries_cloudagent/commands/tests/test_upgrade.py
+++ b/aries_cloudagent/commands/tests/test_upgrade.py
@@ -392,3 +392,231 @@ def test_main(self):
         ) as mock_execute:
             test_module.main()
             mock_execute.assert_called_once
+
+    def test_get_explicit_upgrade_option(self):
+        assert not test_module.ExplicitUpgradeOption.get("test")
+        assert (
+            test_module.ExplicitUpgradeOption.get("critical")
+            == test_module.ExplicitUpgradeOption.ERROR_AND_STOP
+        )
+        assert (
+            test_module.ExplicitUpgradeOption.get("warning")
+            == test_module.ExplicitUpgradeOption.LOG_AND_PROCEED
+        )
+
+    async def test_upgrade_explicit_upgrade_required_check(self):
+        test_config_dict = {
+            "v0.8.1": {
+                "resave_records": [
+                    "aries_cloudagent.connections.models.conn_record.ConnRecord"
+                ],
+                "update_existing_records": False,
+            },
+            "v0.7.2": {
+                "resave_records": [
+                    "aries_cloudagent.connections.models.conn_record.ConnRecord"
+                ],
+                "update_existing_records": False,
+            },
+            "v0.7.1": {"resave_records": [], "update_existing_records": False},
+            "v0.7.0": {"resave_records": [], "update_existing_records": False},
+            "v0.6.0": {"resave_records": [], "update_existing_records": False},
+        }
+        check, to_skip_list, exp_version = test_module.explicit_upgrade_required_check(
+            ["v0.6.0", "v0.7.0", "v0.7.1", "v0.7.2", "v0.8.1"],
+            test_config_dict,
+        )
+        assert (check, to_skip_list, exp_version) == (False, [], None)
+
+        check, to_skip_list, exp_version = test_module.explicit_upgrade_required_check(
+            ["v0.8.1", "v0.6.0", "v0.7.1", "v0.7.2", "v0.7.0"],
+            test_config_dict,
+        )
+        assert (check, to_skip_list, exp_version) == (False, [], None)
+
+        test_config_dict = {
+            "v0.8.1": {
+                "resave_records": [
+                    "aries_cloudagent.connections.models.conn_record.ConnRecord"
+                ],
+                "update_existing_records": False,
+            },
+            "v0.7.2": {
+                "resave_records": [
+                    "aries_cloudagent.connections.models.conn_record.ConnRecord"
+                ],
+                "update_existing_records": False,
+                "explicit_upgrade": "warning",
+            },
+            "v0.7.1": {
+                "resave_records": [],
+                "update_existing_records": False,
+                "explicit_upgrade": "warning",
+            },
+            "v0.7.0": {"resave_records": [], "update_existing_records": False},
+            "v0.6.0": {
+                "resave_records": [],
+                "update_existing_records": False,
+                "explicit_upgrade": "warning",
+            },
+        }
+        check, to_skip_list, exp_version = test_module.explicit_upgrade_required_check(
+            ["v0.6.0", "v0.7.0", "v0.7.1", "v0.7.2", "v0.8.1"],
+            test_config_dict,
+        )
+        assert (check, to_skip_list, exp_version) == (
+            False,
+            ["v0.6.0", "v0.7.1", "v0.7.2"],
+            None,
+        )
+
+        test_config_dict = {
+            "v0.8.1": {
+                "resave_records": [
+                    "aries_cloudagent.connections.models.conn_record.ConnRecord"
+                ],
+                "update_existing_records": False,
+            },
+            "v0.7.2": {
+                "resave_records": [
+                    "aries_cloudagent.connections.models.conn_record.ConnRecord"
+                ],
+                "update_existing_records": False,
+                "explicit_upgrade": "critical",
+            },
+            "v0.7.1": {"resave_records": [], "update_existing_records": False},
+            "v0.7.0": {"resave_records": [], "update_existing_records": False},
+            "v0.6.0": {"resave_records": [], "update_existing_records": False},
+        }
+        check, to_skip_list, exp_version = test_module.explicit_upgrade_required_check(
+            ["v0.6.0", "v0.7.0", "v0.7.1", "v0.7.2", "v0.8.1"],
+            test_config_dict,
+        )
+        assert (check, to_skip_list, exp_version) == (True, [], "v0.7.2")
+
+        test_config_dict = {
+            "v0.8.1": {
+                "resave_records": [
+                    "aries_cloudagent.connections.models.conn_record.ConnRecord"
+                ],
+                "update_existing_records": False,
+            },
+            "v0.7.2": {
+                "resave_records": [
+                    "aries_cloudagent.connections.models.conn_record.ConnRecord"
+                ],
+                "update_existing_records": False,
+                "explicit_upgrade": "warning",
+            },
+            "v0.7.1": {
+                "resave_records": [],
+                "update_existing_records": False,
+                "explicit_upgrade": "warning",
+            },
+            "v0.7.0": {
+                "resave_records": [],
+                "update_existing_records": False,
+                "explicit_upgrade": "critical",
+            },
+            "v0.6.0": {"resave_records": [], "update_existing_records": False},
+        }
+        check, to_skip_list, exp_version = test_module.explicit_upgrade_required_check(
+            ["v0.6.0", "v0.7.0", "v0.7.1", "v0.7.2", "v0.8.1"],
+            test_config_dict,
+        )
+        assert (check, to_skip_list, exp_version) == (True, [], "v0.7.0")
+
+    async def test_upgrade_explicit_check(self):
+        self.profile.settings.extend(
+            {
+                "upgrade.from_version": "v0.7.0",
+            }
+        )
+        with async_mock.patch.object(
+            test_module.yaml,
+            "safe_load",
+            async_mock.MagicMock(
+                return_value={
+                    "v0.7.2": {
+                        "resave_records": {
+                            "base_record_path": [
+                                "aries_cloudagent.connections.models.conn_record.ConnRecord"
+                            ],
+                        },
+                        "update_existing_records": True,
+                    },
+                    "v0.7.3": {
+                        "update_existing_records": True,
+                        "explicit_upgrade": "critical",
+                    },
+                    "v0.7.1": {
+                        "update_existing_records": False,
+                    },
+                }
+            ),
+        ):
+            with self.assertRaises(UpgradeError) as ctx:
+                await test_module.upgrade(profile=self.profile)
+            assert "Explicit upgrade flag with critical value found" in str(
+                ctx.exception
+            )
+
+        with async_mock.patch.object(
+            test_module.yaml,
+            "safe_load",
+            async_mock.MagicMock(
+                return_value={
+                    "v0.7.2": {
+                        "resave_records": {
+                            "base_record_path": [
+                                "aries_cloudagent.connections.models.conn_record.ConnRecord"
+                            ],
+                        },
+                        "update_existing_records": True,
+                        "explicit_upgrade": "warning",
+                    },
+                    "v0.7.3": {
+                        "update_existing_records": True,
+                        "explicit_upgrade": "critical",
+                    },
+                    "v0.7.1": {
+                        "update_existing_records": False,
+                    },
+                }
+            ),
+        ):
+            with self.assertRaises(UpgradeError) as ctx:
+                await test_module.upgrade(profile=self.profile)
+            assert "Explicit upgrade flag with critical value found" in str(
+                ctx.exception
+            )
+
+        with async_mock.patch.object(
+            test_module, "LOGGER", async_mock.MagicMock()
+        ) as mock_logger, async_mock.patch.object(
+            test_module.yaml,
+            "safe_load",
+            async_mock.MagicMock(
+                return_value={
+                    "v0.7.2": {
+                        "resave_records": {
+                            "base_record_path": [
+                                "aries_cloudagent.connections.models.conn_record.ConnRecord"
+                            ],
+                        },
+                        "update_existing_records": True,
+                    },
+                    "v0.7.3": {
+                        "update_existing_records": True,
+                        "explicit_upgrade": "warning",
+                    },
+                    "v0.7.1": {
+                        "update_existing_records": True,
+                        "explicit_upgrade": "warning",
+                    },
+                }
+            ),
+        ):
+            await test_module.upgrade(profile=self.profile)
+            assert mock_logger.warning.call_count == 1
+            assert mock_logger.info.call_count == 0