Repository: hyperledger/aries-cloudagent-python

Issue:
Handling “version” and upgrades when “version” not found in storage 
When an existing agent upgrades to a version of ACA-Py that is aware of the versions and auto-upgrades, there is the possibility that “version” is not found in secure storage.  The current behaviour is to continue with an note in the log.  If that note is missed, the agent will fovever continue without necessary upgrades.

I propose that the behaviour be changed to the following:

When the `from` version is not found in storage for an existing agent, it should be set to the value`0.7.5`, which should trigger an upgrade from that version to the current version. In the normal flow of the upgrade, the current version will be put in storage.

Why is hardcoding `0.7.5` safe?

- This will happen from `0.8.1` forward (or when we add this feature).
- The upgrades from all versions prior to `0.7.5` are the same — resave the connection records, so any old version to `0.8.1` are safe.  Further, if the agent is a `0.8.0` upgrading to a `0.8.1`, the upgrade may be unnecessary, but it will not damage the instance.
- An upgrade of such an instance to a future version that requires an `offline` upgrade will still be handled safely.



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
@@ -7,12 +7,20 @@
 
 from configargparse import ArgumentParser
 from packaging import version as package_version
-from typing import Callable, Sequence, Optional, List
+from typing import (
+    Callable,
+    Sequence,
+    Optional,
+    List,
+    Union,
+    Mapping,
+    Any,
+)
 
 from ..core.profile import Profile
 from ..config import argparse as arg
 from ..config.default_context import DefaultContextBuilder
-from ..config.base import BaseError
+from ..config.base import BaseError, BaseSettings
 from ..config.util import common_config
 from ..config.wallet import wallet_config
 from ..messaging.models.base_record import BaseRecord
@@ -138,23 +146,32 @@ async def add_version_record(profile: Profile, version: str):
     LOGGER.info(f"{RECORD_TYPE_ACAPY_VERSION} storage record set to {version}")
 
 
-async def upgrade(settings: dict):
+async def upgrade(
+    settings: Optional[Union[Mapping[str, Any], BaseSettings]] = None,
+    profile: Optional[Profile] = None,
+):
     """Perform upgradation steps."""
-    context_builder = DefaultContextBuilder(settings)
-    context = await context_builder.build_context()
     try:
+        if profile and (settings or settings == {}):
+            raise UpgradeError("upgrade requires either profile or settings, not both.")
+        if profile:
+            root_profile = profile
+            settings = profile.settings
+        else:
+            context_builder = DefaultContextBuilder(settings)
+            context = await context_builder.build_context()
+            root_profile, _ = await wallet_config(context)
         version_upgrade_config_inst = VersionUpgradeConfig(
             settings.get("upgrade.config_path")
         )
         upgrade_configs = version_upgrade_config_inst.upgrade_configs
-        root_profile, _ = await wallet_config(context)
         upgrade_to_version = f"v{__version__}"
         versions_found_in_config = upgrade_configs.keys()
         sorted_versions_found_in_config = sorted(
             versions_found_in_config, key=lambda x: package_version.parse(x)
         )
+        upgrade_from_version_storage = None
         upgrade_from_version_config = None
-        upgrade_from_version_setting = None
         upgrade_from_version = None
         async with root_profile.session() as session:
             storage = session.inject(BaseStorage)
@@ -162,41 +179,41 @@ async def upgrade(settings: dict):
                 version_storage_record = await storage.find_record(
                     type_filter=RECORD_TYPE_ACAPY_VERSION, tag_query={}
                 )
-                upgrade_from_version_config = version_storage_record.value
+                upgrade_from_version_storage = version_storage_record.value
             except StorageNotFoundError:
                 LOGGER.info("No ACA-Py version found in wallet storage.")
                 version_storage_record = None
 
             if "upgrade.from_version" in settings:
