Skip to content

Commit c5f8e4e

Browse files
committed
feat: create BaseInfrastructureV2 to demonstrate before/after
Signed-off-by: heitorlessa <lessa@amazon.co.uk>
1 parent 074ff4f commit c5f8e4e

File tree

4 files changed

+187
-127
lines changed

4 files changed

+187
-127
lines changed

tests/e2e/metrics/infrastructure.py

Lines changed: 6 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,11 @@
11
from pathlib import Path
2-
from typing import TYPE_CHECKING, Dict, Tuple
3-
from uuid import uuid4
42

5-
import boto3
6-
import yaml
7-
from aws_cdk import App, CfnOutput, RemovalPolicy, Stack, aws_logs
8-
from aws_cdk.aws_lambda import Code, Function, LayerVersion, Runtime, Tracing
3+
from tests.e2e.utils.infrastructure import BaseInfrastructureV2
94

10-
from tests.e2e.utils.asset import Assets
115

12-
if TYPE_CHECKING:
13-
from mypy_boto3_cloudformation import CloudFormationClient
6+
class MetricsStack(BaseInfrastructureV2):
7+
def __init__(self, handlers_dir: Path, feature_name: str = "metrics") -> None:
8+
super().__init__(feature_name, handlers_dir)
149

15-
16-
class MetricsStack:
17-
def __init__(self, handlers_dir: Path) -> None:
18-
self.stack_name = f"test-metrics-{uuid4()}"
19-
self.handlers_dir = handlers_dir
20-
self.app = App()
21-
self.stack = Stack(self.app, self.stack_name)
22-
self.session = boto3.Session()
23-
self.cf_client: "CloudFormationClient" = self.session.client("cloudformation")
24-
# NOTE: CDK stack account and region are tokens, we need to resolve earlier
25-
self.account_id = self.session.client("sts").get_caller_identity()["Account"]
26-
self.region = self.session.region_name
27-
self.stack_outputs: Dict[str, str] = {}
28-
29-
def create_functions(self):
30-
handlers = list(self.handlers_dir.rglob("*.py"))
31-
source = Code.from_asset(f"{self.handlers_dir}")
32-
for fn in handlers:
33-
fn_name = fn.stem
34-
function_python = Function(
35-
self.stack,
36-
id=f"{fn_name}-lambda",
37-
runtime=Runtime.PYTHON_3_8,
38-
code=source,
39-
handler=f"{fn_name}.lambda_handler",
40-
tracing=Tracing.ACTIVE,
41-
layers=[
42-
LayerVersion.from_layer_version_arn(
43-
self.stack,
44-
f"{fn_name}-lambda-powertools",
45-
f"arn:aws:lambda:{self.region}:017000801446:layer:AWSLambdaPowertoolsPython:29",
46-
)
47-
],
48-
)
49-
50-
aws_logs.LogGroup(
51-
self.stack,
52-
id=f"{fn_name}-lg",
53-
log_group_name=f"/aws/lambda/{function_python.function_name}",
54-
retention=aws_logs.RetentionDays.ONE_DAY,
55-
removal_policy=RemovalPolicy.DESTROY,
56-
)
57-
58-
# CFN Outputs only support hyphen
59-
fn_name_camel_case = fn_name.title().replace("_", "") # basic_handler -> BasicHandler
60-
self._add_resource_output(
61-
name=fn_name_camel_case, value=function_python.function_name, arn_value=function_python.function_arn
62-
)
63-
64-
def deploy(self) -> Dict[str, str]:
65-
"""Creates CloudFormation Stack and return stack outputs as dict
66-
67-
Returns
68-
-------
69-
Dict[str, str]
70-
CloudFormation Stack Outputs with output key and value
71-
"""
72-
template, asset_manifest_file = self._synthesize()
73-
assets = Assets(cfn_template=asset_manifest_file, account_id=self.account_id, region=self.region)
74-
assets.upload()
75-
return self._deploy_stack(self.stack_name, template)
76-
77-
def delete(self):
78-
self.cf_client.delete_stack(StackName=self.stack_name)
79-
80-
def get_stack_outputs(self) -> Dict[str, str]:
81-
return self.stack_outputs
82-
83-
def _synthesize(self) -> Tuple[Dict, Path]:
84-
self.create_functions()
85-
cloud_assembly = self.app.synth()
86-
cf_template: Dict = cloud_assembly.get_stack_by_name(self.stack_name).template
87-
cloud_assembly_assets_manifest_path: str = (
88-
cloud_assembly.get_stack_by_name(self.stack_name).dependencies[0].file
89-
)
90-
return cf_template, Path(cloud_assembly_assets_manifest_path)
91-
92-
def _deploy_stack(self, stack_name: str, template: Dict) -> Dict[str, str]:
93-
self.cf_client.create_stack(
94-
StackName=stack_name,
95-
TemplateBody=yaml.dump(template),
96-
TimeoutInMinutes=10,
97-
OnFailure="ROLLBACK",
98-
Capabilities=["CAPABILITY_IAM"],
99-
)
100-
waiter = self.cf_client.get_waiter("stack_create_complete")
101-
waiter.wait(StackName=stack_name, WaiterConfig={"Delay": 10, "MaxAttempts": 50})
102-
103-
stack_details = self.cf_client.describe_stacks(StackName=stack_name)
104-
stack_outputs = stack_details["Stacks"][0]["Outputs"]
105-
self.stack_outputs = {
106-
output["OutputKey"]: output["OutputValue"] for output in stack_outputs if output["OutputKey"]
107-
}
108-
109-
return self.stack_outputs
110-
111-
def _add_resource_output(self, name: str, value: str, arn_value: str):
112-
"""Add both resource value and ARN as Outputs to facilitate tests.
113-
114-
This will create two outputs: {Name} and {Name}Arn
115-
116-
Parameters
117-
----------
118-
name : str
119-
CloudFormation Output Key
120-
value : str
121-
CloudFormation Output Value
122-
arn_value : str
123-
CloudFormation Output Value for ARN
124-
"""
125-
CfnOutput(self.stack, f"{name}", value=value)
126-
CfnOutput(self.stack, f"{name}Arn", value=arn_value)
10+
def create_resources(self):
11+
self.create_lambda_functions()

