Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi-tenancy stale wallet clean up #1692

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7d88b23
feat: add caching for multi-tenant profiles
dbluhm Mar 23, 2022
0769351
feat: add finalizers to profile impls
dbluhm Mar 23, 2022
fd057e2
feat: profile cache only on non askar-profile
dbluhm Mar 25, 2022
fdac67c
refactor: conductor iterates through open_profiles
dbluhm Mar 25, 2022
22e05f6
fix: multitenant manager tests
dbluhm Mar 25, 2022
ac585c9
fix: askar profile tests
dbluhm Mar 25, 2022
6f613ea
fix: open_profiles should be property
dbluhm Mar 25, 2022
ce7ccf1
fix: askar profile tests, profile passed to sesh, txn
dbluhm Mar 25, 2022
c3d9886
Merge branch 'main' into feature/multitenant-profile-cache
dbluhm Mar 25, 2022
a2e306e
feat: profile cache track open profiles via weakref
dbluhm Mar 29, 2022
c32d907
fix: create task for profile finalizer
dbluhm Apr 7, 2022
d0e2a6d
Merge branch 'main' into feature/multitenant-profile-cache
dbluhm Apr 7, 2022
3528409
style: black fixes
dbluhm Apr 7, 2022
f424dbe
fix: prevent circular references from breaking cache
dbluhm Apr 12, 2022
a7b5f84
Merge branch 'main' into feature/multitenant-profile-cache
dbluhm Apr 12, 2022
a091c63
feat: rework multitenancy-config arg
dbluhm Apr 12, 2022
2ffd521
fix: parsing multiple multitenancy-config options
dbluhm Apr 12, 2022
b5b0937
fix: multitenancy config json parsing and tests
dbluhm Apr 12, 2022
569a0ca
Merge branch 'main' into feature/multitenant-profile-cache
dbluhm Apr 13, 2022
1c21804
test: askar profile finalizer
dbluhm Apr 14, 2022
108ecab
refactor: indy test profile by pytest convention
dbluhm Apr 14, 2022
2e80b29
test: indy sdk profile finalizer
dbluhm Apr 14, 2022
9cfbcda
test: cache_size parsing
dbluhm Apr 14, 2022
0422d8a
test: dispatcher responder context weak ref
dbluhm Apr 14, 2022
94b0c3e
test: rescuing still open profile from eviction
dbluhm Apr 14, 2022
a9402f4
test: askar profile manager open profiles
dbluhm Apr 14, 2022
625b276
Merge branch 'main' into feature/multitenant-profile-cache
dbluhm Apr 14, 2022
4a5f65d
fix: self.manager for mocking methods in tests
dbluhm Apr 14, 2022
4bf26e5
test: admin responder profile expired
dbluhm Apr 14, 2022
e84b3f8
feat: askar profiles don't need finalizers
dbluhm May 3, 2022
96e715c
Merge branch 'main' into feature/multitenant-profile-cache
dbluhm May 3, 2022
df75214
Merge branch 'main' into feature/multitenant-profile-cache
dbluhm May 17, 2022
e3c8a10
Merge branch 'main' into feature/multitenant-profile-cache
dbluhm Jun 2, 2022
20d753b
fix: oddities and typos
dbluhm Jun 2, 2022
da236fd
fix: add key_derivation_method help text back in
dbluhm Jun 2, 2022
9575d07
fix: missing async marks for pytest
dbluhm Jun 2, 2022
b018ba8
Merge branch 'main' into feature/multitenant-profile-cache
dbluhm Jun 9, 2022
537425e
Merge branch 'main' into feature/multitenant-profile-cache
dbluhm Jun 22, 2022
5af0ae2
Merge branch 'main' into feature/multitenant-profile-cache
dbluhm Jun 22, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions aries_cloudagent/admin/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Callable, Coroutine
import uuid
import warnings
import weakref