-                upgrade_from_version_setting = settings.get("upgrade.from_version")
+                upgrade_from_version_config = settings.get("upgrade.from_version")
                 LOGGER.info(
                     (
-                        f"Selecting {upgrade_from_version_setting} as "
+                        f"Selecting {upgrade_from_version_config} as "
                         "--from-version from the config."
                     )
                 )
 
         force_upgrade_flag = settings.get("upgrade.force_upgrade") or False
-        if upgrade_from_version_config and upgrade_from_version_setting:
+        if upgrade_from_version_storage and upgrade_from_version_config:
             if (
-                package_version.parse(upgrade_from_version_config)
-                > package_version.parse(upgrade_from_version_setting)
+                package_version.parse(upgrade_from_version_storage)
+                > package_version.parse(upgrade_from_version_config)
             ) and force_upgrade_flag:
-                upgrade_from_version = upgrade_from_version_setting
-            else:
                 upgrade_from_version = upgrade_from_version_config
+            else:
+                upgrade_from_version = upgrade_from_version_storage
         if (
             not upgrade_from_version
-            and not upgrade_from_version_config
-            and upgrade_from_version_setting
+            and not upgrade_from_version_storage
+            and upgrade_from_version_config
         ):
-            upgrade_from_version = upgrade_from_version_setting
+            upgrade_from_version = upgrade_from_version_config
         if (
             not upgrade_from_version
-            and upgrade_from_version_config
-            and not upgrade_from_version_setting
+            and upgrade_from_version_storage
+            and not upgrade_from_version_config
         ):
-            upgrade_from_version = upgrade_from_version_config
+            upgrade_from_version = upgrade_from_version_storage
         if not upgrade_from_version:
             raise UpgradeError(
                 "No upgrade from version found in wallet or settings [--from-version]"
@@ -289,7 +306,8 @@ async def upgrade(settings: dict):
                     f"{RECORD_TYPE_ACAPY_VERSION} storage record "
                     f"set to {upgrade_to_version}"
                 )
-        await root_profile.close()
+        if not profile:
+            await root_profile.close()
     except BaseError as e:
         raise UpgradeError(f"Error during upgrade: {e}")
 
@@ -314,7 +332,7 @@ def execute(argv: Sequence[str] = None):
     settings = get_settings(args)
     common_config(settings)
     loop = asyncio.get_event_loop()
-    loop.run_until_complete(upgrade(settings))
+    loop.run_until_complete(upgrade(settings=settings))
 
 
 def main():
diff --git a/aries_cloudagent/config/argparse.py b/aries_cloudagent/config/argparse.py
--- a/aries_cloudagent/config/argparse.py
+++ b/aries_cloudagent/config/argparse.py
@@ -2021,7 +2021,7 @@ def get_settings(self, args: Namespace):
         return settings
 
 
-@group(CAT_UPGRADE)
+@group(CAT_START, CAT_UPGRADE)
 class UpgradeGroup(ArgumentGroup):
     """ACA-Py Upgrade process settings."""
 
diff --git a/aries_cloudagent/core/conductor.py b/aries_cloudagent/core/conductor.py
--- a/aries_cloudagent/core/conductor.py
+++ b/aries_cloudagent/core/conductor.py
@@ -12,6 +12,7 @@
 import json
 import logging
 
+from packaging import version as package_version
 from qrcode import QRCode
 
 from ..admin.base_server import BaseAdminServer
@@ -78,6 +79,9 @@
 from .util import SHUTDOWN_EVENT_TOPIC, STARTUP_EVENT_TOPIC
 
 LOGGER = logging.getLogger(__name__)
+# Refer ACA-Py issue #2197
+# When the from version is not found
+DEFAULT_ACAPY_VERSION = "v0.7.5"
 
 
 class Conductor:
@@ -316,6 +320,7 @@ async def start(self) -> None:
         )
 
         # record ACA-Py version in Wallet, if needed
+        from_version_storage = None
         from_version = None
         agent_version = f"v{__version__}"
         async with self.root_profile.session() as session:
@@ -325,33 +330,47 @@ async def start(self) -> None:
                     type_filter=RECORD_TYPE_ACAPY_VERSION,
                     tag_query={},
                 )