tests/e2e/metrics/test_metrics.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,6 @@ def test_cold_start_metric(cold_start_fn_arn: str, cold_start_fn: str):
6767
assert metric_data and metric_data[0] == 1.0
6868

6969

70+
# Abstract Infrastructure fixture parallelization work
7071
# helpers: adjust retries and wait to be much smaller
7172
# helpers: make retry config adjustable
72-
# Create separate Infra class so they can live side by side

tests/e2e/utils/helpers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
22
import secrets
3-
from datetime import datetime
3+
from datetime import datetime, timedelta
44
from functools import lru_cache
55
from typing import Dict, List, Optional, Tuple, Union
66

@@ -108,7 +108,7 @@ def get_metrics(
108108
When no metric is found within retry window
109109
"""
110110
cw_client = cw_client or boto3.client("cloudwatch")
111-
end_date = end_date or start_date + datetime.timedelta(minutes=2)
111+
end_date = end_date or start_date + timedelta(minutes=2)
112112

113113
metric_query = build_metric_query_data(
114114
namespace=namespace, metric_name=metric_name, period=period, stat=stat, dimensions=dimensions

tests/e2e/utils/infrastructure.py

Lines changed: 178 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@
66
from abc import ABC, abstractmethod
77
from enum import Enum
88
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
1011

1112
import boto3
1213
import yaml
1314
from aws_cdk import App, AssetStaging, BundlingOptions, CfnOutput, DockerImage, RemovalPolicy, Stack, aws_logs
1415
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
1519

1620
PYTHON_RUNTIME_VERSION = f"V{''.join(map(str, sys.version_info[:2]))}"
1721

@@ -24,11 +28,11 @@ class PythonVersion(Enum):
2428

2529
class BaseInfrastructureStack(ABC):
2630
@abstractmethod
27-
def synthesize() -> Tuple[dict, str]:
31+
def synthesize(self) -> Tuple[dict, str]:
2832
...
2933

3034
@abstractmethod
31-
def __call__() -> Tuple[dict, str]:
35+
def __call__(self) -> Tuple[dict, str]:
3236
...
3337

3438

@@ -210,3 +214,174 @@ def _find_assets(self, asset_template: str, account_id: str, region: str):
210214

211215
def _transform_output(self, outputs: dict):
212216
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

Comments
 (0)