helpers.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. """A set of AWS-specific helper methods used by the framework."""
  2. from __future__ import annotations
  3. import logging
  4. from typing import Any
  5. from typing import TYPE_CHECKING
  6. from typing import TypeVar
  7. from boto3.resources.params import create_request_parameters
  8. from botocore import xform_name
  9. from botocore.exceptions import ClientError
  10. from botocore.utils import merge_dicts
  11. from cloudbridge.base.resources import ClientPagedResultList
  12. from cloudbridge.base.resources import ServerPagedResultList
  13. from cloudbridge.interfaces.resources import CloudResource
  14. from cloudbridge.interfaces.resources import ResultList
  15. if TYPE_CHECKING:
  16. from .provider import AWSCloudProvider
  17. log = logging.getLogger(__name__)
  18. T = TypeVar("T")
  19. # How many items to ask the service for per request, independent of how many
  20. # the caller wants back. Small enough to sit inside every EC2 describe's
  21. # documented ceiling that the service model does not declare (DescribeVolumes
  22. # is the tightest of those, at 500), large enough that a sparse filtered scan
  23. # does not turn into thousands of round trips. Operations that do declare a
  24. # ceiling are clamped to it - see BotoEC2Service._page_size.
  25. DEFAULT_PAGE_SIZE = 500
  26. def trim_empty_params(params_dict: dict[str, Any]) -> dict[str, Any]:
  27. """
  28. Given a dict containing potentially null values, trims out
  29. all the null values. This is to please Boto, which throws
  30. a parameter validation exception for NoneType arguments.
  31. e.g. Given
  32. {
  33. 'GroupName': 'abc',
  34. 'Description': None,
  35. 'VpcId': 'xyz',
  36. }
  37. returns:
  38. {
  39. 'GroupName': 'abc',
  40. 'VpcId': 'xyz'
  41. }
  42. """
  43. log.debug("Removing null values from %s", params_dict)
  44. return {k: v for k, v in params_dict.items() if v is not None}
  45. def find_tag_value(tags: list[dict[str, Any]] | None, key: str) -> Any:
  46. """
  47. Finds the value associated with a given key from a list of AWS tags.
  48. :type tags: list of ``dict``
  49. :param tags: The AWS tag list to search through
  50. :type key: ``str``
  51. :param key: Name of the tag to search for
  52. """
  53. log.info("Searching for %s in %s", key, tags)
  54. for tag in tags or []:
  55. if tag.get('Key') == key:
  56. log.info("Found %s, returning %s", key, tag.get('Value'))
  57. return tag.get('Value')
  58. return None
  59. class BotoGenericService(object):
  60. """
  61. Generic implementation of a Boto3 AWS service. Uses Boto3
  62. resource, collection and paging support to implement
  63. basic cloudbridge methods.
  64. """
  65. def __init__(self, provider: AWSCloudProvider,
  66. cb_resource: Any, boto_conn: Any,
  67. boto_collection_name: str) -> None:
  68. """
  69. :type provider: :class:`AWSCloudProvider`
  70. :param provider: CloudBridge AWS provider to use
  71. :type cb_resource: :class:`CloudResource`
  72. :param cb_resource: CloudBridge Resource class to wrap results in
  73. :type boto_conn: :class:`Boto3.Resource`
  74. :param boto_conn: Boto top level service resource (e.g. EC2, S3)
  75. connection.
  76. :type boto_collection_name: ``str``
  77. :param boto_collection_name: Boto collection name that corresponds
  78. to the CloudBridge resource (e.g. key_pair)
  79. """
  80. self.provider = provider
  81. self.cb_resource = cb_resource
  82. self.boto_conn = boto_conn
  83. self.boto_collection_model = self._infer_collection_model(
  84. boto_conn, boto_collection_name)
  85. # Perform an empty filter to convert to a ResourceCollection
  86. self.boto_collection = (getattr(self.boto_conn, boto_collection_name)
  87. .filter())
  88. self.boto_resource = self._infer_boto_resource(
  89. boto_conn, self.boto_collection_model)
  90. def _infer_collection_model(self, conn: Any, collection_name: str) -> Any:
  91. log.debug("Retrieving boto model for collection: %s", collection_name)
  92. return next(col for col in conn.meta.resource_model.collections
  93. if col.name == collection_name)
  94. def _infer_boto_resource(self, conn: Any, collection_model: Any) -> Any:
  95. log.debug("Retrieving resource model for collection: %s",
  96. collection_model.name)
  97. resource_model = next(
  98. sr for sr in conn.meta.resource_model.subresources
  99. if sr.resource.model.name == collection_model.resource.model.name)
  100. return getattr(self.boto_conn, resource_model.name)
  101. def get_raw(self, resource_id: str) -> Any:
  102. """
  103. Returns a single resource.
  104. :type resource_id: ``str``
  105. :param resource_id: ID of the boto resource to fetch
  106. :returns An unwrapped AWS resource
  107. """
  108. try:
  109. log.debug("Retrieving resource: %s with id: %s",
  110. self.boto_collection_model.name, resource_id)
  111. obj = self.boto_resource(resource_id)
  112. obj.load()
  113. log.debug("Successfully Retrieved: %s", obj)
  114. return obj
  115. except ClientError as exc:
  116. error_code = exc.response['Error']['Code']
  117. if any(status in error_code for status in
  118. ('NotFound', 'InvalidParameterValue', 'Malformed', '404')):
  119. log.debug("Object not found: %s", resource_id)
  120. return None
  121. else:
  122. raise exc
  123. def get(self, resource_id: str) -> Any:
  124. """
  125. Returns a single resource.
  126. :type resource_id: ``str``
  127. :param resource_id: ID of the boto resource to fetch
  128. :returns A CloudBridge wrapped resource
  129. """
  130. aws_res = self.get_raw(resource_id)
  131. if aws_res:
  132. return self.cb_resource(self.provider, aws_res)
  133. else:
  134. return None
  135. def _page_size(self, client: Any, list_op: str, limit: int) -> int:
  136. """
  137. Transport page size for a paginated call.
  138. ``PageSize`` is how many items the service returns per request;
  139. ``MaxItems`` is how many the caller asked for. Using the caller's
  140. limit for both makes a small limit walk a large scan in tiny
  141. increments: a filtered ``describe_images`` that takes 10.0s at a page
  142. size of 1000 takes 977.6s at 5, for the same single result. Ask for a
  143. full page regardless of the limit and let ``MaxItems`` do the
  144. bounding - at worst one page more data is fetched than was wanted,
  145. and a limit larger than a page is served over several requests.
  146. EC2's per-operation ceilings differ - ``DescribeRouteTables`` allows
  147. 100 where most allow 1000 - and exceeding one is a hard
  148. ``InvalidParameterValue`` rather than a clamp, so defer to whatever
  149. the service model declares for this operation.
  150. """
  151. # Deliberately not max(limit, ...): a caller asking for more than a
  152. # page still gets it, over several requests, rather than having an
  153. # oversized limit pushed at a service that would reject it.
  154. page_size = DEFAULT_PAGE_SIZE
  155. api_name = client.meta.method_to_api_mapping.get(list_op)
  156. if not api_name:
  157. return page_size
  158. input_shape = client.meta.service_model.operation_model(
  159. api_name).input_shape
  160. max_results = input_shape.members.get('MaxResults') if input_shape \
  161. else None
  162. # Not every operation declares bounds, even where the documentation
  163. # gives them; fall back to a size known to be within all of them.
  164. ceiling = max_results.metadata.get('max') if max_results else None
  165. return min(page_size, ceiling) if ceiling else page_size
  166. def _get_list_operation(self) -> str:
  167. """
  168. This function discovers the list operation for a particular resource
  169. collection. For example, given the resource collection model for
  170. KeyPair, it returns the list operation for it, as describe_key_pairs.
  171. """
  172. return xform_name(self.boto_collection_model.request.operation)
  173. def _to_boto_resource(self, collection: Any, params: Any,
  174. page: Any) -> Any:
  175. """
  176. This function duplicates some of the logic of the pages() method in
  177. boto.resources.collection.ResourceCollection. It will convert a raw
  178. json response to the corresponding Boto resource. It's necessary
  179. because paginators() return json responses, and there's no direct way
  180. to convert a paginated json response to a Boto Resource.
  181. """
  182. # pylint:disable=protected-access
  183. return collection._handler(collection._parent, params, page)
  184. def _get_paginated_results(self, limit: int | None, marker: str | None,
  185. collection: Any) -> tuple[Any, Any]:
  186. """
  187. If a Boto Paginator is available, use it. The results
  188. are converted back into BotoResources by directly accessing
  189. protected members of ResourceCollection. This logic can be removed
  190. depending on issue: https://github.com/boto/boto3/issues/1268.
  191. """
  192. # pylint:disable=protected-access
  193. cleaned_params = collection._params.copy()
  194. cleaned_params.pop('limit', None)
  195. cleaned_params.pop('page_size', None)
  196. # pylint:disable=protected-access
  197. params = create_request_parameters(
  198. collection._parent, collection._model.request)
  199. merge_dicts(params, cleaned_params, append_lists=True)
  200. client = self.boto_conn.meta.client
  201. list_op = self._get_list_operation()
  202. paginator = client.get_paginator(list_op)
  203. PaginationConfig: dict[str, Any] = {}
  204. if limit:
  205. PaginationConfig = {
  206. 'MaxItems': limit,
  207. 'PageSize': self._page_size(client, list_op, limit)}
  208. if marker:
  209. PaginationConfig.update({'StartingToken': marker})
  210. params.update({'PaginationConfig': PaginationConfig})
  211. args = trim_empty_params(params)
  212. pages = paginator.paginate(**args)
  213. # resume_token is not populated unless the iterator is used
  214. items = pages.build_full_result()
  215. boto_objs = self._to_boto_resource(collection, args, items)
  216. resume_token = pages.resume_token
  217. return (resume_token, boto_objs)
  218. def _make_query(self, collection: Any, limit: int | None,
  219. marker: str | None) -> tuple[str, Any, Any]:
  220. """
  221. Decide between server or client pagination,
  222. depending on the availability of a Boto Paginator.
  223. See issue: https://github.com/boto/boto3/issues/1268
  224. """
  225. client = self.boto_conn.meta.client
  226. list_op = self._get_list_operation()
  227. if client.can_paginate(list_op):
  228. log.debug("Supports server side pagination. Server will"
  229. " limit and page results.")
  230. res_token, items = self._get_paginated_results(limit, marker,
  231. collection)
  232. return 'server', res_token, items
  233. else:
  234. log.debug("Does not support server side pagination. Client will"
  235. " limit and page results.")
  236. return 'client', None, collection
  237. def list(self, limit: int | None = None, marker: str | None = None,
  238. collection: Any = None,
  239. **kwargs: Any) -> ResultList[CloudResource]:
  240. """
  241. List a set of resources.
  242. :type collection: ``ResourceCollection``
  243. :param collection: Boto resource collection object corresponding to the
  244. current resource. See http://boto3.readthedocs.io/
  245. en/latest/guide/collections.html
  246. """
  247. limit = limit or self.provider.config.default_result_limit
  248. collection = collection or self.boto_collection.filter(**kwargs)
  249. pag_type, resume_token, boto_objs = self._make_query(collection,
  250. limit,
  251. marker)
  252. # Wrap in CB objects.
  253. results = [self.cb_resource(self.provider, obj) for obj in boto_objs]
  254. if pag_type == 'server':
  255. log.debug("Using server pagination.")
  256. return ServerPagedResultList(is_truncated=True if resume_token
  257. else False,
  258. marker=resume_token if resume_token
  259. else None,
  260. supports_total=False,
  261. data=results)
  262. else:
  263. log.debug("Did not received a resume token, will page in client"
  264. " if necessary.")
  265. return ClientPagedResultList(self.provider, results,
  266. limit=limit, marker=marker)
  267. def find(self, filters: dict[str, Any], limit: int | None = None,
  268. marker: str | None = None,
  269. **kwargs: Any) -> ResultList[CloudResource]:
  270. """
  271. Return a list of resources by filter.
  272. :type filters: A ``dict`` of filters
  273. :param filters: A list of filters, where the dict key is the filter
  274. name and the value is the value to filter by.
  275. """
  276. boto_filters = [{'Name': key, 'Values': [value]}
  277. for key, value in filters.items()]
  278. collection = self.boto_collection
  279. collection = collection.filter(Filters=boto_filters)
  280. if kwargs:
  281. collection = collection.filter(**kwargs)
  282. return self.list(limit=limit, marker=marker, collection=collection)
  283. def create(self, boto_method: str, **kwargs: Any) -> Any:
  284. """
  285. Creates a resource
  286. :type boto_method: ``str``
  287. :param boto_method: AWS Service method to invoke
  288. :type kwargs: ``dict``
  289. :param kwargs: Arguments to be passed as-is to the service method
  290. """
  291. log.debug("Creating a resource by invoking %s on these arguments: %s",
  292. boto_method, kwargs)
  293. trimmed_args = trim_empty_params(kwargs)
  294. result = getattr(self.boto_conn, boto_method)(**trimmed_args)
  295. if isinstance(result, list):
  296. return [self.cb_resource(self.provider, obj)
  297. for obj in result if obj]
  298. else:
  299. return self.cb_resource(self.provider, result) if result else None
  300. def delete(self, resource_id: str) -> None:
  301. """
  302. Deletes a resource by id
  303. :type resource_id: ``str``
  304. :param resource_id: ID of the resource
  305. """
  306. log.info("Delete the resource with the id %s", resource_id)
  307. res = self.get(resource_id)
  308. if res:
  309. res.delete()
  310. class BotoEC2Service(BotoGenericService):
  311. """
  312. Boto EC2 service implementation
  313. """
  314. def __init__(self, provider: AWSCloudProvider,
  315. cb_resource: Any,
  316. boto_collection_name: str) -> None:
  317. """
  318. :type provider: :class:`AWSCloudProvider`
  319. :param provider: CloudBridge AWS provider to use
  320. :type cb_resource: :class:`CloudResource`
  321. :param cb_resource: CloudBridge Resource class to wrap results in
  322. :type boto_collection_name: ``str``
  323. :param boto_collection_name: Boto collection name that corresponds
  324. to the CloudBridge resource (e.g. key_pair)
  325. """
  326. super(BotoEC2Service, self).__init__(
  327. provider, cb_resource, provider.ec2_conn,
  328. boto_collection_name)
  329. class BotoS3Service(BotoGenericService):
  330. """
  331. Boto S3 service implementation.
  332. """
  333. def __init__(self, provider: AWSCloudProvider,
  334. cb_resource: Any,
  335. boto_collection_name: str) -> None:
  336. """
  337. :type provider: :class:`AWSCloudProvider`
  338. :param provider: CloudBridge AWS provider to use
  339. :type cb_resource: :class:`CloudResource`
  340. :param cb_resource: CloudBridge Resource class to wrap results in
  341. :type boto_collection_name: ``str``
  342. :param boto_collection_name: Boto collection name that corresponds
  343. to the CloudBridge resource (e.g. key_pair)
  344. """
  345. super(BotoS3Service, self).__init__(
  346. provider, cb_resource, provider.s3_conn,
  347. boto_collection_name)