-                from_version = record.value
+                from_version_storage = record.value
+                LOGGER.info(
+                    "Existing acapy_version storage record found, "
+                    f"version set to {from_version_storage}"
+                )
             except StorageNotFoundError:
-                LOGGER.warning(("Wallet version storage record not found."))
-        from_version = from_version or self.root_profile.settings.get(
-            "upgrade.config_path"
+                LOGGER.warning("Wallet version storage record not found.")
+        from_version_config = self.root_profile.settings.get("upgrade.from_version")
+        force_upgrade_flag = (
+            self.root_profile.settings.get("upgrade.force_upgrade") or False
         )
-        if from_version:
-            config_available_list = get_upgrade_version_list(
-                config_path=self.root_profile.settings.get("upgrade.config_path"),
-                from_version=from_version,
-            )
-            if len(config_available_list) >= 1 and (
-                from_version != agent_version
-                or self.root_profile.settings.get("upgrade.force_upgrade")
-            ):
-                await upgrade(self.root_profile.settings)
+
+        if force_upgrade_flag and from_version_config:
+            if from_version_storage:
+                if package_version.parse(from_version_storage) > package_version.parse(
+                    from_version_config
+                ):
+                    from_version = from_version_config
+                else:
+                    from_version = from_version_storage
+            else:
+                from_version = from_version_config
         else:
+            from_version = from_version_storage or from_version_config
+        if not from_version:
             LOGGER.warning(
                 (
                     "No upgrade from version was found from wallet or via"
-                    " --from-version startup argument. "
-                    f"{RECORD_TYPE_ACAPY_VERSION} storage record will be "
-                    f"set to {agent_version}. You can run run the upgrade "
-                    "command with --from-version and --force-upgrade to "
-                    "upgrade later."
+                    " --from-version startup argument. Defaulting to "
+                    f"{DEFAULT_ACAPY_VERSION}."
                 )
             )
+            from_version = DEFAULT_ACAPY_VERSION
+            self.root_profile.settings.set_value("upgrade.from_version", from_version)
+        config_available_list = get_upgrade_version_list(
+            config_path=self.root_profile.settings.get("upgrade.config_path"),
+            from_version=from_version,
+        )
+        if len(config_available_list) >= 1:
+            await upgrade(profile=self.root_profile)
+        elif not (from_version_storage and from_version_storage == agent_version):
             await add_version_record(profile=self.root_profile, version=agent_version)
 
         # Create a static connection for use by the test-suite


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
@@ -46,7 +46,7 @@ async def test_upgrade_storage_from_version_included(self):
             ConnRecord, "save", async_mock.CoroutineMock()
         ):
             await test_module.upgrade(
-                {
+                settings={
                     "upgrade.config_path": "./aries_cloudagent/commands/default_version_upgrade_config.yml",
                     "upgrade.from_version": "v0.7.2",
                 }
@@ -69,29 +69,21 @@ async def test_upgrade_storage_missing_from_version(self):
         ), async_mock.patch.object(
             ConnRecord, "save", async_mock.CoroutineMock()
         ):
-            await test_module.upgrade({})
+            await test_module.upgrade(settings={})
 
     async def test_upgrade_from_version(self):
+        self.profile.settings.extend(
+            {
+                "upgrade.from_version": "v0.7.2",
+            }
+        )
         with async_mock.patch.object(
-            test_module,
-            "wallet_config",
-            async_mock.CoroutineMock(
-                return_value=(
-                    self.profile,
-                    async_mock.CoroutineMock(did="public DID", verkey="verkey"),
-                )
-            ),
-        ), async_mock.patch.object(
             ConnRecord,
             "query",
             async_mock.CoroutineMock(return_value=[ConnRecord()]),
-        ), async_mock.patch.object(
-            ConnRecord, "save", async_mock.CoroutineMock()
-        ):
+        ), async_mock.patch.object(ConnRecord, "save", async_mock.CoroutineMock()):
             await test_module.upgrade(
-                {
-                    "upgrade.from_version": "v0.7.2",
-                }
+                profile=self.profile,
             )
 
     async def test_upgrade_callable(self):
