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

Core: Use EmulatorBaseStore #10680

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
284 changes: 17 additions & 267 deletions localstack/services/stores.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,169 +30,26 @@ class SqsStore(BaseStore):
"""

import re
from collections.abc import Callable
from threading import RLock
from typing import Any, Generic, Iterator, Type, TypeVar, Union
from typing import Type

from localstack import config
from localstack.utils.aws.aws_stack import get_valid_regions_for_service

LOCAL_ATTR_PREFIX = "attr_"

BaseStoreType = TypeVar("BaseStoreType")


#
# Descriptor protocol classes
#


class LocalAttribute:
"""
Descriptor protocol for marking store attributes as local to a region.
"""

def __init__(self, default: Union[Callable, int, float, str, bool, None]):
"""
:param default: Default value assigned to the local attribute. Must be a scalar
or a callable.
"""
self.default = default

def __set_name__(self, owner, name):
self.name = LOCAL_ATTR_PREFIX + name

def __get__(self, obj: BaseStoreType, objtype=None) -> Any:
if not hasattr(obj, self.name):
if isinstance(self.default, Callable):
value = self.default()
else:
value = self.default
setattr(obj, self.name, value)

return getattr(obj, self.name)

def __set__(self, obj: BaseStoreType, value: Any):
setattr(obj, self.name, value)


class CrossRegionAttribute:
"""
Descriptor protocol for marking store attributes as shared across all regions.
"""

def __init__(self, default: Union[Callable, int, float, str, bool, None]):
"""
:param default: The default value assigned to the cross-region attribute.
This must be a scalar or a callable.
"""
self.default = default

def __set_name__(self, owner, name):
self.name = name

def __get__(self, obj: BaseStoreType, objtype=None) -> Any:
self._check_region_store_association(obj)

if self.name not in obj._global.keys():
if isinstance(self.default, Callable):
obj._global[self.name] = self.default()
else:
obj._global[self.name] = self.default

return obj._global[self.name]

def __set__(self, obj: BaseStoreType, value: Any):
self._check_region_store_association(obj)

obj._global[self.name] = value

def _check_region_store_association(self, obj):
if not hasattr(obj, "_global"):
# Raise if a Store is instantiated outside of a RegionBundle
raise AttributeError(
"Could not resolve cross-region attribute because there is no associated RegionBundle"
)


class CrossAccountAttribute:
"""
Descriptor protocol for marking a store attributes as shared across all regions and accounts.

This should be used for resources that are identified by ARNs.
"""

def __init__(self, default: Union[Callable, int, float, str, bool, None]):
"""
:param default: The default value assigned to the cross-account attribute.
This must be a scalar or a callable.
"""
self.default = default

def __set_name__(self, owner, name):
self.name = name

def __get__(self, obj: BaseStoreType, objtype=None) -> Any:
self._check_account_store_association(obj)

if self.name not in obj._universal.keys():
if isinstance(self.default, Callable):
obj._universal[self.name] = self.default()
else:
obj._universal[self.name] = self.default

return obj._universal[self.name]

def __set__(self, obj: BaseStoreType, value: Any):
self._check_account_store_association(obj)

obj._universal[self.name] = value

def _check_account_store_association(self, obj):
if not hasattr(obj, "_universal"):
# Raise if a Store is instantiated outside an AccountRegionBundle
raise AttributeError(
"Could not resolve cross-account attribute because there is no associated AccountRegionBundle"
)


#
# Base models
#


class BaseStore:
"""
Base class for defining stores for LocalStack providers.
"""

_service_name: str
_account_id: str
_region_name: str
_global: dict
_universal: dict

def __repr__(self):
try:
repr_templ = "<{name} object for {service_name} at {account_id}/{region_name}>"
return repr_templ.format(
name=self.__class__.__name__,
service_name=self._service_name,
account_id=self._account_id,
region_name=self._region_name,
)
except AttributeError:
return super().__repr__()
from localstack_stores.models import CrossAccountAttribute, CrossRegionAttribute, LocalAttribute # noqa
from localstack_stores.models import BaseStore, BaseStoreType # noqa
from localstack_stores.models import GenericAccountRegionBundle, GenericRegionBundle


#
# Encapsulations
#


class RegionBundle(dict, Generic[BaseStoreType]):
class RegionBundle(GenericRegionBundle):
"""
Encapsulation for stores across all regions for a specific AWS account ID.
We're overriding the base-class to implement AWS-specific validation
"""

def __init__(
Expand All @@ -204,25 +61,17 @@ def __init__(
lock: RLock = None,
universal: dict = None,
):
self.store = store
self.account_id = account_id
self.service_name = service_name
super().__init__(
service_name=service_name,
store=store,
account_id=account_id,
lock=lock,
universal=universal
)
self.validate = validate
self.lock = lock or RLock()

self.valid_regions = get_valid_regions_for_service(service_name)

# Keeps track of all cross-region attributes. This dict is maintained at
# a region level (hence in RegionBundle). A ref is passed to every store
# intialised in this region so that backref is possible.
self._global = {}

# Keeps track of all cross-account attributes. This dict is maintained at
# the account level (ie. AccountRegionBundle). A ref is passed down from
# AccountRegionBundle to RegionBundle to individual stores to enable backref.
self._universal = universal

def __getitem__(self, region_name) -> BaseStoreType:
def validate_item(self, region_name: str) -> None:
if (
not config.ALLOW_NONSTANDARD_REGIONS
and self.validate
Expand All @@ -232,115 +81,16 @@ def __getitem__(self, region_name) -> BaseStoreType:
f"'{region_name}' is not a valid AWS region name for {self.service_name}"
)

with self.lock:
if region_name not in self.keys():
store_obj = self.store()

store_obj._global = self._global
store_obj._universal = self._universal
store_obj.service_name = self.service_name
store_obj._account_id = self.account_id
store_obj._region_name = region_name

self[region_name] = store_obj

return super().__getitem__(region_name)

def reset(self, _reset_universal: bool = False):
"""
Clear all store data.

