Skip to content

Commit ad49b26

Browse files
committed
Merge branch 'morosi-asset' into 'master'
Use parameters for the S3 keys of assets See merge request it/e3-aws!77
2 parents 6a27e66 + 9ccfa44 commit ad49b26

24 files changed

+571
-324
lines changed

src/e3/aws/cfn/main.py

Lines changed: 12 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,6 @@ def __init__(
156156
self.assume_role = assume_role
157157
self.aws_env: Session | AWSEnv | None = None
158158
self.deploy_branch = deploy_branch
159-
# A temporary dir will be assigned when generating assets
160-
self.gen_assets_dir: str | None = None
161159

162160
self.timestamp = datetime.utcnow().strftime("%Y-%m-%d/%H:%M:%S.%f")
163161

@@ -264,7 +262,7 @@ def _upload_dir(
264262
upload_bucket.push(key=s3_object_key, content=fd, exist_ok=True)
265263

266264
def _upload_stack(self, stack: Stack) -> None:
267-
"""Upload stack assets, data, and template to S3.
265+
"""Upload stack data and template to S3.
268266
269267
:param stack: the stack to upload
270268
"""
@@ -274,23 +272,7 @@ def _upload_stack(self, stack: Stack) -> None:
274272

275273
assert self.args is not None
276274

277-
if self.aws_env:
278-
s3 = self.aws_env.client("s3")
279-
else:
280-
s3 = None
281-
logging.warning(
282-
"no aws session, won't be able to check if assets exist in the bucket"
283-
)
284-
285-
# Synchronize assets to the bucket before creating the stack
286-
if self.gen_assets_dir is not None and self.s3_assets_key is not None:
287-
self._upload_dir(
288-
root_dir=self.gen_assets_dir,
289-
s3_bucket=self.s3_bucket,
290-
s3_key=self.s3_assets_key,
291-
s3_client=s3,
292-
check_exists=True,
293-
)
275+
s3 = self.aws_env.client("s3") if self.aws_env else None
294276

295277
with tempfile.TemporaryDirectory() as tempd:
296278
# Push data associated with CFNMain and then all data
@@ -510,31 +492,19 @@ def execute(
510492
return 1
511493

512494
return_val = 0
495+
stacks = self.create_stack()
496+
497+
if isinstance(stacks, list):
498+
for stack in stacks:
499+
return_val = self.execute_for_stack(stack, aws_env=aws_env)
500+
# Stop at first failure
501+
if return_val:
502+
return return_val
503+
else:
504+
return_val = self.execute_for_stack(stacks, aws_env=aws_env)
513505

514-
# Create a temporary assets dir here as assets need to be generated at the
515-
# time of create_stack
516-
with tempfile.TemporaryDirectory() as temp_assets_dir:
517-
self.gen_assets_dir = temp_assets_dir
518-
self.pre_create_stack()
519-
stacks = self.create_stack()
520-
self.post_create_stack()
521-
522-
if isinstance(stacks, list):
523-
for stack in stacks:
524-
return_val = self.execute_for_stack(stack, aws_env=aws_env)
525-
# Stop at first failure
526-
if return_val:
527-
return return_val
528-
else:
529-
return_val = self.execute_for_stack(stacks, aws_env=aws_env)
530-
531-
self.gen_assets_dir = None
532506
return return_val
533507

534-
def pre_create_stack(self) -> None:
535-
"""Before create_stack."""
536-
pass
537-
538508
@abc.abstractmethod
539509
def create_stack(self) -> Stack | list[Stack]:
540510
"""Create a stack.
@@ -543,10 +513,6 @@ def create_stack(self) -> Stack | list[Stack]:
543513
"""
544514
pass
545515

546-
def post_create_stack(self) -> None:
547-
"""After create_stack."""
548-
pass
549-
550516
@property
551517
def stack_policy_body(self) -> str | None:
552518
"""Stack Policy that can be set by calling the command ``protect``.

src/e3/aws/troposphere/__init__.py

Lines changed: 158 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
from __future__ import annotations
22
from abc import ABC, abstractmethod
3+
from tempfile import TemporaryDirectory
34
from itertools import chain
4-
from troposphere import AWSObject, Output, Template
5+
from troposphere import AWSObject, Output, Template, Parameter
6+
from collections import deque
7+
import logging
58

69
from e3.aws import cfn, name_to_id, Session
710
from e3.aws.cfn.main import CFNMain
811
from e3.aws.troposphere.iam.policy_document import PolicyDocument
912
from typing import TYPE_CHECKING
1013

14+
1115
if TYPE_CHECKING: # all: no cover
12-
from typing import Union
16+
from typing import Union, Any
1317
from collections.abc import Iterable
1418
from troposphere import And, Condition, Equals, If, Not, Or
1519

@@ -40,20 +44,78 @@ def cfn_policy_document(self, stack: Stack) -> PolicyDocument:
4044
"""
4145
return PolicyDocument([])
4246

43-
def create_assets_dir(self, root_dir: str) -> None: # noqa: B027
44-
"""Put assets in root_dir before export to S3 bucket referenced by the stack.
47+
def create_data_dir(self, root_dir: str) -> None: # noqa: B027
48+
"""Put data in root_dir before export to S3 bucket referenced by the stack.
4549
46-
:param root_dir: local directory in which assets should be stored. Assets will
50+
:param root_dir: local directory in which data should be stored. Data will
4751
be then uploaded to an S3 bucket accessible from the template. The
4852
target location is the one received by resources method. Note that
4953
the same root_dir is shared by all resources in your stack.
5054
"""
5155
pass
5256

53-
def create_data_dir(self, root_dir: str) -> None: # noqa: B027
54-
"""Put data in root_dir before export to S3 bucket referenced by the stack.
5557

56-
:param root_dir: local directory in which data should be stored. Data will
58+
class Asset(Construct):
59+
"""Generic asset.
60+
61+
Assets are local files or directories that are uploaded to S3 and that can be
62+
referenced by other resources. For example, an asset might be a directory
63+
that contains the handler code for an AWS Lambda function. Assets can represent
64+
any artifact that the app needs to operate.
65+
66+
Each asset insert an additional parameter to the CloudFormation template, that
67+
can be used by other resources, with the intrinsic function Fn::Sub, to
68+
reference the S3 key of the asset.
69+
"""
70+
71+
def __init__(self, name: str) -> None:
72+
"""Initialize Asset.
73+
74+
:param name: the logical name for CloudFormation
75+
"""
76+
self.name = name
77+
self.s3_key_parameter_name = f"{self.name}S3Key"
78+
79+
@property
80+
@abstractmethod
81+
def s3_key(self) -> str | None:
82+
"""Return the S3 key of this asset.
83+
84+
It may return None if the S3 key is not yet known.
85+
"""
86+
...
87+
88+
@property
89+
def s3_key_parameter(self) -> str:
90+
"""Return the parameter that stores the S3 key.
91+
92+
The Default value is omitted if the S3 key is not yet known.
93+
"""
94+
params: dict[str, Any] = {}
95+
s3_key = self.s3_key
96+
if s3_key is not None:
97+
params["Default"] = s3_key
98+
99+
return Parameter(
100+
self.s3_key_parameter_name,
101+
Type="String",
102+
Description=f"S3 key of asset {self.name}",
103+
**params,
104+
)
105+
106+
def resources(self, stack: Stack) -> list[AWSObject | Construct]:
107+
"""Return no resources."""
108+
# Add the parameter during template creation even if the S3 key may not
109+
# be known yet. This is useful if creating a stack from code, so that
110+
# the exported template contains the parameter
111+
stack.add_parameter(self.s3_key_parameter)
112+
return []
113+
114+
@abstractmethod
115+
def create_assets_dir(self, root_dir: str) -> None: # noqa: B027
116+
"""Put assets in root_dir before export to S3 bucket referenced by the stack.
117+
118+
:param root_dir: local directory in which assets should be stored. Assets will
57119
be then uploaded to an S3 bucket accessible from the template. The
58120
target location is the one received by resources method. Note that
59121
the same root_dir is shared by all resources in your stack.
@@ -75,7 +137,6 @@ def __init__(
75137
s3_key: str | None = None,
76138
s3_assets_key: str | None = None,
77139
version: str | None = None,
78-
gen_assets_dir: str | None = None,
79140
) -> None:
80141
"""Initialize Stack attributes.
81142
@@ -89,7 +150,6 @@ def __init__(
89150
:param s3_key: s3 prefix in s3_bucket in which data is stored
90151
:param s3_assets_key: s3 prefix in s3_bucket in which assets are stored
91152
:param version: template format version
92-
:param gen_assets_dir: directory where to generate stack assets to upload to S3
93153
"""
94154
super().__init__(
95155
stack_name,
@@ -99,28 +159,22 @@ def __init__(
99159
s3_key=s3_key,
100160
)
101161
self.constructs: list[Construct | AWSObject] = []
162+
self.assets: dict[str, Asset] = {}
102163

103164
self.deploy_session = deploy_session
104165
self.dry_run = dry_run
105166
self.version = version
106167
self.s3_assets_key = s3_assets_key
107-
self.gen_assets_dir = gen_assets_dir
108168
self.template = Template()
109169

110170
def construct_to_objects(self, construct: Construct | AWSObject) -> list[AWSObject]:
111171
"""Return list of AWS objects resources from a construct.
112172
113-
The function create_assets_dir is called at the same time, on each construct,
114-
as some AWS objects may have properties that depend on assets.
115-
116173
:param construct: construct to list resources from
117174
"""
118175
if isinstance(construct, AWSObject):
119176
return [construct]
120177
else:
121-
if self.gen_assets_dir is not None:
122-
construct.create_assets_dir(self.gen_assets_dir)
123-
124178
return list(
125179
chain.from_iterable(
126180
[
@@ -145,10 +199,23 @@ def add(self, element: AWSObject | Construct | Stack) -> Stack:
145199
# Add the new constructs (non expanded)
146200
self.constructs += constructs
147201

148-
# Update the template
202+
# Update the template with AWSObjects.
203+
# Convert Constructs to AWSObjects | Constructs recursively
149204
resources = []
150-
for construct in constructs:
151-
resources.extend(self.construct_to_objects(construct))
205+
constructs_to_objects = deque(constructs)
206+
while constructs_to_objects:
207+
construct = constructs_to_objects.pop()
208+
if isinstance(construct, AWSObject):
209+
resources.append(construct)
210+
else:
211+
# Special case to keep track of Assets and generate parameters
212+
# for the S3 keys
213+
if isinstance(construct, Asset):
214+
self.add_parameter(construct.s3_key_parameter)
215+
self.assets[construct.name] = construct
216+
217+
constructs_to_objects.extend(construct.resources(stack=self))
218+
152219
self.template.add_resource(resources)
153220

154221
return self
@@ -163,6 +230,20 @@ def extend(self, elements: Iterable[AWSObject | Construct | Stack]) -> Stack:
163230

164231
return self
165232

233+
def add_parameter(self, parameter: Parameter | list[Parameter]) -> None:
234+
"""Add parameters to stack template.
235+
236+
:param parameter: parameter to add to the template
237+
"""
238+
if not isinstance(parameter, list):
239+
parameter = [parameter]
240+
241+
for param in parameter:
242+
if param.title in self.template.parameters:
243+
self.template.parameters[param.title] = param
244+
else:
245+
self.template.add_parameter(param)
246+
166247
def add_output(self, output: Output | list[Output]) -> None:
167248
"""Add outputs to stack template.
168249
@@ -266,14 +347,7 @@ def __init__(
266347
s3_key=self.s3_data_key,
267348
s3_assets_key=self.s3_assets_key,
268349
)
269-
270-
def pre_create_stack(self) -> None:
271-
"""Assign the temporary assets directory to the stack."""
272-
self.stack.gen_assets_dir = self.gen_assets_dir
273-
274-
def post_create_stack(self) -> None:
275-
"""Unassign the temporary assets directory from the stack."""
276-
self.stack.gen_assets_dir = None
350+
self.gen_assets_dir: str | None = None
277351

278352
def add(self, element: AWSObject | Construct | Stack) -> Stack:
279353
"""Add resource to project's stack.
@@ -288,3 +362,59 @@ def extend(self, elements: list[AWSObject | Construct | Stack]) -> Stack:
288362
:param elements: resources to add to the stack.
289363
"""
290364
return self.stack.extend(elements)
365+
366+
def _upload_stack(self, stack: cfn.Stack) -> None:
367+
"""See CFNMain."""
368+
# Nothing to upload if there is no S3 bucket or S3 assets key set
369+
if self.s3_bucket is not None and self.s3_assets_key is not None:
370+
# Upload assets to S3 first
371+
if self.aws_env:
372+
s3 = self.aws_env.client("s3")
373+
else:
374+
s3 = None
375+
logging.warning(
376+
"no aws session, won't be able to check if assets exist "
377+
"in the bucket"
378+
)
379+
380+
assert self.gen_assets_dir is not None
381+
self._upload_dir(
382+
root_dir=self.gen_assets_dir,
383+
s3_bucket=self.s3_bucket,
384+
s3_key=self.s3_assets_key,
385+
s3_client=s3,
386+
check_exists=True,
387+
)
388+
389+
# Upload the rest
390+
super()._upload_stack(stack)
391+
392+
def execute_for_stack(
393+
self, stack: cfn.Stack, aws_env: Session | None = None
394+
) -> int:
395+
"""See CFNMain."""
396+
# Set the directory where to generate assets
397+
with TemporaryDirectory() as tmpd:
398+
self.gen_assets_dir = tmpd
399+
400+
# Create the assets directory
401+
assert self.args is not None
402+
if isinstance(stack, Stack) and self.args.command in [
403+
"show",
404+
"push",
405+
"update",
406+
]:
407+
for asset in stack.assets.values():
408+
# Populate the assets directory
409+
asset.create_assets_dir(root_dir=tmpd)
410+
411+
# Add a parameter to the stack with the known S3 key of the
412+
# asset
413+
stack.add_parameter(asset.s3_key_parameter)
414+
415+
try:
416+
# Execute the command for the stack
417+
return super().execute_for_stack(stack=stack, aws_env=aws_env)
418+
finally:
419+
# Unset the assets directory
420+
self.gen_assets_dir = None

src/e3/aws/troposphere/asset.py

Lines changed: 0 additions & 24 deletions
This file was deleted.

0 commit comments

Comments
 (0)