@@ -125,7 +117,7 @@ async def test_upgrade_callable(self):
             ),
         ):
             await test_module.upgrade(
-                {
+                settings={
                     "upgrade.from_version": "v0.7.2",
                 }
             )
@@ -146,7 +138,7 @@ async def test_upgrade_x_same_version(self):
             ),
         ):
             await test_module.upgrade(
-                {
+                settings={
                     "upgrade.config_path": "./aries_cloudagent/commands/default_version_upgrade_config.yml",
                 }
             )
@@ -174,7 +166,7 @@ async def test_upgrade_missing_from_version(self):
         ):
             with self.assertRaises(UpgradeError) as ctx:
                 await test_module.upgrade(
-                    {
+                    settings={
                         "upgrade.config_path": "./aries_cloudagent/commands/default_version_upgrade_config.yml",
                     }
                 )
@@ -213,7 +205,7 @@ async def test_upgrade_x_callable_not_set(self):
         ):
             with self.assertRaises(UpgradeError) as ctx:
                 await test_module.upgrade(
-                    {
+                    settings={
                         "upgrade.from_version": "v0.6.0",
                     }
                 )
@@ -246,7 +238,7 @@ async def test_upgrade_x_class_not_found(self):
         ):
             with self.assertRaises(UpgradeError) as ctx:
                 await test_module.upgrade(
-                    {
+                    settings={
                         "upgrade.from_version": "v0.7.2",
                     }
                 )
@@ -311,7 +303,7 @@ async def test_upgrade_x_invalid_record_type(self):
         ):
             with self.assertRaises(UpgradeError) as ctx:
                 await test_module.upgrade(
-                    {
+                    settings={
                         "upgrade.from_version": "v0.7.2",
                     }
                 )
@@ -350,7 +342,7 @@ async def test_upgrade_force(self):
             ),
         ):
             await test_module.upgrade(
-                {
+                settings={
                     "upgrade.from_version": "v0.7.0",
                     "upgrade.force_upgrade": True,
                 }
@@ -381,9 +373,17 @@ async def test_upgrade_x_invalid_config(self):
             async_mock.MagicMock(return_value={}),
         ):
             with self.assertRaises(UpgradeError) as ctx:
-                await test_module.upgrade({})
+                await test_module.upgrade(settings={})
             assert "No version configs found in" in str(ctx.exception)
 
+    async def test_upgrade_x_params(self):
+        with self.assertRaises(UpgradeError) as ctx:
+            await test_module.upgrade(profile=self.profile, settings={})
+        assert "upgrade requires either profile or settings" in str(ctx.exception)
+        with self.assertRaises(UpgradeError) as ctx:
+            await test_module.upgrade(profile=self.profile, settings={"...": "..."})
+        assert "upgrade requires either profile or settings" in str(ctx.exception)
+
     def test_main(self):
         with async_mock.patch.object(
             test_module, "__name__", "__main__"
diff --git a/aries_cloudagent/core/tests/test_conductor.py b/aries_cloudagent/core/tests/test_conductor.py
--- a/aries_cloudagent/core/tests/test_conductor.py
+++ b/aries_cloudagent/core/tests/test_conductor.py
@@ -157,6 +157,180 @@ async def test_startup_version_record_exists(self):
             mock_inbound_mgr.return_value.stop.assert_awaited_once_with()
             mock_outbound_mgr.return_value.stop.assert_awaited_once_with()
 
+    async def test_startup_version_no_upgrade_add_record(self):
+        builder: ContextBuilder = StubContextBuilder(self.test_settings)
+        conductor = test_module.Conductor(builder)
+
+        with async_mock.patch.object(
+            test_module, "InboundTransportManager", autospec=True
+        ) as mock_inbound_mgr, async_mock.patch.object(
+            test_module, "OutboundTransportManager", autospec=True
+        ) as mock_outbound_mgr, async_mock.patch.object(
+            BaseStorage,
+            "find_record",
+            async_mock.AsyncMock(return_value=async_mock.MagicMock(value="v0.8.1")),
+        ), async_mock.patch.object(
+            test_module,
+            "get_upgrade_version_list",
+            async_mock.MagicMock(return_value=[]),
+        ), async_mock.patch.object(
+            test_module,
+            "add_version_record",
+            async_mock.AsyncMock(),
+        ):
+            mock_outbound_mgr.return_value.registered_transports = {
+                "test": async_mock.MagicMock(schemes=["http"])
+            }
+            await conductor.setup()
+            session = await conductor.root_profile.session()
+            wallet = session.inject(BaseWallet)
+            await wallet.create_public_did(
+                SOV,
+                ED25519,
+            )
+            mock_inbound_mgr.return_value.registered_transports = {}
+            mock_outbound_mgr.return_value.registered_transports = {}
+            await conductor.start()
+            await conductor.stop()
+
+        with async_mock.patch.object(
+            test_module, "InboundTransportManager", autospec=True
+        ) as mock_inbound_mgr, async_mock.patch.object(
+            test_module, "OutboundTransportManager", autospec=True
+        ) as mock_outbound_mgr, async_mock.patch.object(
+            BaseStorage,
+            "find_record",
+            async_mock.AsyncMock(
+                return_value=async_mock.MagicMock(value=f"v{__version__}")
+            ),
+        ), async_mock.patch.object(
+            test_module,
+            "get_upgrade_version_list",
+            async_mock.MagicMock(return_value=[]),
+        ):
+            mock_outbound_mgr.return_value.registered_transports = {
+                "test": async_mock.MagicMock(schemes=["http"])
+            }
+            await conductor.setup()
+            session = await conductor.root_profile.session()
+            wallet = session.inject(BaseWallet)
+            await wallet.create_public_did(
+                SOV,
+                ED25519,
+            )
+            mock_inbound_mgr.return_value.registered_transports = {}
+            mock_outbound_mgr.return_value.registered_transports = {}
+            await conductor.start()
+            await conductor.stop()
+
+    async def test_startup_version_force_upgrade(self):
+        test_settings = {
+            "admin.webhook_urls": ["http://sample.webhook.ca"],
+            "upgrade.from_version": "v0.7.5",
+            "upgrade.force_upgrade": True,
+        }
+        builder: ContextBuilder = StubContextBuilder(test_settings)
+        conductor = test_module.Conductor(builder)
+        with async_mock.patch.object(
+            test_module, "InboundTransportManager", autospec=True
+        ) as mock_inbound_mgr, async_mock.patch.object(
+            test_module, "OutboundTransportManager", autospec=True
+        ) as mock_outbound_mgr, async_mock.patch.object(
+            test_module, "LoggingConfigurator", autospec=True
+        ) as mock_logger, async_mock.patch.object(
+            BaseStorage,
+            "find_record",
+            async_mock.AsyncMock(return_value=async_mock.MagicMock(value="v0.8.0")),
+        ), async_mock.patch.object(
+            test_module,
+            "get_upgrade_version_list",
+            async_mock.MagicMock(return_value=["v0.8.0-rc1", "v8.0.0", "v0.8.1-rc1"]),
+        ), async_mock.patch.object(
+            test_module,
+            "upgrade",
+            async_mock.AsyncMock(),
+        ):
+            mock_outbound_mgr.return_value.registered_transports = {
+                "test": async_mock.MagicMock(schemes=["http"])
+            }
+            await conductor.setup()
+            session = await conductor.root_profile.session()
+            wallet = session.inject(BaseWallet)
+            await wallet.create_public_did(
+                SOV,
+                ED25519,
+            )
+            mock_inbound_mgr.return_value.registered_transports = {}
+            mock_outbound_mgr.return_value.registered_transports = {}
+            await conductor.start()
+            await conductor.stop()
+
+        with async_mock.patch.object(
+            test_module, "InboundTransportManager", autospec=True
+        ) as mock_inbound_mgr, async_mock.patch.object(
+            test_module, "OutboundTransportManager", autospec=True
+        ) as mock_outbound_mgr, async_mock.patch.object(
+            test_module, "LoggingConfigurator", autospec=True
+        ) as mock_logger, async_mock.patch.object(
+            BaseStorage,
+            "find_record",
+            async_mock.AsyncMock(return_value=async_mock.MagicMock(value="v0.7.0")),
+        ), async_mock.patch.object(
+            test_module,
+            "get_upgrade_version_list",
+            async_mock.MagicMock(return_value=["v0.7.2", "v0.7.3", "v0.7.4"]),
+        ), async_mock.patch.object(
+            test_module,
+            "upgrade",
+            async_mock.AsyncMock(),
+        ):
+            mock_outbound_mgr.return_value.registered_transports = {
+                "test": async_mock.MagicMock(schemes=["http"])
+            }
+            await conductor.setup()
+            session = await conductor.root_profile.session()
+            wallet = session.inject(BaseWallet)
+            await wallet.create_public_did(
+                SOV,
+                ED25519,
+            )
+            mock_inbound_mgr.return_value.registered_transports = {}
+            mock_outbound_mgr.return_value.registered_transports = {}
+            await conductor.start()
+            await conductor.stop()
+
+        with async_mock.patch.object(
+            test_module, "InboundTransportManager", autospec=True
+        ) as mock_inbound_mgr, async_mock.patch.object(
+            test_module, "OutboundTransportManager", autospec=True
+        ) as mock_outbound_mgr, async_mock.patch.object(
+            test_module, "LoggingConfigurator", autospec=True
+        ) as mock_logger, async_mock.patch.object(
+            BaseStorage,
+            "find_record",
+            async_mock.AsyncMock(side_effect=StorageNotFoundError()),
+        ), async_mock.patch.object(
+            test_module,
+            "get_upgrade_version_list",
+            async_mock.MagicMock(return_value=["v0.8.0-rc1", "v8.0.0", "v0.8.1-rc1"]),
+        ), async_mock.patch.object(
+            test_module,
+            "upgrade",
+            async_mock.AsyncMock(),
+        ):
+            await conductor.setup()
+            session = await conductor.root_profile.session()
+            wallet = session.inject(BaseWallet)
+            await wallet.create_public_did(
+                SOV,
+                ED25519,
+            )
+            mock_inbound_mgr.return_value.registered_transports = {}
+            mock_outbound_mgr.return_value.registered_transports = {}
+            await conductor.start()
+            mock_logger.print_banner.assert_called_once()
+            await conductor.stop()
+
     async def test_startup_version_record_not_exists(self):
         builder: ContextBuilder = StubContextBuilder(self.test_settings)
         conductor = test_module.Conductor(builder)
@@ -173,7 +347,11 @@ async def test_startup_version_record_not_exists(self):
             async_mock.AsyncMock(side_effect=StorageNotFoundError()),
         ), async_mock.patch.object(
             test_module,
-            "add_version_record",
+            "get_upgrade_version_list",
+            async_mock.MagicMock(return_value=["v0.8.0-rc1", "v8.0.0", "v0.8.1-rc1"]),
+        ), async_mock.patch.object(
+            test_module,
+            "upgrade",
             async_mock.AsyncMock(),
         ):
             mock_outbound_mgr.return_value.registered_transports = {
@@ -1444,7 +1622,7 @@ async def test_startup_x_no_storage_version(self):
             async_mock.AsyncMock(side_effect=StorageNotFoundError()),
         ), async_mock.patch.object(
             test_module,
-            "add_version_record",
+            "upgrade",
             async_mock.AsyncMock(),
         ):
             mock_outbound_mgr.return_value.registered_transports = {