This only deletes the data held in the stores. All instantiated stores
are retained. This includes data shared by all stores in this account
and marked by the CrossRegionAttribute descriptor.

Data marked by CrossAccountAttribute descriptor is only cleared when
`_reset_universal` is set. Note that this escapes the logical boundary of
the account associated with this RegionBundle and affects *all* accounts.
Hence this argument is not intended for public use and is only used when
invoking this method from AccountRegionBundle.
"""
# For safety, clear data in all referenced store instances, if any
for store_inst in self.values():
attrs = list(store_inst.__dict__.keys())
for attr in attrs:
# reset the cross-region attributes
if attr == "_global":
store_inst._global.clear()

if attr == "_universal" and _reset_universal:
store_inst._universal.clear()

# reset the local attributes
elif attr.startswith(LOCAL_ATTR_PREFIX):
delattr(store_inst, attr)

self._global.clear()

with self.lock:
self.clear()


class AccountRegionBundle(dict, Generic[BaseStoreType]):
class AccountRegionBundle(GenericAccountRegionBundle):
"""
Encapsulation for all stores for all AWS account IDs.
"""

def __init__(self, service_name: str, store: Type[BaseStoreType], validate: bool = True):
"""
:param service_name: Name of the service. Must be a valid service defined in botocore.
:param store: Class definition of the Store
:param validate: Whether to raise if invalid region names or account IDs are used during subscription
"""
self.service_name = service_name
self.store = store
super().__init__(service_name=service_name, store=store, region_bundle_type=RegionBundle)
self.validate = validate
self.lock = RLock()

# Keeps track of all cross-account attributes. This dict is maintained at
# the account level (hence in AccountRegionBundle). A ref is passed to
# every region bundle, which in turn passes it to every store in it.
self._universal = {}

def __getitem__(self, account_id: str) -> RegionBundle[BaseStoreType]:
def validate_item(self, account_id: str):
if self.validate and not re.match(r"\d{12}", account_id):
raise ValueError(f"'{account_id}' is not a valid AWS account ID")

with self.lock:
if account_id not in self.keys():
self[account_id] = RegionBundle(
service_name=self.service_name,
store=self.store,
account_id=account_id,
validate=self.validate,
lock=self.lock,
universal=self._universal,
)

return super().__getitem__(account_id)

def reset(self):
"""
Clear all store data.

This only deletes the data held in the stores. All instantiated stores are retained.
"""
# For safety, clear all referenced region bundles, if any
for region_bundle in self.values():
region_bundle.reset(_reset_universal=True)

self._universal.clear()

with self.lock:
self.clear()

def iter_stores(self) -> Iterator[tuple[str, str, BaseStoreType]]:
"""
Iterate over a flattened view of all stores in this AccountRegionBundle, where each record is a
tuple of account id, region name, and the store within that account and region. Example::

:return: an iterator
"""
for account_id, region_stores in self.items():
for region_name, store in region_stores.items():
yield account_id, region_name, store
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ base-runtime = [
"Werkzeug>=3.0.0",
"xmltodict>=0.13.0",
"rolo>=0.4",
# TODO: Needs a release
"[email protected]:localstack/localstack_stores.git",
# TODO: remove those 2 dependencies
"flask>=3.0.0",
"Quart>=0.19.2",
Expand Down