5
5
import os
6
6
import sys
7
7
from typing import TYPE_CHECKING
8
+ from zipfile import ZipFile
9
+ from hashlib import sha256
8
10
11
+ from e3 .fingerprint import Fingerprint
9
12
from e3 .archive import create_archive
10
- from e3 .fs import sync_tree , rm
13
+ from e3 .fs import sync_tree , rm , mv
11
14
from e3 .os .process import Run
12
15
from troposphere import awslambda , logs , GetAtt , Ref , Sub
13
16
16
19
from e3 .aws .troposphere .iam .policy_document import PolicyDocument
17
20
from e3 .aws .troposphere .iam .policy_statement import PolicyStatement
18
21
from e3 .aws .troposphere .iam .role import Role
22
+ from e3 .aws .troposphere .asset import Asset
19
23
from e3 .aws .util .ecr import build_and_push_image
20
24
21
25
if TYPE_CHECKING :
22
- from typing import Any
26
+ from typing import Any , Callable
23
27
from troposphere import AWSObject
24
28
from e3 .aws .troposphere import Stack
25
29
26
30
logger = logging .getLogger ("e3.aws.troposphere.awslambda" )
27
31
28
32
33
+ def package_pyfunction_code (
34
+ filename : str ,
35
+ / ,
36
+ package_dir : str ,
37
+ root_dir : str ,
38
+ populate_package_dir : Callable [[str ], None ],
39
+ runtime : str | None = None ,
40
+ requirement_file : str | None = None ,
41
+ ) -> None :
42
+ """Package user code with dependencies.
43
+
44
+ :param filename: name of the archive
45
+ :param package_dir: temporary packaging directory
46
+ :param root_dir: destination directory for the archive
47
+ :param populate_package_dir: callback to populate the package directory with
48
+ extra code
49
+ :param runtime: the Python runtime
50
+ :param requirement_file: the list of Python dependencies
51
+ """
52
+ # Install the requirements
53
+ if requirement_file is not None :
54
+ assert runtime is not None
55
+ runtime_config = PyFunction .RUNTIME_CONFIGS [runtime ]
56
+ p = Run (
57
+ [
58
+ sys .executable ,
59
+ "-m" ,
60
+ "pip" ,
61
+ "install" ,
62
+ f"--python-version={ runtime .lstrip ('python' )} " ,
63
+ * (f"--platform={ platform } " for platform in runtime_config ["platforms" ]),
64
+ f"--implementation={ runtime_config ['implementation' ]} " ,
65
+ "--only-binary=:all:" ,
66
+ f"--target={ package_dir } " ,
67
+ "-r" ,
68
+ requirement_file ,
69
+ ],
70
+ output = None ,
71
+ )
72
+ assert p .status == 0
73
+
74
+ # Populate the package directory with extra code
75
+ if populate_package_dir is not None :
76
+ populate_package_dir (package_dir )
77
+
78
+ # Create an archive
79
+ create_archive (
80
+ filename ,
81
+ from_dir = package_dir ,
82
+ dest = root_dir ,
83
+ no_root_dir = True ,
84
+ )
85
+
86
+ # Remove the temporary directory
87
+ rm (package_dir , recursive = True )
88
+
89
+
90
+ class ChecksumNotComputedError (Exception ):
91
+ """Error raised when PyFunctionAsset.checksum was not computed."""
92
+
93
+
94
+ class PyFunctionAsset (Asset ):
95
+ """PyFunction code packaged with dependencies."""
96
+
97
+ def __init__ (
98
+ self ,
99
+ name : str ,
100
+ * ,
101
+ code_dir : str ,
102
+ runtime : str ,
103
+ requirement_file : str | None = None ,
104
+ ) -> None :
105
+ """Initialize PyFunctionAsset.
106
+
107
+ :param name: name of the archive
108
+ :param code_dir: directory that contains the Python code
109
+ :param runtime: the Python runtime
110
+ :param requirement_file: the list of Python dependencies
111
+ """
112
+ self .name = name
113
+ self .code_dir = code_dir
114
+ self .runtime = runtime
115
+ self .requirement_file = requirement_file
116
+ self .checksum : str | None = None
117
+
118
+ @property
119
+ def s3_key (self ) -> str :
120
+ """Return a unique S3 key with the checksum of the package."""
121
+ if self .checksum is None :
122
+ raise ChecksumNotComputedError (
123
+ "no checksum, was the asset added to the stack?"
124
+ )
125
+
126
+ return f"{ self .name } /{ self .name } _{ self .checksum } .zip"
127
+
128
+ def populate_package_dir (self , package_dir : str ) -> None :
129
+ """Copy user code into package directory.
130
+
131
+ :param package_dir: directory in which the package content is put
132
+ """
133
+ # Add lambda code
134
+ sync_tree (self .code_dir , package_dir , delete = False )
135
+
136
+ def compute_checksum (self , archive_path : str ) -> str :
137
+ """Compute the checksum of the archive.
138
+
139
+ All .pyc files are excluded as they are not reproducible.
140
+
141
+ :param archive_path: path of the archive
142
+ :return: the checksum
143
+ """
144
+ fingerprint = Fingerprint ()
145
+ with ZipFile (archive_path ) as zip :
146
+ for zip_info in zip .infolist ():
147
+ if zip_info .is_dir ():
148
+ fingerprint .add (zip_info .filename , "" )
149
+ elif not zip_info .filename .endswith (".pyc" ):
150
+ with zip .open (zip_info ) as f :
151
+ hash = sha256 ()
152
+ hash .update (f .read ())
153
+ fingerprint .add (zip_info .filename , hash .hexdigest ())
154
+
155
+ return fingerprint .checksum ()
156
+
157
+ def create_assets_dir (self , root_dir : str ) -> None :
158
+ """Populate the assets dir.
159
+
160
+ :param root_dir: directory where to put assets
161
+ """
162
+ # Stop if code already packaged and checksum already computed.
163
+ # This allows to possibly add the same asset multiple times to the stack
164
+ # without risking to package it each time
165
+ if self .checksum is not None :
166
+ return
167
+
168
+ # Directory where the archive is generated
169
+ archive_dir = os .path .join (root_dir , self .name )
170
+
171
+ # Create a temporary packaging directory
172
+ package_dir = os .path .join (archive_dir , "package" )
173
+
174
+ # Package the code with dependencies
175
+ archive_name = f"{ self .name } .zip"
176
+ package_pyfunction_code (
177
+ archive_name ,
178
+ package_dir = package_dir ,
179
+ root_dir = archive_dir ,
180
+ populate_package_dir = self .populate_package_dir ,
181
+ runtime = self .runtime ,
182
+ requirement_file = self .requirement_file ,
183
+ )
184
+
185
+ archive_path = os .path .abspath (os .path .join (archive_dir , archive_name ))
186
+ self .checksum = self .compute_checksum (archive_path )
187
+
188
+ # Rename the archive with the checksum
189
+ checksum_archive_name = f"{ self .name } _{ self .checksum } .zip"
190
+ checksum_archive_path = os .path .join (archive_dir , checksum_archive_name )
191
+ mv (archive_path , checksum_archive_path )
192
+
193
+
29
194
class Function (Construct ):
30
195
"""A lambda function."""
31
196
@@ -392,9 +557,10 @@ def __init__(
392
557
name : str ,
393
558
description : str ,
394
559
role : str | GetAtt | Role ,
395
- code_dir : str ,
396
560
handler : str ,
397
561
runtime : str ,
562
+ code_asset : Asset | None = None ,
563
+ code_dir : str | None = None ,
398
564
requirement_file : str | None = None ,
399
565
code_version : int | None = None ,
400
566
timeout : int = 3 ,
@@ -412,9 +578,10 @@ def __init__(
412
578
:param name: function name
413
579
:param description: a description of the function
414
580
:param role: role to be asssumed during lambda execution
415
- :param code_dir: directory containing the python code
416
581
:param handler: name of the function to be invoked on lambda execution
417
582
:param runtime: lambda runtime. It must be a Python runtime.
583
+ :param code_asset: asset containing the python code
584
+ :param code_dir: directory containing the python code
418
585
:param requirement_file: requirement file for the application code.
419
586
Required packages are automatically fetched (works only from linux)
420
587
and packaged along with the lambda code
@@ -462,65 +629,31 @@ def __init__(
462
629
self .code_dir = code_dir
463
630
self .requirement_file = requirement_file
464
631
632
+ if code_asset is not None :
633
+ self .code_asset = code_asset
634
+ else :
635
+ assert (
636
+ code_dir is not None
637
+ ), "code_dir must be provided when code_asset is None"
638
+
639
+ self .code_asset = PyFunctionAsset (
640
+ name = name_to_id (f"{ name } Sources" ),
641
+ code_dir = code_dir ,
642
+ runtime = runtime ,
643
+ requirement_file = requirement_file ,
644
+ )
645
+
465
646
def resources (self , stack : Stack ) -> list [AWSObject ]:
466
647
"""Compute AWS resources for the construct."""
467
648
assert isinstance (stack .s3_bucket , str )
468
649
return self .lambda_resources (
469
650
code_bucket = stack .s3_bucket ,
470
- code_key = f"{ stack .s3_key } { self .name } _lambda.zip" ,
471
- )
472
-
473
- def populate_package_dir (self , package_dir : str ) -> None :
474
- """Copy user code into lambda package directory.
475
-
476
- :param package_dir: directory in which the package content is put
477
- """
478
- # Add lambda code
479
- sync_tree (self .code_dir , package_dir , delete = False )
480
-
481
- def create_data_dir (self , root_dir : str ) -> None :
482
- """Create data to be pushed to bucket used by cloudformation for resources."""
483
- # Create directory specific to that lambda
484
- package_dir = os .path .join (root_dir , name_to_id (self .name ), "package" )
485
-
486
- # Install the requirements
487
- if self .requirement_file is not None :
488
- assert self .runtime is not None
489
- runtime_config = PyFunction .RUNTIME_CONFIGS [self .runtime ]
490
- p = Run (
491
- [
492
- sys .executable ,
493
- "-m" ,
494
- "pip" ,
495
- "install" ,
496
- f"--python-version={ self .runtime .lstrip ('python' )} " ,
497
- * (
498
- f"--platform={ platform } "
499
- for platform in runtime_config ["platforms" ]
500
- ),
501
- f"--implementation={ runtime_config ['implementation' ]} " ,
502
- "--only-binary=:all:" ,
503
- f"--target={ package_dir } " ,
504
- "-r" ,
505
- self .requirement_file ,
506
- ],
507
- output = None ,
508
- )
509
- assert p .status == 0
510
-
511
- # Copy user code
512
- self .populate_package_dir (package_dir = package_dir )
513
-
514
- # Create an archive
515
- create_archive (
516
- f"{ self .name } _lambda.zip" ,
517
- from_dir = package_dir ,
518
- dest = root_dir ,
519
- no_root_dir = True ,
651
+ code_key = f"{ stack .s3_assets_key } { self .code_asset .s3_key } " ,
520
652
)
521
653
522
- # Remove temporary directory
523
- rm (package_dir , recursive = True )
654
+ def create_assets_dir (self , root_dir : str ) -> None :
655
+ """Create assets to be pushed to bucket used by cloudformation for resources."""
656
+ self .code_asset .create_assets_dir (root_dir = root_dir )
524
657
525
658
526
659
class Py38Function (PyFunction ):
0 commit comments