6
6
from abc import ABC , abstractmethod
7
7
from enum import Enum
8
8
from pathlib import Path
9
- from typing import Dict , List , Tuple , Type
9
+ from typing import Dict , List , Optional , Tuple , Type
10
+ from uuid import uuid4
10
11
11
12
import boto3
12
13
import yaml
13
14
from aws_cdk import App , AssetStaging , BundlingOptions , CfnOutput , DockerImage , RemovalPolicy , Stack , aws_logs
14
15
from aws_cdk .aws_lambda import Code , Function , LayerVersion , Runtime , Tracing
16
+ from mypy_boto3_cloudformation import CloudFormationClient
17
+
18
+ from tests .e2e .utils .asset import Assets
15
19
16
20
PYTHON_RUNTIME_VERSION = f"V{ '' .join (map (str , sys .version_info [:2 ]))} "
17
21
@@ -24,11 +28,11 @@ class PythonVersion(Enum):
24
28
25
29
class BaseInfrastructureStack (ABC ):
26
30
@abstractmethod
27
- def synthesize () -> Tuple [dict , str ]:
31
+ def synthesize (self ) -> Tuple [dict , str ]:
28
32
...
29
33
30
34
@abstractmethod
31
- def __call__ () -> Tuple [dict , str ]:
35
+ def __call__ (self ) -> Tuple [dict , str ]:
32
36
...
33
37
34
38
@@ -210,3 +214,174 @@ def _find_assets(self, asset_template: str, account_id: str, region: str):
210
214
211
215
def _transform_output (self , outputs : dict ):
212
216
return {output ["OutputKey" ]: output ["OutputValue" ] for output in outputs if output ["OutputKey" ]}
217
+
218
+
219
+ class BaseInfrastructureV2 (ABC ):
220
+ def __init__ (self , feature_name : str , handlers_dir : Path ) -> None :
221
+ self .stack_name = f"test-{ feature_name } -{ uuid4 ()} "
222
+ self .handlers_dir = handlers_dir
223
+ self .app = App ()
224
+ self .stack = Stack (self .app , self .stack_name )
225
+ self .session = boto3 .Session ()
226
+ self .cf_client : CloudFormationClient = self .session .client ("cloudformation" )
227
+ # NOTE: CDK stack account and region are tokens, we need to resolve earlier
228
+ self .account_id = self .session .client ("sts" ).get_caller_identity ()["Account" ]
229
+ self .region = self .session .region_name
230
+ self .stack_outputs : Dict [str , str ] = {}
231
+
232
+ def create_lambda_functions (self , function_props : Optional [Dict ] = None ):
233
+ """Create Lambda functions available under handlers_dir
234
+
235
+ It creates CloudFormation Outputs for every function found in PascalCase. For example,
236
+ {handlers_dir}/basic_handler.py creates `BasicHandler` and `BasicHandlerArn` outputs.
237
+
238
+
239
+ Parameters
240
+ ----------
241
+ function_props: Optional[Dict]
242
+ CDK Lambda FunctionProps as dictionary to override defaults
243
+
244
+ Examples
245
+ -------
246
+
247
+ Creating Lambda functions available in the handlers directory
248
+
249
+ ```python
250
+ self.create_lambda_functions()
251
+ ```
252
+
253
+ Creating Lambda functions and override runtime to Python 3.7
254
+
255
+ ```python
256
+ from aws_cdk.aws_lambda import Runtime
257
+
258
+ self.create_lambda_functions(function_props={"runtime": Runtime.PYTHON_3_7)
259
+ ```
260
+ """
261
+ handlers = list (self .handlers_dir .rglob ("*.py" ))
262
+ source = Code .from_asset (f"{ self .handlers_dir } " )
263
+ props_override = function_props or {}
264
+
265
+ for fn in handlers :
266
+ fn_name = fn .stem
267
+ function_settings = {
268
+ "id" : f"{ fn_name } -lambda" ,
269
+ "code" : source ,
270
+ "handler" : f"{ fn_name } .lambda_handler" ,
271
+ "tracing" : Tracing .ACTIVE ,
272
+ "runtime" : Runtime .PYTHON_3_9 ,
273
+ "layers" : [
274
+ LayerVersion .from_layer_version_arn (
275
+ self .stack ,
276
+ f"{ fn_name } -lambda-powertools" ,
277
+ f"arn:aws:lambda:{ self .region } :017000801446:layer:AWSLambdaPowertoolsPython:29" ,
278
+ )
279
+ ],
280
+ ** props_override ,
281
+ }
282
+
283
+ function_python = Function (self .stack , ** function_settings )
284
+
285
+ aws_logs .LogGroup (
286
+ self .stack ,
287
+ id = f"{ fn_name } -lg" ,
288
+ log_group_name = f"/aws/lambda/{ function_python .function_name } " ,
289
+ retention = aws_logs .RetentionDays .ONE_DAY ,
290
+ removal_policy = RemovalPolicy .DESTROY ,
291
+ )
292
+
293
+ # CFN Outputs only support hyphen
294
+ fn_name_pascal_case = fn_name .title ().replace ("_" , "" ) # basic_handler -> BasicHandler
295
+ self ._add_resource_output (
296
+ name = fn_name_pascal_case , value = function_python .function_name , arn = function_python .function_arn
297
+ )
298
+
299
+ def deploy (self ) -> Dict [str , str ]:
300
+ """Creates CloudFormation Stack and return stack outputs as dict
301
+
302
+ Returns
303
+ -------
304
+ Dict[str, str]
305
+ CloudFormation Stack Outputs with output key and value
306
+ """
307
+ template , asset_manifest_file = self ._synthesize ()
308
+ assets = Assets (cfn_template = asset_manifest_file , account_id = self .account_id , region = self .region )
309
+ assets .upload ()
310
+ return self ._deploy_stack (self .stack_name , template )
311
+
312
+ def delete (self ):
313
+ self .cf_client .delete_stack (StackName = self .stack_name )
314
+
315
+ def get_stack_outputs (self ) -> Dict [str , str ]:
316
+ return self .stack_outputs
317
+
318
+ @abstractmethod
319
+ def create_resources (self ):
320
+ """Create any necessary CDK resources. It'll be called before deploy
321
+
322
+ Examples
323
+ -------
324
+
325
+ Creating a S3 bucket and export name and ARN
326
+
327
+ ```python
328
+ def created_resources(self):
329
+ s3 = s3.Bucket(self.stack, "MyBucket")
330
+
331
+ # This will create MyBucket and MyBucketArn CloudFormation Output
332
+ self._add_resource_output(name="MyBucket", value=s3.bucket_name, arn_value=bucket.bucket_arn)
333
+ ```
334
+
335
+ Creating Lambda functions available in the handlers directory
336
+
337
+ ```python
338
+ def created_resources(self):
339
+ self.create_lambda_functions()
340
+ ```
341
+ """
342
+ ...
343
+
344
+ def _synthesize (self ) -> Tuple [Dict , Path ]:
345
+ self .create_resources ()
346
+ cloud_assembly = self .app .synth ()
347
+ cf_template : Dict = cloud_assembly .get_stack_by_name (self .stack_name ).template
348
+ cloud_assembly_assets_manifest_path : str = (
349
+ cloud_assembly .get_stack_by_name (self .stack_name ).dependencies [0 ].file # type: ignore[attr-defined]
350
+ )
351
+ return cf_template , Path (cloud_assembly_assets_manifest_path )
352
+
353
+ def _deploy_stack (self , stack_name : str , template : Dict ) -> Dict [str , str ]:
354
+ self .cf_client .create_stack (
355
+ StackName = stack_name ,
356
+ TemplateBody = yaml .dump (template ),
357
+ TimeoutInMinutes = 10 ,
358
+ OnFailure = "ROLLBACK" ,
359
+ Capabilities = ["CAPABILITY_IAM" ],
360
+ )
361
+ waiter = self .cf_client .get_waiter ("stack_create_complete" )
362
+ waiter .wait (StackName = stack_name , WaiterConfig = {"Delay" : 10 , "MaxAttempts" : 50 })
363
+
364
+ stack_details = self .cf_client .describe_stacks (StackName = stack_name )
365
+ stack_outputs = stack_details ["Stacks" ][0 ]["Outputs" ]
366
+ self .stack_outputs = {
367
+ output ["OutputKey" ]: output ["OutputValue" ] for output in stack_outputs if output ["OutputKey" ]
368
+ }
369
+
370
+ return self .stack_outputs
371
+
372
+ def _add_resource_output (self , name : str , value : str , arn : str ):
373
+ """Add both resource value and ARN as Outputs to facilitate tests.
374
+
375
+ This will create two outputs: {Name} and {Name}Arn
376
+
377
+ Parameters
378
+ ----------
379
+ name : str
380
+ CloudFormation Output Key
381
+ value : str
382
+ CloudFormation Output Value
383
+ arn : str
384
+ CloudFormation Output Value for ARN
385
+ """
386
+ CfnOutput (self .stack , f"{ name } " , value = value )
387
+ CfnOutput (self .stack , f"{ name } Arn" , value = arn )
0 commit comments