11from __future__ import annotations
22from abc import ABC , abstractmethod
3+ from tempfile import TemporaryDirectory
34from 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
69from e3 .aws import cfn , name_to_id , Session
710from e3 .aws .cfn .main import CFNMain
811from e3 .aws .troposphere .iam .policy_document import PolicyDocument
912from typing import TYPE_CHECKING
1013
14+
1115if 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
0 commit comments