from aiohttp import web
from aiohttp_apispec import (
Expand Down Expand Up @@ -115,7 +116,11 @@ def __init__(

"""
super().__init__(**kwargs)
self._profile = profile
# Weakly hold the profile so this reference doesn't prevent profiles
# from being cleaned up when appropriate.
# Binding this AdminResponder to the profile's context creates a circular
# reference.
self._profile = weakref.ref(profile)
self._send = send

async def send_outbound(self, message: OutboundMessage) -> OutboundSendStatus:
Expand All @@ -125,7 +130,10 @@ async def send_outbound(self, message: OutboundMessage) -> OutboundSendStatus:
Args:
message: The `OutboundMessage` to be sent
"""
return await self._send(self._profile, message)
profile = self._profile()
if not profile:
raise RuntimeError("weakref to profile has expired")
return await self._send(profile, message)

async def send_webhook(self, topic: str, payload: dict):
"""
Expand All @@ -139,7 +147,10 @@ async def send_webhook(self, topic: str, payload: dict):
"responder.send_webhook is deprecated; please use the event bus instead.",
DeprecationWarning,
)
await self._profile.notify("acapy::webhook::" + topic, payload)
profile = self._profile()
if not profile:
raise RuntimeError("weakref to profile has expired")
await profile.notify("acapy::webhook::" + topic, payload)

@property
def send_fn(self) -> Coroutine:
Expand Down
17 changes: 15 additions & 2 deletions aries_cloudagent/admin/tests/test_admin_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,9 @@ async def test_import_routes_multitenant_middleware(self):
context = InjectionContext()
context.injector.bind_instance(ProtocolRegistry, ProtocolRegistry())
context.injector.bind_instance(GoalCodeRegistry, GoalCodeRegistry())
profile = InMemoryProfile.test_profile()
context.injector.bind_instance(
test_module.BaseMultitenantManager,
test_module.BaseMultitenantManager(profile),
async_mock.MagicMock(spec=test_module.BaseMultitenantManager),
)
await DefaultContextBuilder().load_plugins(context)
server = self.get_admin_server(
Expand Down Expand Up @@ -486,3 +485,17 @@ async def test_on_record_event(server, event_topic, webhook_topic):
) as mock_send_webhook:
await server._on_record_event(profile, Event(event_topic, None))
mock_send_webhook.assert_called_once_with(profile, webhook_topic, None)


@pytest.mark.asyncio
async def test_admin_responder_profile_expired_x():
def _smaller_scope():
profile = InMemoryProfile.test_profile()
return test_module.AdminResponder(profile, None)

responder = _smaller_scope()
with pytest.raises(RuntimeError):
await responder.send_outbound(None)

with pytest.raises(RuntimeError):
await responder.send_webhook("test", {})
18 changes: 12 additions & 6 deletions aries_cloudagent/askar/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,18 @@ class AskarProfile(Profile):

BACKEND_NAME = "askar"

def __init__(self, opened: AskarOpenStore, context: InjectionContext = None):
def __init__(
self,
opened: AskarOpenStore,
context: InjectionContext = None,
*,
profile_id: str = None
):
"""Create a new AskarProfile instance."""
super().__init__(context=context, name=opened.name, created=opened.created)
self.opened = opened
self.ledger_pool: IndyVdrLedgerPool = None
self.profile_id = profile_id
self.init_ledger_pool()
self.bind_providers()

Expand All @@ -56,8 +63,8 @@ def store(self) -> Store:

async def remove(self):
"""Remove the profile."""
if self.settings.get("multitenant.wallet_type") == "askar-profile":
await self.store.remove_profile(self.settings.get("wallet.askar_profile"))
if self.profile_id:
await self.store.remove_profile(self.profile_id)

def init_ledger_pool(self):
"""Initialize the ledger pool."""
Expand Down Expand Up @@ -160,11 +167,10 @@ def __init__(
):
"""Create a new IndySdkProfileSession instance."""
super().__init__(profile=profile, context=context, settings=settings)
profile_id = profile.context.settings.get("wallet.askar_profile")
if is_txn:
self._opener = self.profile.store.transaction(profile_id)
self._opener = self.profile.store.transaction(profile.profile_id)
else:
self._opener = self.profile.store.session(profile_id)
self._opener = self.profile.store.session(profile.profile_id)
self._handle: Session = None
self._acquire_start: float = None
self._acquire_end: float = None
Expand Down
155 changes: 78 additions & 77 deletions aries_cloudagent/askar/tests/test_profile.py
Original file line number Diff line number Diff line change
@@ -1,87 +1,88 @@
import asyncio
import logging
import pytest

from asynctest import TestCase as AsyncTestCase, mock
from asynctest import mock

from ...askar.profile import AskarProfile
from ...config.injection_context import InjectionContext

from .. import profile as test_module


class TestProfile(AsyncTestCase):
@mock.patch("aries_cloudagent.askar.store.AskarOpenStore")
async def test_init_success(self, AskarOpenStore):
askar_profile = AskarProfile(
AskarOpenStore,
)

assert askar_profile.opened == AskarOpenStore

@mock.patch("aries_cloudagent.askar.store.AskarOpenStore")
async def test_remove_success(self, AskarOpenStore):
openStore = AskarOpenStore
context = InjectionContext()
profile_id = "profile_id"
context.settings = {
"multitenant.wallet_type": "askar-profile",
"wallet.askar_profile": profile_id,
"ledger.genesis_transactions": mock.MagicMock(),
}
askar_profile = AskarProfile(openStore, context)
remove_profile_stub = asyncio.Future()
remove_profile_stub.set_result(True)
openStore.store.remove_profile.return_value = remove_profile_stub

await askar_profile.remove()

openStore.store.remove_profile.assert_called_once_with(profile_id)

@mock.patch("aries_cloudagent.askar.store.AskarOpenStore")
async def test_remove_profile_not_removed_if_wallet_type_not_askar_profile(
self, AskarOpenStore
):
openStore = AskarOpenStore
context = InjectionContext()
context.settings = {"multitenant.wallet_type": "basic"}
askar_profile = AskarProfile(openStore, context)

await askar_profile.remove()

openStore.store.remove_profile.assert_not_called()

@pytest.mark.asyncio
async def test_profile_manager_transaction(self):
profile = "profileId"

with mock.patch("aries_cloudagent.askar.profile.AskarProfile") as AskarProfile:
askar_profile = AskarProfile(None, True)
askar_profile_transaction = mock.MagicMock()
askar_profile.store.transaction.return_value = askar_profile_transaction
askar_profile.context.settings.get.return_value = profile

transactionProfile = test_module.AskarProfileSession(askar_profile, True)

assert transactionProfile._opener == askar_profile_transaction
askar_profile.context.settings.get.assert_called_once_with(
"wallet.askar_profile"
)
askar_profile.store.transaction.assert_called_once_with(profile)

@pytest.mark.asyncio
async def test_profile_manager_store(self):
profile = "profileId"

with mock.patch("aries_cloudagent.askar.profile.AskarProfile") as AskarProfile:
askar_profile = AskarProfile(None, False)
askar_profile_session = mock.MagicMock()
askar_profile.store.session.return_value = askar_profile_session
askar_profile.context.settings.get.return_value = profile

sessionProfile = test_module.AskarProfileSession(askar_profile, False)

assert sessionProfile._opener == askar_profile_session
askar_profile.context.settings.get.assert_called_once_with(
"wallet.askar_profile"
)
askar_profile.store.session.assert_called_once_with(profile)
@pytest.fixture
def open_store():
yield mock.MagicMock()


@pytest.mark.asyncio
async def test_init_success(open_store):
askar_profile = AskarProfile(
open_store,
)

assert askar_profile.opened == open_store


@pytest.mark.asyncio
async def test_remove_success(open_store):
openStore = open_store
context = InjectionContext()
profile_id = "profile_id"
context.settings = {
"multitenant.wallet_type": "askar-profile",
"wallet.askar_profile": profile_id,
"ledger.genesis_transactions": mock.MagicMock(),
}
askar_profile = AskarProfile(openStore, context, profile_id=profile_id)
remove_profile_stub = asyncio.Future()
remove_profile_stub.set_result(True)
openStore.store.remove_profile.return_value = remove_profile_stub

await askar_profile.remove()

openStore.store.remove_profile.assert_called_once_with(profile_id)


@pytest.mark.asyncio
async def test_remove_profile_not_removed_if_wallet_type_not_askar_profile(open_store):
openStore = open_store
context = InjectionContext()
context.settings = {"multitenant.wallet_type": "basic"}
askar_profile = AskarProfile(openStore, context)

await askar_profile.remove()

openStore.store.remove_profile.assert_not_called()


@pytest.mark.asyncio
async def test_profile_manager_transaction():
profile = "profileId"

with mock.patch("aries_cloudagent.askar.profile.AskarProfile") as AskarProfile:
askar_profile = AskarProfile(None, True, profile_id=profile)
askar_profile.profile_id = profile
askar_profile_transaction = mock.MagicMock()
askar_profile.store.transaction.return_value = askar_profile_transaction

transactionProfile = test_module.AskarProfileSession(askar_profile, True)

assert transactionProfile._opener == askar_profile_transaction
askar_profile.store.transaction.assert_called_once_with(profile)


@pytest.mark.asyncio
async def test_profile_manager_store():
profile = "profileId"

with mock.patch("aries_cloudagent.askar.profile.AskarProfile") as AskarProfile:
askar_profile = AskarProfile(None, False, profile_id=profile)
askar_profile.profile_id = profile
askar_profile_session = mock.MagicMock()
askar_profile.store.session.return_value = askar_profile_session

sessionProfile = test_module.AskarProfileSession(askar_profile, False)

assert sessionProfile._opener == askar_profile_session
askar_profile.store.session.assert_called_once_with(profile)
59 changes: 38 additions & 21 deletions aries_cloudagent/config/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -1620,13 +1620,15 @@ def add_arguments(self, parser: ArgumentParser):
parser.add_argument(
"--multitenancy-config",
type=str,
metavar="<multitenancy-config>",
nargs="+",
metavar="key=value",
env_var="ACAPY_MULTITENANCY_CONFIGURATION",
help=(
'Specify multitenancy configuration ("wallet_type" and "wallet_name"). '
'For example: "{"wallet_type":"askar-profile","wallet_name":'
'"askar-profile-name", "key_derivation_method":"RAW"}"'
'"wallet_name" is only used when "wallet_type" is "askar-profile"'
"Specify multitenancy configuration in key=value pairs. "
'For example: "wallet_type=askar-profile wallet_name=askar-profile-name" '
"Possible values: wallet_name, wallet_key, cache_size, "
'key_derivation_method. "wallet_name" is only used when '
'"wallet_type" is "askar-profile"'
),
)

Expand All @@ -1647,22 +1649,37 @@ def get_settings(self, args: Namespace):
settings["multitenant.admin_enabled"] = True

if args.multitenancy_config:
multitenancyConfig = json.loads(args.multitenancy_config)

if multitenancyConfig.get("wallet_type"):
settings["multitenant.wallet_type"] = multitenancyConfig.get(
"wallet_type"
)

if multitenancyConfig.get("wallet_name"):
settings["multitenant.wallet_name"] = multitenancyConfig.get(
"wallet_name"
)

if multitenancyConfig.get("key_derivation_method"):
settings[
"multitenant.key_derivation_method"
] = multitenancyConfig.get("key_derivation_method")
# Legacy support
if (
len(args.multitenancy_config) == 1
and args.multitenancy_config[0][0] == "{"
):
multitenancy_config = json.loads(args.multitenancy_config[0])
if multitenancy_config.get("wallet_type"):
settings["multitenant.wallet_type"] = multitenancy_config.get(
"wallet_type"
)

if multitenancy_config.get("wallet_name"):
settings["multitenant.wallet_name"] = multitenancy_config.get(
"wallet_name"
)

if multitenancy_config.get("cache_size"):
settings["multitenant.cache_size"] = multitenancy_config.get(
"cache_size"
)

if multitenancy_config.get("key_derivation_method"):
settings[
"multitenant.key_derivation_method"
] = multitenancy_config.get("key_derivation_method")

else:
for value_str in args.multitenancy_config:
key, value = value_str.split("=", maxsplit=1)
value = yaml.safe_load(value)
settings[f"multitenant.{key}"] = value

return settings

Expand Down
21 changes: 20 additions & 1 deletion aries_cloudagent/config/tests/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,26 @@ async def test_multitenancy_settings(self):
"--jwt-secret",
"secret",
"--multitenancy-config",
'{"wallet_type":"askar","wallet_name":"test"}',
'{"wallet_type":"askar","wallet_name":"test", "cache_size": 10}',
]
)

settings = group.get_settings(result)

assert settings.get("multitenant.enabled") == True
assert settings.get("multitenant.jwt_secret") == "secret"
assert settings.get("multitenant.wallet_type") == "askar"
assert settings.get("multitenant.wallet_name") == "test"

result = parser.parse_args(
[
"--multitenant",
"--jwt-secret",
"secret",
"--multitenancy-config",
"wallet_type=askar",
"wallet_name=test",
"cache_size=10",
]
)

Expand Down
2 changes: 1 addition & 1 deletion aries_cloudagent/core/conductor.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ async def stop(self, timeout=1.0):
# close multitenant profiles
multitenant_mgr = self.context.inject_or(BaseMultitenantManager)
if multitenant_mgr:
for profile in multitenant_mgr._instances.values():
for profile in multitenant_mgr.open_profiles:
shutdown.run(profile.close())

if self.root_profile:
Expand Down
Loading