diff --git a/.gitignore b/.gitignore index cbbabe9471f..5b2bb76ec50 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ .idea/**/tasks.xml .idea/dictionaries .idea -.vscode # Sensitive or high-churn files: .idea/**/dataSources/ @@ -376,10 +375,7 @@ GitHub.sublime-settings ### VisualStudioCode ### .vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json +!.vscode/launch.json.template .history ### Theia editor (GitPod) diff --git a/.vscode/launch.json.template b/.vscode/launch.json.template new file mode 100644 index 00000000000..483586ba238 --- /dev/null +++ b/.vscode/launch.json.template @@ -0,0 +1,67 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run: Remote Invoke Sync", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/samcli/__main__.py", + "python": "${command:python.interpreterPath}", + "args": [ + "remote", + "invoke", + "", + "--debug", + "--output", + "json" + ], + "env": { + "PYTHONPATH": "${workspaceFolder}" + }, + "console": "integratedTerminal", + "justMyCode": false + }, + { + "name": "Run: Remote Invoke Streaming Response", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/samcli/__main__.py", + "python": "${command:python.interpreterPath}", + "args": [ + "remote", + "invoke", + "", + "--debug", + "--output", + "json" + ], + "env": { + "PYTHONPATH": "${workspaceFolder}" + }, + "console": "integratedTerminal", + "justMyCode": false + }, + { + "name": "Test: Run current test file", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": ["${file}", "-v"], + "python": "${command:python.interpreterPath}", + "console": "integratedTerminal", + "justMyCode": false + }, + { + "name": "Format and PR", + "type": "node", + "request": "launch", + "runtimeExecutable": "bash", + "runtimeArgs": [ + "-c", + "cd \"${workspaceFolder}\" && make black && make pr" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + } + ] +} diff --git a/DEVELOPMENT_GUIDE.md b/DEVELOPMENT_GUIDE.md index 634ac17ca74..240d2adafd6 100644 --- a/DEVELOPMENT_GUIDE.md +++ b/DEVELOPMENT_GUIDE.md @@ -110,8 +110,18 @@ samdev --version # this will print something like "SAM CLI, version x.xx.x" echo '__version__ = "123.456.789"' >> samcli/__init__.py samdev --version # this will print "SAM CLI, version 123.456.789" ``` +### 3 VS Code Debugger -### 3. (Optional) Install development version of SAM Transformer +For VS Code Launch configuration, run the following command to create your local VS Code launch configuration: + +```bash +cp .vscode/launch.json.template .vscode/launch.json +``` + +This will create a local copy of the launch configuration that you can customize without affecting the shared template. + + +### 4. (Optional) Install development version of SAM Transformer If you want to run the latest version of [SAM Transformer](https://github.com/aws/serverless-application-model/) or work on it at the same time, you can clone it locally and install it in your virtual environment. diff --git a/appveyor-ubuntu.yml b/appveyor-ubuntu.yml index 4c94632c9f2..9e4ef20a0e9 100644 --- a/appveyor-ubuntu.yml +++ b/appveyor-ubuntu.yml @@ -304,6 +304,9 @@ install: - sh: 'export AWS_KMS_KEY=$(echo "$test_env_var" | jq -j ".TestKMSKeyArn")' - sh: 'export AWS_SIGNING_PROFILE_NAME=$(echo "$test_env_var" | jq -j ".TestSigningProfileName")' - sh: 'export AWS_SIGNING_PROFILE_VERSION_ARN=$(echo "$test_env_var" | jq -j ".TestSigningProfileARN")' + - sh: 'export LMI_SUBNET_ID=$(echo "$test_env_var" | jq -j ".LMISubnetId")' + - sh: 'export LMI_SECURITY_GROUP_ID=$(echo "$test_env_var" | jq -j ".LMISecurityGroupId")' + - sh: 'export LMI_OPERATOR_ROLE_ARN=$(echo "$test_env_var" | jq -j ".LMIOperatorRoleArn")' # Runtime-aware Docker Hub authentication with fail-fast - sh: | diff --git a/appveyor-windows-al2023.yml b/appveyor-windows-al2023.yml index e453ae5839d..e2f51ba7ce7 100644 --- a/appveyor-windows-al2023.yml +++ b/appveyor-windows-al2023.yml @@ -102,6 +102,9 @@ install: $env:AWS_KMS_KEY = $test_env_var_json.TestKMSKeyArn; $env:AWS_SIGNING_PROFILE_NAME = $test_env_var_json.TestSigningProfileName; $env:AWS_SIGNING_PROFILE_VERSION_ARN = $test_env_var_json.TestSigningProfileARN; + $env:LMI_SUBNET_ID = $test_env_var_json.LMISubnetId; + $env:LMI_SECURITY_GROUP_ID = $test_env_var_json.LMISecurityGroupId; + $env:LMI_OPERATOR_ROLE_ARN = $test_env_var_json.LMIOperatorRoleArn; }" diff --git a/appveyor-windows.yml b/appveyor-windows.yml index f043dce8e2b..f3c2e321a97 100644 --- a/appveyor-windows.yml +++ b/appveyor-windows.yml @@ -112,6 +112,9 @@ install: $env:AWS_KMS_KEY = $test_env_var_json.TestKMSKeyArn; $env:AWS_SIGNING_PROFILE_NAME = $test_env_var_json.TestSigningProfileName; $env:AWS_SIGNING_PROFILE_VERSION_ARN = $test_env_var_json.TestSigningProfileARN; + $env:LMI_SUBNET_ID = $test_env_var_json.LMISubnetId; + $env:LMI_SECURITY_GROUP_ID = $test_env_var_json.LMISecurityGroupId; + $env:LMI_OPERATOR_ROLE_ARN = $test_env_var_json.LMIOperatorRoleArn; }" diff --git a/requirements/base.txt b/requirements/base.txt index 3c9b5cc6898..0891bbaf1e3 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,12 +1,12 @@ chevron~=0.12 click==8.1.8 Flask<3.2 -boto3[crt]>=1.29.2,<2 +boto3[crt]==1.42.0 jmespath~=1.0.1 ruamel_yaml~=0.18.16 PyYAML~=6.0 cookiecutter~=2.6.0 -aws-sam-translator==1.103.0 +aws-sam-translator==1.104.0 #docker minor version updates can include breaking changes. Auto update micro version only. docker~=7.1.0 dateparser~=1.2 @@ -34,4 +34,4 @@ tzlocal==5.3.1 cfn-lint~=1.41.0 # Type checking boto3 objects -boto3-stubs[apigateway,cloudformation,ecr,iam,lambda,s3,schemas,secretsmanager,signer,stepfunctions,sts,xray,sqs,kinesis]==1.41.0 +boto3-stubs[apigateway,cloudformation,ecr,iam,lambda,s3,schemas,secretsmanager,signer,stepfunctions,sts,xray,sqs,kinesis]>=1.41.0 diff --git a/requirements/reproducible-linux.txt b/requirements/reproducible-linux.txt index a3fc9264bd5..6807452bc8e 100644 --- a/requirements/reproducible-linux.txt +++ b/requirements/reproducible-linux.txt @@ -22,53 +22,53 @@ aws-lambda-builders==1.60.0 \ --hash=sha256:518b668130e550a4c88968432fba344a36aa965cca89bf8a30456f991053ae0e \ --hash=sha256:c61d49dd80ee00a484bea3754ff55fd45e6ec5cd3c56169a6eb8767882187dbd # via aws-sam-cli (setup.py) -aws-sam-translator==1.103.0 \ - --hash=sha256:8317b72ef412db581dc7846932a44dfc1729adea578d9307a3e6ece46a7882ca \ - --hash=sha256:d4eb4a1efa62f00b253ee5f8c0084bd4b7687186c6a12338f900ebe07ff74dad +aws-sam-translator==1.104.0 \ + --hash=sha256:907c50e812f88514fa8f41b8adcb37ba0ee28e1b8c0144b011c4985471b1201d \ + --hash=sha256:d873a064df52a7e6426e34cb55ee737085757114776b6ea03e58416f6976d975 # via # aws-sam-cli (setup.py) # cfn-lint -awscrt==0.28.4 \ - --hash=sha256:0024b3e26a5ce9ffc9a92533f0a62bd823e025465f3b90ad3dda2878a260171a \ - --hash=sha256:046006703a7ed6278d5f80214c9aae02fc6b6a65a5f7ceb721becf9e1ad90604 \ - --hash=sha256:08941fb1f603f1b7b722e8670f121ccf8f25a4073d2f92e3696ae763e309a39d \ - --hash=sha256:08b884bb6809d22f80921feb0ae9353fea1a750109a18d02057b6bba742db439 \ - --hash=sha256:13ed9b71a346146a89de85c173d007142416e6cc0358d7ca6b0d68dc1d159667 \ - --hash=sha256:19adb9fa309111e20e1e850c876f093247ad084efdaa2dd654a15aef4b4bc637 \ - --hash=sha256:1c6319d297d18ba7cf3c6a8f69f76fd22b949e4ea8a280eb2098a8d6ed0d25be \ - --hash=sha256:1dd5dac3f761cb74c70c7feebf9f8dc96dc3b8db8248e5899bcbf34633d974a3 \ - --hash=sha256:277af1c4e5ef666192bd04aea8c3afcbb26d7794594f6f7ba23d7285df5be65e \ - --hash=sha256:43e1a88bec567e89eb398151ca6af389feb3a7f9b297141061fe759e65b43a52 \ - --hash=sha256:4657eb947e214c8314d1b26df64e4f8c7c2f3b6817cebae00e3288a4bb1779c5 \ - --hash=sha256:56e949e965a7668fe0bc6638edf88c009e44090216bb894a5ac758153b01757c \ - --hash=sha256:5d95f06142634043dc5c9e20e8f7a16b710e32aa1ae3f42eebb3643cabbe4275 \ - --hash=sha256:653f0c57f1f00cf526045ae2b76cd79f13cb751ed5400a545dcf44a9e5d0fa96 \ - --hash=sha256:694c183bf2c3ef1d538caa5a73c007cddd841529bc43c6beeb02eb6a353094e6 \ - --hash=sha256:6a8543c8637374a0963113663fe053c43868d091e5109bcdc1f14f79f780a6c7 \ - --hash=sha256:726926408dea51284fc5f4ab112e797226d59c172bc58156e925099769cc4217 \ - --hash=sha256:79217f918e02e02ef9ec6f2e77cdf5afc44a8c904840fe0f873d9116db1e8860 \ - --hash=sha256:79d1cb861d017db8657a0fe0b4a02ddc60d596107e2e9e7816eaaca1afa30da4 \ - --hash=sha256:7d4365d17bc44e24f861237e37c085aca89d22af756e6f1f5f05ffdc72c93737 \ - --hash=sha256:7e0559ea770589958cdbed21f46d2ffdec2836ef43a00a4689d25205bb05cd22 \ - --hash=sha256:86bb7612250925d49480a4648d30855d8f3d0e1dd8c322c586b4684847ff5d70 \ - --hash=sha256:8bfbe9dae84acb76d05ffde64a85c06e71c05819890f4c28be3204c75e0d5c76 \ - --hash=sha256:8f1aef999e0d48a4c3c2e6a713849392b883f918f4c1ce2b00d701c94c3252f8 \ - --hash=sha256:9389743eb4c04d1fa0ed5448b4bc6c8283239ece9a9ff4145a5d41ddecd02d42 \ - --hash=sha256:98dd46532ebb311123080c8df8805a94e66c02fa1b7d52d6d2f9abcf589c7f8b \ - --hash=sha256:98deb64086f30454f791bd52aae2a2086d7896831b5966e8d22cb49b85758e4a \ - --hash=sha256:a14b75f6c0cf79f2cb614c2459a492f8fed1836456e6488125652c9b2e7777aa \ - --hash=sha256:a40aa941cf8201382986e4287c4fe51067a8bc2c78d9668937a6861cf14a54c6 \ - --hash=sha256:b6d6de9172ef52ba1fb5cba12355bf6e845447a750a5214e9f57bf08aeeb6251 \ - --hash=sha256:d1e205e53b08456f0f83210c20c674ebdef96e3e80f716d1bf4ad666db2c643b \ - --hash=sha256:d2835094e92d0a3d1722d03afd54983115b2172d57581a664ad6a2af3d33c12c \ - --hash=sha256:d419febaf110d8dbcfdcd7a37a74882a8fae68a6f96e40d7d53c93ef11fc9c70 \ - --hash=sha256:d5b252bd5b30056b73827fdbc8d74d7d7af09e271b94ffca2cc3d96e625389ea \ - --hash=sha256:dc11d00600888a690c1ad875759708a4d21bdf81b6c2032e0227687d27fca910 \ - --hash=sha256:dd23b9bad57812d7b1d1de785e10a44e3352cf1f3c0e5bd7b678b27d93f482a4 \ - --hash=sha256:e030f2036619d98e237a3561c8138b61fd4bf2f3f361d9f2e0b1112a8cd53d5b \ - --hash=sha256:e20266fed25bd4198f59541b4343328479524042b781e810e8c6ad9c82a0420c \ - --hash=sha256:e328d2afe68c772cae8cab23a210329ce1f434a1380fb585c2dba890cf08d3f1 \ - --hash=sha256:e82b2ccd3caf1159d5a2ffea480eb5dbb0e9ea9baf4ac640d9fb86615ef22b9e +awscrt==0.29.1 \ + --hash=sha256:002479448ee57c412f9543323ff9aeecc53d2bb7b0d39d8a0ba5909b8d51c459 \ + --hash=sha256:1035bfa73e5eab360434bde20b991e674439073362ca45fd4cc7f95db7044ac6 \ + --hash=sha256:1446d0ba07bf8fd9d034e45597bf526f01ca5a2fc0c72d0a20c3b360a32471af \ + --hash=sha256:1af4d8f32f3bef192669a2bf302f01fe9451562b51d39a878287d276e83d922c \ + --hash=sha256:210625ad30535afd9691da4293150801c89c1ef681ac3f45aa1186ba912c62ac \ + --hash=sha256:21a19a299e998c9b0a72b685fcdbd60888ee3e40ef25136f6c10be9ee2183e28 \ + --hash=sha256:21d120ab3d8d68cd380be88a680b2467da52899bdaa406265f1d72778401ac2a \ + --hash=sha256:258ec1255bcdaf45154f67fb988e4a0b35a0e222e7bfe3d013fee3eb560ddca3 \ + --hash=sha256:2b6907f5e0dcc61e76404e5dd5901aeddb0c3cfad25115a22048babf26982e2f \ + --hash=sha256:342896264785dffef5a9022925899d100215025415f63388b8f228708a3de321 \ + --hash=sha256:3c8acc0b8c6434f93d7341c875670df2af29de037da11386e19c9e1b773efd00 \ + --hash=sha256:41ddbcbcf3ec9b67afcf13c6e532b90d0734b90ecadd15b2875319234bad63e3 \ + --hash=sha256:45d1c06a634c7b4be34a04f21a46c6c08444a94fa44b96ab109be891fb2b606b \ + --hash=sha256:4e71994e5e2923672983488666f2bc1cf108adacb22049cc1ce38493891f88e0 \ + --hash=sha256:5b11e6b962d271bdff7b66c903df5d0e05611fb7e0dda91326a78a42b738dfed \ + --hash=sha256:62073a50ca316018d27937ed881824181726e314fe6d1800f86a9bca3e9cadcb \ + --hash=sha256:657cc493e3aa3a836b00fd7c17c9c8cbccd4d75059475826452d406a67722c95 \ + --hash=sha256:6bd87da4eb4bbda738caad2e13d3943bb217bce1cdb21d411632c9b553322f5b \ + --hash=sha256:701d7834967acae9802e4913521ba3bb7e2795e2391c987465da6bd97c8e2af2 \ + --hash=sha256:74bb7c5f7a8203897aebf96a5d8b0a9993e6efa4e856a57f0fff1aa9cec482e9 \ + --hash=sha256:7774450c90f29aaea0986e2cd85a1f10a47f67544af5453947df317f0a1b4708 \ + --hash=sha256:794ab960ca5db0e24b9438d5ffe63f7d1c57e9b6f9c4b3f017a3b889135d3874 \ + --hash=sha256:7cddff658bffc265a0df325575f0173d421772282f5da9f8e8bf9702a03deabc \ + --hash=sha256:8a9442031ba341218878607f5a0d1df18ac98fb951c17f99b836784eb9380442 \ + --hash=sha256:8c5b7a0cd1d6553882cea4340319a06e8b7583058b508bdab8859607a40c3350 \ + --hash=sha256:8fc304af5f6f83e7e73096fb42eb51d4a85fa7a90456466ef22872095d4ca46f \ + --hash=sha256:9ab168654175b4a869a2e61836bed0428cb72ac0a7aca63ce36cde04d941e2a2 \ + --hash=sha256:9ae0921af7204ad2095d1e610b4a79a43282358bb5353167c5f497ef001af099 \ + --hash=sha256:9cf1ed3552c498555f1eee64154be288f9ce1e6fe540368087870f11195db4a7 \ + --hash=sha256:af8cb6898c2060d4b0cf2e4b9150ec49d3ed432ee8ae481413cef2edbbd53a9f \ + --hash=sha256:bbe77db7e317080219ecad1ff9a0f69750b01265cab305586c24a3a587b7ba71 \ + --hash=sha256:c55a4ad949b68811083bc8b010bf0bc64907a3c0dd048e783cb06733bcc60c56 \ + --hash=sha256:c560faacb671cf494725f0a76558ae22db3ab6feeef0f7af757f64b94383b307 \ + --hash=sha256:ca53a8cf69808a48a5f0a02a1a4270a203551b59c47ba7797e789c337feec50b \ + --hash=sha256:ce87d70d511c50d192f068f0540df19dc5c6b0d35a902e0a213106e2854df3eb \ + --hash=sha256:d21df9c33e20e2ab7f5cb595853752b7e49bb40de7a41e46eb37d66fd721a09e \ + --hash=sha256:d75bafdb2ad8e1f5ab427d3efcea894c84207b3ed08405922ea0f74f61f13792 \ + --hash=sha256:e826eb2cad73273cc6835e893e21e5b83ff42f505f88dc2cccdb2dc64dbca870 \ + --hash=sha256:f3a5a37b6d26b2f33e9dbfa4849de5539c2463da3024519114236102b2676cfb \ + --hash=sha256:f9c320b70986f054f5b3bebe8e05879eff5aaf4dc2b8b67b25eed5be23399acd # via botocore binaryornot==0.4.4 \ --hash=sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061 \ @@ -78,9 +78,9 @@ blinker==1.9.0 \ --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc # via flask -boto3[crt]==1.41.0 \ - --hash=sha256:73bf7f63152406404c0359c013a692e884b98a3b297160058a38f00ef19e375b \ - --hash=sha256:d5c454bb23655b052073c8dc6703dda5360825b72b1691822ae7709050b96390 +boto3[crt]==1.42.0 \ + --hash=sha256:9c67729a6112b7dced521ea70b0369fba138e89852b029a7876041cd1460c084 \ + --hash=sha256:af32b7f61dd6293cad728ec205bcb3611ab1bf7b7dbccfd0f2bd7b9c9af96039 # via # aws-sam-cli (setup.py) # aws-sam-translator @@ -88,9 +88,9 @@ boto3-stubs[apigateway,cloudformation,ecr,iam,kinesis,lambda,s3,schemas,secretsm --hash=sha256:74d138f2d2f5f48140be81d680722b0194e09bcdf8d2080a914c21d277c2cfe3 \ --hash=sha256:9e373ac5f9c721c5e615ef694a992c333480c71905d3b8e940175dd7265eef93 # via aws-sam-cli (setup.py) -botocore[crt]==1.41.0 \ - --hash=sha256:555afbf86a644bfa4ebd7bd98d717b53b792e6bbb2c49f2b308fb06964cf1655 \ - --hash=sha256:a5018d6268eee358dfc5d86e596c3062b4e225690acaf946f54c00063b804bf8 +botocore[crt]==1.41.6 \ + --hash=sha256:08fe47e9b306f4436f5eaf6a02cb6d55c7745d13d2d093ce5d917d3ef3d3df75 \ + --hash=sha256:963cc946e885acb941c96e7d343cb6507b479812ca22566ceb3e9410d0588de0 # via # boto3 # s3transfer @@ -1182,9 +1182,9 @@ ruamel-yaml-clib==0.2.14 \ --hash=sha256:fbc08c02e9b147a11dfcaa1ac8a83168b699863493e183f7c0c8b12850b7d259 \ --hash=sha256:ff86876889ea478b1381089e55cf9e345707b312beda4986f823e1d95e8c0f59 # via ruamel-yaml -s3transfer==0.14.0 \ - --hash=sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456 \ - --hash=sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125 +s3transfer==0.16.0 \ + --hash=sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe \ + --hash=sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920 # via boto3 six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ diff --git a/requirements/reproducible-mac.txt b/requirements/reproducible-mac.txt index cdf84011ec8..5d63d2d4c1b 100644 --- a/requirements/reproducible-mac.txt +++ b/requirements/reproducible-mac.txt @@ -22,53 +22,53 @@ aws-lambda-builders==1.60.0 \ --hash=sha256:518b668130e550a4c88968432fba344a36aa965cca89bf8a30456f991053ae0e \ --hash=sha256:c61d49dd80ee00a484bea3754ff55fd45e6ec5cd3c56169a6eb8767882187dbd # via aws-sam-cli (setup.py) -aws-sam-translator==1.103.0 \ - --hash=sha256:8317b72ef412db581dc7846932a44dfc1729adea578d9307a3e6ece46a7882ca \ - --hash=sha256:d4eb4a1efa62f00b253ee5f8c0084bd4b7687186c6a12338f900ebe07ff74dad +aws-sam-translator==1.104.0 \ + --hash=sha256:907c50e812f88514fa8f41b8adcb37ba0ee28e1b8c0144b011c4985471b1201d \ + --hash=sha256:d873a064df52a7e6426e34cb55ee737085757114776b6ea03e58416f6976d975 # via # aws-sam-cli (setup.py) # cfn-lint -awscrt==0.28.4 \ - --hash=sha256:0024b3e26a5ce9ffc9a92533f0a62bd823e025465f3b90ad3dda2878a260171a \ - --hash=sha256:046006703a7ed6278d5f80214c9aae02fc6b6a65a5f7ceb721becf9e1ad90604 \ - --hash=sha256:08941fb1f603f1b7b722e8670f121ccf8f25a4073d2f92e3696ae763e309a39d \ - --hash=sha256:08b884bb6809d22f80921feb0ae9353fea1a750109a18d02057b6bba742db439 \ - --hash=sha256:13ed9b71a346146a89de85c173d007142416e6cc0358d7ca6b0d68dc1d159667 \ - --hash=sha256:19adb9fa309111e20e1e850c876f093247ad084efdaa2dd654a15aef4b4bc637 \ - --hash=sha256:1c6319d297d18ba7cf3c6a8f69f76fd22b949e4ea8a280eb2098a8d6ed0d25be \ - --hash=sha256:1dd5dac3f761cb74c70c7feebf9f8dc96dc3b8db8248e5899bcbf34633d974a3 \ - --hash=sha256:277af1c4e5ef666192bd04aea8c3afcbb26d7794594f6f7ba23d7285df5be65e \ - --hash=sha256:43e1a88bec567e89eb398151ca6af389feb3a7f9b297141061fe759e65b43a52 \ - --hash=sha256:4657eb947e214c8314d1b26df64e4f8c7c2f3b6817cebae00e3288a4bb1779c5 \ - --hash=sha256:56e949e965a7668fe0bc6638edf88c009e44090216bb894a5ac758153b01757c \ - --hash=sha256:5d95f06142634043dc5c9e20e8f7a16b710e32aa1ae3f42eebb3643cabbe4275 \ - --hash=sha256:653f0c57f1f00cf526045ae2b76cd79f13cb751ed5400a545dcf44a9e5d0fa96 \ - --hash=sha256:694c183bf2c3ef1d538caa5a73c007cddd841529bc43c6beeb02eb6a353094e6 \ - --hash=sha256:6a8543c8637374a0963113663fe053c43868d091e5109bcdc1f14f79f780a6c7 \ - --hash=sha256:726926408dea51284fc5f4ab112e797226d59c172bc58156e925099769cc4217 \ - --hash=sha256:79217f918e02e02ef9ec6f2e77cdf5afc44a8c904840fe0f873d9116db1e8860 \ - --hash=sha256:79d1cb861d017db8657a0fe0b4a02ddc60d596107e2e9e7816eaaca1afa30da4 \ - --hash=sha256:7d4365d17bc44e24f861237e37c085aca89d22af756e6f1f5f05ffdc72c93737 \ - --hash=sha256:7e0559ea770589958cdbed21f46d2ffdec2836ef43a00a4689d25205bb05cd22 \ - --hash=sha256:86bb7612250925d49480a4648d30855d8f3d0e1dd8c322c586b4684847ff5d70 \ - --hash=sha256:8bfbe9dae84acb76d05ffde64a85c06e71c05819890f4c28be3204c75e0d5c76 \ - --hash=sha256:8f1aef999e0d48a4c3c2e6a713849392b883f918f4c1ce2b00d701c94c3252f8 \ - --hash=sha256:9389743eb4c04d1fa0ed5448b4bc6c8283239ece9a9ff4145a5d41ddecd02d42 \ - --hash=sha256:98dd46532ebb311123080c8df8805a94e66c02fa1b7d52d6d2f9abcf589c7f8b \ - --hash=sha256:98deb64086f30454f791bd52aae2a2086d7896831b5966e8d22cb49b85758e4a \ - --hash=sha256:a14b75f6c0cf79f2cb614c2459a492f8fed1836456e6488125652c9b2e7777aa \ - --hash=sha256:a40aa941cf8201382986e4287c4fe51067a8bc2c78d9668937a6861cf14a54c6 \ - --hash=sha256:b6d6de9172ef52ba1fb5cba12355bf6e845447a750a5214e9f57bf08aeeb6251 \ - --hash=sha256:d1e205e53b08456f0f83210c20c674ebdef96e3e80f716d1bf4ad666db2c643b \ - --hash=sha256:d2835094e92d0a3d1722d03afd54983115b2172d57581a664ad6a2af3d33c12c \ - --hash=sha256:d419febaf110d8dbcfdcd7a37a74882a8fae68a6f96e40d7d53c93ef11fc9c70 \ - --hash=sha256:d5b252bd5b30056b73827fdbc8d74d7d7af09e271b94ffca2cc3d96e625389ea \ - --hash=sha256:dc11d00600888a690c1ad875759708a4d21bdf81b6c2032e0227687d27fca910 \ - --hash=sha256:dd23b9bad57812d7b1d1de785e10a44e3352cf1f3c0e5bd7b678b27d93f482a4 \ - --hash=sha256:e030f2036619d98e237a3561c8138b61fd4bf2f3f361d9f2e0b1112a8cd53d5b \ - --hash=sha256:e20266fed25bd4198f59541b4343328479524042b781e810e8c6ad9c82a0420c \ - --hash=sha256:e328d2afe68c772cae8cab23a210329ce1f434a1380fb585c2dba890cf08d3f1 \ - --hash=sha256:e82b2ccd3caf1159d5a2ffea480eb5dbb0e9ea9baf4ac640d9fb86615ef22b9e +awscrt==0.29.1 \ + --hash=sha256:002479448ee57c412f9543323ff9aeecc53d2bb7b0d39d8a0ba5909b8d51c459 \ + --hash=sha256:1035bfa73e5eab360434bde20b991e674439073362ca45fd4cc7f95db7044ac6 \ + --hash=sha256:1446d0ba07bf8fd9d034e45597bf526f01ca5a2fc0c72d0a20c3b360a32471af \ + --hash=sha256:1af4d8f32f3bef192669a2bf302f01fe9451562b51d39a878287d276e83d922c \ + --hash=sha256:210625ad30535afd9691da4293150801c89c1ef681ac3f45aa1186ba912c62ac \ + --hash=sha256:21a19a299e998c9b0a72b685fcdbd60888ee3e40ef25136f6c10be9ee2183e28 \ + --hash=sha256:21d120ab3d8d68cd380be88a680b2467da52899bdaa406265f1d72778401ac2a \ + --hash=sha256:258ec1255bcdaf45154f67fb988e4a0b35a0e222e7bfe3d013fee3eb560ddca3 \ + --hash=sha256:2b6907f5e0dcc61e76404e5dd5901aeddb0c3cfad25115a22048babf26982e2f \ + --hash=sha256:342896264785dffef5a9022925899d100215025415f63388b8f228708a3de321 \ + --hash=sha256:3c8acc0b8c6434f93d7341c875670df2af29de037da11386e19c9e1b773efd00 \ + --hash=sha256:41ddbcbcf3ec9b67afcf13c6e532b90d0734b90ecadd15b2875319234bad63e3 \ + --hash=sha256:45d1c06a634c7b4be34a04f21a46c6c08444a94fa44b96ab109be891fb2b606b \ + --hash=sha256:4e71994e5e2923672983488666f2bc1cf108adacb22049cc1ce38493891f88e0 \ + --hash=sha256:5b11e6b962d271bdff7b66c903df5d0e05611fb7e0dda91326a78a42b738dfed \ + --hash=sha256:62073a50ca316018d27937ed881824181726e314fe6d1800f86a9bca3e9cadcb \ + --hash=sha256:657cc493e3aa3a836b00fd7c17c9c8cbccd4d75059475826452d406a67722c95 \ + --hash=sha256:6bd87da4eb4bbda738caad2e13d3943bb217bce1cdb21d411632c9b553322f5b \ + --hash=sha256:701d7834967acae9802e4913521ba3bb7e2795e2391c987465da6bd97c8e2af2 \ + --hash=sha256:74bb7c5f7a8203897aebf96a5d8b0a9993e6efa4e856a57f0fff1aa9cec482e9 \ + --hash=sha256:7774450c90f29aaea0986e2cd85a1f10a47f67544af5453947df317f0a1b4708 \ + --hash=sha256:794ab960ca5db0e24b9438d5ffe63f7d1c57e9b6f9c4b3f017a3b889135d3874 \ + --hash=sha256:7cddff658bffc265a0df325575f0173d421772282f5da9f8e8bf9702a03deabc \ + --hash=sha256:8a9442031ba341218878607f5a0d1df18ac98fb951c17f99b836784eb9380442 \ + --hash=sha256:8c5b7a0cd1d6553882cea4340319a06e8b7583058b508bdab8859607a40c3350 \ + --hash=sha256:8fc304af5f6f83e7e73096fb42eb51d4a85fa7a90456466ef22872095d4ca46f \ + --hash=sha256:9ab168654175b4a869a2e61836bed0428cb72ac0a7aca63ce36cde04d941e2a2 \ + --hash=sha256:9ae0921af7204ad2095d1e610b4a79a43282358bb5353167c5f497ef001af099 \ + --hash=sha256:9cf1ed3552c498555f1eee64154be288f9ce1e6fe540368087870f11195db4a7 \ + --hash=sha256:af8cb6898c2060d4b0cf2e4b9150ec49d3ed432ee8ae481413cef2edbbd53a9f \ + --hash=sha256:bbe77db7e317080219ecad1ff9a0f69750b01265cab305586c24a3a587b7ba71 \ + --hash=sha256:c55a4ad949b68811083bc8b010bf0bc64907a3c0dd048e783cb06733bcc60c56 \ + --hash=sha256:c560faacb671cf494725f0a76558ae22db3ab6feeef0f7af757f64b94383b307 \ + --hash=sha256:ca53a8cf69808a48a5f0a02a1a4270a203551b59c47ba7797e789c337feec50b \ + --hash=sha256:ce87d70d511c50d192f068f0540df19dc5c6b0d35a902e0a213106e2854df3eb \ + --hash=sha256:d21df9c33e20e2ab7f5cb595853752b7e49bb40de7a41e46eb37d66fd721a09e \ + --hash=sha256:d75bafdb2ad8e1f5ab427d3efcea894c84207b3ed08405922ea0f74f61f13792 \ + --hash=sha256:e826eb2cad73273cc6835e893e21e5b83ff42f505f88dc2cccdb2dc64dbca870 \ + --hash=sha256:f3a5a37b6d26b2f33e9dbfa4849de5539c2463da3024519114236102b2676cfb \ + --hash=sha256:f9c320b70986f054f5b3bebe8e05879eff5aaf4dc2b8b67b25eed5be23399acd # via botocore binaryornot==0.4.4 \ --hash=sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061 \ @@ -78,9 +78,9 @@ blinker==1.9.0 \ --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc # via flask -boto3[crt]==1.41.0 \ - --hash=sha256:73bf7f63152406404c0359c013a692e884b98a3b297160058a38f00ef19e375b \ - --hash=sha256:d5c454bb23655b052073c8dc6703dda5360825b72b1691822ae7709050b96390 +boto3[crt]==1.42.0 \ + --hash=sha256:9c67729a6112b7dced521ea70b0369fba138e89852b029a7876041cd1460c084 \ + --hash=sha256:af32b7f61dd6293cad728ec205bcb3611ab1bf7b7dbccfd0f2bd7b9c9af96039 # via # aws-sam-cli (setup.py) # aws-sam-translator @@ -88,9 +88,9 @@ boto3-stubs[apigateway,cloudformation,ecr,iam,kinesis,lambda,s3,schemas,secretsm --hash=sha256:74d138f2d2f5f48140be81d680722b0194e09bcdf8d2080a914c21d277c2cfe3 \ --hash=sha256:9e373ac5f9c721c5e615ef694a992c333480c71905d3b8e940175dd7265eef93 # via aws-sam-cli (setup.py) -botocore[crt]==1.41.0 \ - --hash=sha256:555afbf86a644bfa4ebd7bd98d717b53b792e6bbb2c49f2b308fb06964cf1655 \ - --hash=sha256:a5018d6268eee358dfc5d86e596c3062b4e225690acaf946f54c00063b804bf8 +botocore[crt]==1.41.6 \ + --hash=sha256:08fe47e9b306f4436f5eaf6a02cb6d55c7745d13d2d093ce5d917d3ef3d3df75 \ + --hash=sha256:963cc946e885acb941c96e7d343cb6507b479812ca22566ceb3e9410d0588de0 # via # boto3 # s3transfer @@ -1182,9 +1182,9 @@ ruamel-yaml-clib==0.2.14 \ --hash=sha256:fbc08c02e9b147a11dfcaa1ac8a83168b699863493e183f7c0c8b12850b7d259 \ --hash=sha256:ff86876889ea478b1381089e55cf9e345707b312beda4986f823e1d95e8c0f59 # via ruamel-yaml -s3transfer==0.14.0 \ - --hash=sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456 \ - --hash=sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125 +s3transfer==0.16.0 \ + --hash=sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe \ + --hash=sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920 # via boto3 six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ diff --git a/requirements/reproducible-win.txt b/requirements/reproducible-win.txt index 4afa96ff70f..d4629967e3b 100644 --- a/requirements/reproducible-win.txt +++ b/requirements/reproducible-win.txt @@ -22,53 +22,53 @@ aws-lambda-builders==1.60.0 \ --hash=sha256:518b668130e550a4c88968432fba344a36aa965cca89bf8a30456f991053ae0e \ --hash=sha256:c61d49dd80ee00a484bea3754ff55fd45e6ec5cd3c56169a6eb8767882187dbd # via aws-sam-cli (setup.py) -aws-sam-translator==1.103.0 \ - --hash=sha256:8317b72ef412db581dc7846932a44dfc1729adea578d9307a3e6ece46a7882ca \ - --hash=sha256:d4eb4a1efa62f00b253ee5f8c0084bd4b7687186c6a12338f900ebe07ff74dad +aws-sam-translator==1.104.0 \ + --hash=sha256:907c50e812f88514fa8f41b8adcb37ba0ee28e1b8c0144b011c4985471b1201d \ + --hash=sha256:d873a064df52a7e6426e34cb55ee737085757114776b6ea03e58416f6976d975 # via # aws-sam-cli (setup.py) # cfn-lint -awscrt==0.28.4 \ - --hash=sha256:0024b3e26a5ce9ffc9a92533f0a62bd823e025465f3b90ad3dda2878a260171a \ - --hash=sha256:046006703a7ed6278d5f80214c9aae02fc6b6a65a5f7ceb721becf9e1ad90604 \ - --hash=sha256:08941fb1f603f1b7b722e8670f121ccf8f25a4073d2f92e3696ae763e309a39d \ - --hash=sha256:08b884bb6809d22f80921feb0ae9353fea1a750109a18d02057b6bba742db439 \ - --hash=sha256:13ed9b71a346146a89de85c173d007142416e6cc0358d7ca6b0d68dc1d159667 \ - --hash=sha256:19adb9fa309111e20e1e850c876f093247ad084efdaa2dd654a15aef4b4bc637 \ - --hash=sha256:1c6319d297d18ba7cf3c6a8f69f76fd22b949e4ea8a280eb2098a8d6ed0d25be \ - --hash=sha256:1dd5dac3f761cb74c70c7feebf9f8dc96dc3b8db8248e5899bcbf34633d974a3 \ - --hash=sha256:277af1c4e5ef666192bd04aea8c3afcbb26d7794594f6f7ba23d7285df5be65e \ - --hash=sha256:43e1a88bec567e89eb398151ca6af389feb3a7f9b297141061fe759e65b43a52 \ - --hash=sha256:4657eb947e214c8314d1b26df64e4f8c7c2f3b6817cebae00e3288a4bb1779c5 \ - --hash=sha256:56e949e965a7668fe0bc6638edf88c009e44090216bb894a5ac758153b01757c \ - --hash=sha256:5d95f06142634043dc5c9e20e8f7a16b710e32aa1ae3f42eebb3643cabbe4275 \ - --hash=sha256:653f0c57f1f00cf526045ae2b76cd79f13cb751ed5400a545dcf44a9e5d0fa96 \ - --hash=sha256:694c183bf2c3ef1d538caa5a73c007cddd841529bc43c6beeb02eb6a353094e6 \ - --hash=sha256:6a8543c8637374a0963113663fe053c43868d091e5109bcdc1f14f79f780a6c7 \ - --hash=sha256:726926408dea51284fc5f4ab112e797226d59c172bc58156e925099769cc4217 \ - --hash=sha256:79217f918e02e02ef9ec6f2e77cdf5afc44a8c904840fe0f873d9116db1e8860 \ - --hash=sha256:79d1cb861d017db8657a0fe0b4a02ddc60d596107e2e9e7816eaaca1afa30da4 \ - --hash=sha256:7d4365d17bc44e24f861237e37c085aca89d22af756e6f1f5f05ffdc72c93737 \ - --hash=sha256:7e0559ea770589958cdbed21f46d2ffdec2836ef43a00a4689d25205bb05cd22 \ - --hash=sha256:86bb7612250925d49480a4648d30855d8f3d0e1dd8c322c586b4684847ff5d70 \ - --hash=sha256:8bfbe9dae84acb76d05ffde64a85c06e71c05819890f4c28be3204c75e0d5c76 \ - --hash=sha256:8f1aef999e0d48a4c3c2e6a713849392b883f918f4c1ce2b00d701c94c3252f8 \ - --hash=sha256:9389743eb4c04d1fa0ed5448b4bc6c8283239ece9a9ff4145a5d41ddecd02d42 \ - --hash=sha256:98dd46532ebb311123080c8df8805a94e66c02fa1b7d52d6d2f9abcf589c7f8b \ - --hash=sha256:98deb64086f30454f791bd52aae2a2086d7896831b5966e8d22cb49b85758e4a \ - --hash=sha256:a14b75f6c0cf79f2cb614c2459a492f8fed1836456e6488125652c9b2e7777aa \ - --hash=sha256:a40aa941cf8201382986e4287c4fe51067a8bc2c78d9668937a6861cf14a54c6 \ - --hash=sha256:b6d6de9172ef52ba1fb5cba12355bf6e845447a750a5214e9f57bf08aeeb6251 \ - --hash=sha256:d1e205e53b08456f0f83210c20c674ebdef96e3e80f716d1bf4ad666db2c643b \ - --hash=sha256:d2835094e92d0a3d1722d03afd54983115b2172d57581a664ad6a2af3d33c12c \ - --hash=sha256:d419febaf110d8dbcfdcd7a37a74882a8fae68a6f96e40d7d53c93ef11fc9c70 \ - --hash=sha256:d5b252bd5b30056b73827fdbc8d74d7d7af09e271b94ffca2cc3d96e625389ea \ - --hash=sha256:dc11d00600888a690c1ad875759708a4d21bdf81b6c2032e0227687d27fca910 \ - --hash=sha256:dd23b9bad57812d7b1d1de785e10a44e3352cf1f3c0e5bd7b678b27d93f482a4 \ - --hash=sha256:e030f2036619d98e237a3561c8138b61fd4bf2f3f361d9f2e0b1112a8cd53d5b \ - --hash=sha256:e20266fed25bd4198f59541b4343328479524042b781e810e8c6ad9c82a0420c \ - --hash=sha256:e328d2afe68c772cae8cab23a210329ce1f434a1380fb585c2dba890cf08d3f1 \ - --hash=sha256:e82b2ccd3caf1159d5a2ffea480eb5dbb0e9ea9baf4ac640d9fb86615ef22b9e +awscrt==0.29.1 \ + --hash=sha256:002479448ee57c412f9543323ff9aeecc53d2bb7b0d39d8a0ba5909b8d51c459 \ + --hash=sha256:1035bfa73e5eab360434bde20b991e674439073362ca45fd4cc7f95db7044ac6 \ + --hash=sha256:1446d0ba07bf8fd9d034e45597bf526f01ca5a2fc0c72d0a20c3b360a32471af \ + --hash=sha256:1af4d8f32f3bef192669a2bf302f01fe9451562b51d39a878287d276e83d922c \ + --hash=sha256:210625ad30535afd9691da4293150801c89c1ef681ac3f45aa1186ba912c62ac \ + --hash=sha256:21a19a299e998c9b0a72b685fcdbd60888ee3e40ef25136f6c10be9ee2183e28 \ + --hash=sha256:21d120ab3d8d68cd380be88a680b2467da52899bdaa406265f1d72778401ac2a \ + --hash=sha256:258ec1255bcdaf45154f67fb988e4a0b35a0e222e7bfe3d013fee3eb560ddca3 \ + --hash=sha256:2b6907f5e0dcc61e76404e5dd5901aeddb0c3cfad25115a22048babf26982e2f \ + --hash=sha256:342896264785dffef5a9022925899d100215025415f63388b8f228708a3de321 \ + --hash=sha256:3c8acc0b8c6434f93d7341c875670df2af29de037da11386e19c9e1b773efd00 \ + --hash=sha256:41ddbcbcf3ec9b67afcf13c6e532b90d0734b90ecadd15b2875319234bad63e3 \ + --hash=sha256:45d1c06a634c7b4be34a04f21a46c6c08444a94fa44b96ab109be891fb2b606b \ + --hash=sha256:4e71994e5e2923672983488666f2bc1cf108adacb22049cc1ce38493891f88e0 \ + --hash=sha256:5b11e6b962d271bdff7b66c903df5d0e05611fb7e0dda91326a78a42b738dfed \ + --hash=sha256:62073a50ca316018d27937ed881824181726e314fe6d1800f86a9bca3e9cadcb \ + --hash=sha256:657cc493e3aa3a836b00fd7c17c9c8cbccd4d75059475826452d406a67722c95 \ + --hash=sha256:6bd87da4eb4bbda738caad2e13d3943bb217bce1cdb21d411632c9b553322f5b \ + --hash=sha256:701d7834967acae9802e4913521ba3bb7e2795e2391c987465da6bd97c8e2af2 \ + --hash=sha256:74bb7c5f7a8203897aebf96a5d8b0a9993e6efa4e856a57f0fff1aa9cec482e9 \ + --hash=sha256:7774450c90f29aaea0986e2cd85a1f10a47f67544af5453947df317f0a1b4708 \ + --hash=sha256:794ab960ca5db0e24b9438d5ffe63f7d1c57e9b6f9c4b3f017a3b889135d3874 \ + --hash=sha256:7cddff658bffc265a0df325575f0173d421772282f5da9f8e8bf9702a03deabc \ + --hash=sha256:8a9442031ba341218878607f5a0d1df18ac98fb951c17f99b836784eb9380442 \ + --hash=sha256:8c5b7a0cd1d6553882cea4340319a06e8b7583058b508bdab8859607a40c3350 \ + --hash=sha256:8fc304af5f6f83e7e73096fb42eb51d4a85fa7a90456466ef22872095d4ca46f \ + --hash=sha256:9ab168654175b4a869a2e61836bed0428cb72ac0a7aca63ce36cde04d941e2a2 \ + --hash=sha256:9ae0921af7204ad2095d1e610b4a79a43282358bb5353167c5f497ef001af099 \ + --hash=sha256:9cf1ed3552c498555f1eee64154be288f9ce1e6fe540368087870f11195db4a7 \ + --hash=sha256:af8cb6898c2060d4b0cf2e4b9150ec49d3ed432ee8ae481413cef2edbbd53a9f \ + --hash=sha256:bbe77db7e317080219ecad1ff9a0f69750b01265cab305586c24a3a587b7ba71 \ + --hash=sha256:c55a4ad949b68811083bc8b010bf0bc64907a3c0dd048e783cb06733bcc60c56 \ + --hash=sha256:c560faacb671cf494725f0a76558ae22db3ab6feeef0f7af757f64b94383b307 \ + --hash=sha256:ca53a8cf69808a48a5f0a02a1a4270a203551b59c47ba7797e789c337feec50b \ + --hash=sha256:ce87d70d511c50d192f068f0540df19dc5c6b0d35a902e0a213106e2854df3eb \ + --hash=sha256:d21df9c33e20e2ab7f5cb595853752b7e49bb40de7a41e46eb37d66fd721a09e \ + --hash=sha256:d75bafdb2ad8e1f5ab427d3efcea894c84207b3ed08405922ea0f74f61f13792 \ + --hash=sha256:e826eb2cad73273cc6835e893e21e5b83ff42f505f88dc2cccdb2dc64dbca870 \ + --hash=sha256:f3a5a37b6d26b2f33e9dbfa4849de5539c2463da3024519114236102b2676cfb \ + --hash=sha256:f9c320b70986f054f5b3bebe8e05879eff5aaf4dc2b8b67b25eed5be23399acd # via botocore binaryornot==0.4.4 \ --hash=sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061 \ @@ -78,9 +78,9 @@ blinker==1.9.0 \ --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc # via flask -boto3[crt]==1.41.0 \ - --hash=sha256:73bf7f63152406404c0359c013a692e884b98a3b297160058a38f00ef19e375b \ - --hash=sha256:d5c454bb23655b052073c8dc6703dda5360825b72b1691822ae7709050b96390 +boto3[crt]==1.42.0 \ + --hash=sha256:9c67729a6112b7dced521ea70b0369fba138e89852b029a7876041cd1460c084 \ + --hash=sha256:af32b7f61dd6293cad728ec205bcb3611ab1bf7b7dbccfd0f2bd7b9c9af96039 # via # aws-sam-cli (setup.py) # aws-sam-translator @@ -88,9 +88,9 @@ boto3-stubs[apigateway,cloudformation,ecr,iam,kinesis,lambda,s3,schemas,secretsm --hash=sha256:74d138f2d2f5f48140be81d680722b0194e09bcdf8d2080a914c21d277c2cfe3 \ --hash=sha256:9e373ac5f9c721c5e615ef694a992c333480c71905d3b8e940175dd7265eef93 # via aws-sam-cli (setup.py) -botocore[crt]==1.41.0 \ - --hash=sha256:555afbf86a644bfa4ebd7bd98d717b53b792e6bbb2c49f2b308fb06964cf1655 \ - --hash=sha256:a5018d6268eee358dfc5d86e596c3062b4e225690acaf946f54c00063b804bf8 +botocore[crt]==1.41.6 \ + --hash=sha256:08fe47e9b306f4436f5eaf6a02cb6d55c7745d13d2d093ce5d917d3ef3d3df75 \ + --hash=sha256:963cc946e885acb941c96e7d343cb6507b479812ca22566ceb3e9410d0588de0 # via # boto3 # s3transfer @@ -1208,9 +1208,9 @@ ruamel-yaml-clib==0.2.14 \ --hash=sha256:fbc08c02e9b147a11dfcaa1ac8a83168b699863493e183f7c0c8b12850b7d259 \ --hash=sha256:ff86876889ea478b1381089e55cf9e345707b312beda4986f823e1d95e8c0f59 # via ruamel-yaml -s3transfer==0.14.0 \ - --hash=sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456 \ - --hash=sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125 +s3transfer==0.16.0 \ + --hash=sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe \ + --hash=sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920 # via boto3 six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ diff --git a/samcli/commands/local/lib/debug_context.py b/samcli/commands/local/lib/debug_context.py index d73b735d066..254248cd929 100644 --- a/samcli/commands/local/lib/debug_context.py +++ b/samcli/commands/local/lib/debug_context.py @@ -29,3 +29,47 @@ def __bool__(self): def __nonzero__(self): return self.__bool__() + + def __eq__(self, other): + """ + Compare two DebugContext instances for equality. + + Parameters + ---------- + other : DebugContext or None + Other debug context to compare with + + Returns + ------- + bool + True if both debug contexts have the same configuration + """ + if not isinstance(other, DebugContext): + return False + + return ( + self.debug_ports == other.debug_ports + and self.debugger_path == other.debugger_path + and self.debug_args == other.debug_args + and self.debug_function == other.debug_function + and self.container_env_vars == other.container_env_vars + ) + + def __hash__(self): + """ + Make DebugContext hashable so it can be used in sets/dicts. + + Returns + ------- + int + Hash value based on debug context attributes + """ + return hash( + ( + self.debug_ports, + str(self.debugger_path) if self.debugger_path else None, + self.debug_args, + self.debug_function, + tuple(sorted(self.container_env_vars.items())) if self.container_env_vars else None, + ) + ) diff --git a/samcli/commands/local/lib/local_lambda.py b/samcli/commands/local/lib/local_lambda.py index 5ed7b3613e4..9d70c1b329c 100644 --- a/samcli/commands/local/lib/local_lambda.py +++ b/samcli/commands/local/lib/local_lambda.py @@ -129,7 +129,6 @@ def invoke( FunctionNotfound When we cannot find a function with the given name """ - # Normalize function identifier from ARN if provided normalized_function_identifier = normalize_sam_function_identifier(function_identifier) @@ -271,6 +270,7 @@ def get_invoke_config(self, function: Function, override_runtime: Optional[str] env_vars=env_vars, runtime_management_config=function.runtime_management_config, code_real_path=code_real_path, + capacity_provider_configuration=function.capacity_provider_configuration, ) def _make_env_vars(self, function: Function) -> EnvironmentVariables: @@ -354,6 +354,8 @@ def _make_env_vars(self, function: Function) -> EnvironmentVariables: shell_env_values=shell_env, override_values=overrides, aws_creds=aws_creds, + capacity_provider_configuration=function.capacity_provider_configuration, + is_debugging=self.is_debugging(), ) # EnvironmentVariables is not yet annotated with type hints, disable mypy check for now. type: ignore def _get_session_creds(self) -> Optional[Credentials]: diff --git a/samcli/commands/sync/command.py b/samcli/commands/sync/command.py index 0e9abb1bf76..d69ec410693 100644 --- a/samcli/commands/sync/command.py +++ b/samcli/commands/sync/command.py @@ -403,7 +403,10 @@ def do_cli( max_wait_duration=60, ) as deploy_context: with SyncContext( - dependency_layer, build_context.build_dir, build_context.cache_dir, skip_deploy_sync + dependency_layer, + build_context.build_dir, + build_context.cache_dir, + skip_deploy_sync, ) as sync_context: if watch: watch_excludes_filter = watch_exclude or {} diff --git a/samcli/commands/sync/sync_context.py b/samcli/commands/sync/sync_context.py index 1c305babb2d..ccdd79cfae2 100644 --- a/samcli/commands/sync/sync_context.py +++ b/samcli/commands/sync/sync_context.py @@ -158,7 +158,13 @@ class SyncContext: _file_path: Path skip_deploy_sync: bool - def __init__(self, dependency_layer: bool, build_dir: str, cache_dir: str, skip_deploy_sync: bool): + def __init__( + self, + dependency_layer: bool, + build_dir: str, + cache_dir: str, + skip_deploy_sync: bool, + ): self._current_state = SyncState(dependency_layer, dict(), None) self._previous_state = None self.skip_deploy_sync = skip_deploy_sync diff --git a/samcli/lib/providers/provider.py b/samcli/lib/providers/provider.py index 166e047bee7..2d54f759f99 100644 --- a/samcli/lib/providers/provider.py +++ b/samcli/lib/providers/provider.py @@ -8,6 +8,7 @@ import os import posixpath from collections import namedtuple +from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Set, Union, cast @@ -61,6 +62,37 @@ def is_buildable(self) -> bool: return self in {FunctionBuildInfo.BuildableZip, FunctionBuildInfo.BuildableImage} +@dataclass +class CapacityProviderConfig: + """Configuration for functions with capacity provider""" + + arn: str + execution_environment_max_concurrency: int = 4 + execution_environment_memory_to_vcpu_ratio: Optional[float] = None + + @classmethod + def from_dict(cls, config: Optional[Dict]) -> Optional["CapacityProviderConfig"]: + """Create CapacityProviderConfig from CapacityProviderConfig dictionary""" + if not config: + return None + + # Handle CDK structure: CapacityProviderConfig.LambdaManagedInstancesCapacityProviderConfig + if "LambdaManagedInstancesCapacityProviderConfig" in config: + cdk_config = config["LambdaManagedInstancesCapacityProviderConfig"] + return cls( + arn=cdk_config.get("CapacityProviderArn", ""), + execution_environment_max_concurrency=cdk_config.get("PerExecutionEnvironmentMaxConcurrency", 4), + execution_environment_memory_to_vcpu_ratio=cdk_config.get("ExecutionEnvironmentMemoryGiBPerVCpu"), + ) + + # Handle SAM structure: direct properties + return cls( + arn=config.get("Arn", ""), + execution_environment_max_concurrency=config.get("PerExecutionEnvironmentMaxConcurrency", 4), + execution_environment_memory_to_vcpu_ratio=config.get("ExecutionEnvironmentMemoryToVCpuRatio"), + ) + + class Function(NamedTuple): """ Named Tuple to representing the properties of a Lambda Function @@ -116,6 +148,18 @@ class Function(NamedTuple): runtime_management_config: Optional[Dict] = None # LoggingConfig for Advanced logging logging_config: Optional[Dict] = None + # LambdaManagedInstance Capacity Provider Configuration + capacity_provider_config: Optional[Dict] = None + # PublishToLatestPublished configuration + publish_to_latest_published: Optional[bool] = None + + @property + def capacity_provider_configuration(self) -> Optional[CapacityProviderConfig]: + """Returns capacity provider configuration if this function uses a capacity provider""" + if self.capacity_provider_config: + return CapacityProviderConfig.from_dict(self.capacity_provider_config) + return None + # Function Tenancy Configuration for multi-tenant functions tenancy_config: Optional[Dict] = None diff --git a/samcli/lib/providers/sam_function_provider.py b/samcli/lib/providers/sam_function_provider.py index 2f578b2055a..782dcdff553 100644 --- a/samcli/lib/providers/sam_function_provider.py +++ b/samcli/lib/providers/sam_function_provider.py @@ -3,11 +3,13 @@ """ import logging +from collections import defaultdict from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple, cast from samtranslator.policy_template_processor.exceptions import TemplateNotFoundException +from samcli.cli.context import Context from samcli.commands._utils.template import TemplateFailedParsingException from samcli.commands.local.cli_common.user_exceptions import InvalidLayerVersionArn from samcli.lib.build.exceptions import MissingFunctionHandlerException @@ -303,8 +305,10 @@ def _extract_functions( if SamFunctionProvider._should_include_function(function, function_logical_ids_set): filtered_functions[full_path] = function + SamFunctionProvider._track_function_field_usage(filtered_functions) return filtered_functions + SamFunctionProvider._track_function_field_usage(result) return result @staticmethod @@ -522,6 +526,8 @@ def _build_function_configuration( runtime_management_config=resource_properties.get("RuntimeManagementConfig"), function_build_info=function_build_info, logging_config=resource_properties.get("LoggingConfig"), + capacity_provider_config=resource_properties.get("CapacityProviderConfig", None), + publish_to_latest_published=resource_properties.get("PublishToLatestPublished", None), tenancy_config=resource_properties.get("TenancyConfig"), ) @@ -818,6 +824,57 @@ def _metadata_has_necessary_entries_for_image_function_to_be_built(metadata: Opt """ return isinstance(metadata, dict) and bool(metadata.get("DockerContext")) + @staticmethod + def _track_function_field_usage(functions: Dict[str, Function]) -> None: + """ + Track which function fields are being used across all functions. + Stores list of used fields in context for telemetry collection. + + Parameters + ---------- + functions : Dict[str, Function] + Dictionary of functions to analyze + """ + + exclude_fields = { + "function_id", + "name", + "functionname", + "runtime", + "memory", + "timeout", + "handler", + "packagetype", + "metadata", + "inlinecode", + "architectures", + "stack_path", + "function_build_info", + "rolearn", + } + + used_fields: defaultdict[str, int] = defaultdict(int) + for function in functions.values(): + try: + for field_name in function._fields: + if field_name in exclude_fields: + continue + + value = getattr(function, field_name) + if value is not None and value not in ([], {}): + used_fields[field_name] += 1 + except (AttributeError, TypeError): + # Function object doesn't have _fields like we're expecting, probably for Mock in tests + continue + + if used_fields: + try: + ctx = Context.get_current_context() + setattr(ctx, "function_fields_used", dict(used_fields)) + except RuntimeError: + # No context available, skip telemetry + pass + class RefreshableSamFunctionProvider(SamFunctionProvider): """ diff --git a/samcli/lib/remote_invoke/lambda_invoke_executors.py b/samcli/lib/remote_invoke/lambda_invoke_executors.py index b978357071e..6c124cb17d7 100644 --- a/samcli/lib/remote_invoke/lambda_invoke_executors.py +++ b/samcli/lib/remote_invoke/lambda_invoke_executors.py @@ -100,14 +100,77 @@ def _execute_boto_call(self, boto_client_method) -> dict: f" {str(param_val_ex).replace(f'{FUNCTION_NAME}, ', '').replace(f'{PAYLOAD}, ', '')}" ) from param_val_ex except ClientError as client_ex: - if boto_utils.get_client_error_code(client_ex) == "ValidationException": + error_code = boto_utils.get_client_error_code(client_ex) + error_message = str(client_ex) + + # Check if this is a capacity provider error about tail logs + if ( + error_code == "InvalidParameterValueException" + and "Tail logs are not supported for functions" in error_message + ): + # Remove LogType and retry + self.request_parameters.pop("LogType", None) + try: + return cast(dict, boto_client_method(**self.request_parameters)) + except ClientError as retry_ex: + error_code = boto_utils.get_client_error_code(retry_ex) + error_message = str(retry_ex) + + if error_code == "ValidationException": raise InvalidResourceBotoParameterException( f"Invalid parameter value provided. {str(client_ex).replace('(ValidationException) ', '')}" ) from client_ex - elif boto_utils.get_client_error_code(client_ex) == "InvalidRequestContentException": + elif error_code == "InvalidRequestContentException": raise InvalidResourceBotoParameterException(client_ex) from client_ex raise ErrorBotoApiCallException(client_ex) from client_ex + def _process_log_result(self, log_result: str) -> RemoteInvokeLogOutput: + """ + Process the log result from Lambda invocation. + + The log_result can be in one of two formats: + 1. Traditional format: Base64-encoded string containing the last 4KB of function logs + 2. New format: Base64-encoded JSON containing logGroup and logStreamName references + + Parameters + ---------- + log_result : str + Base64-encoded log result from Lambda invocation + + Returns + ------- + RemoteInvokeLogOutput + Log output object containing either the decoded logs or a message with log reference + """ + decoded_log = base64.b64decode(log_result).decode("utf-8") + + # Try to parse as JSON to check if it's the new format + try: + log_data = json.loads(decoded_log) + + # Check if it has the expected fields for the new format + if isinstance(log_data, dict) and "logGroup" in log_data and "logStreamName" in log_data: + log_group = log_data.get("logGroup") + log_stream = log_data.get("logStreamName") + + LOG.debug("Detected new log result format with CloudWatch references") + + # Create a helpful message for the user + message = ( + f"Function logs are available in CloudWatch Logs:\n" + f"Log Group: {log_group}\n" + f"Log Stream: {log_stream}\n\n" + ) + + return RemoteInvokeLogOutput(message) + except JSONDecodeError: + # Not JSON, treat as regular log content + LOG.debug("Log result is in traditional format (raw logs)") + pass + + # Default case: return the decoded log as is + return RemoteInvokeLogOutput(decoded_log) + @abstractmethod def _execute_lambda_invoke(self, payload: str) -> RemoteInvokeIterableResponseType: raise NotImplementedError() @@ -131,7 +194,7 @@ def _execute_lambda_invoke(self, payload: str) -> RemoteInvokeIterableResponseTy if self._remote_output_format == RemoteInvokeOutputFormat.TEXT: log_result = lambda_response.get(LOG_RESULT) if log_result: - yield RemoteInvokeLogOutput(base64.b64decode(log_result).decode("utf-8")) + yield self._process_log_result(log_result) yield RemoteInvokeResponse(cast(StreamingBody, lambda_response.get(PAYLOAD)).read().decode("utf-8")) @@ -157,9 +220,7 @@ def _execute_lambda_invoke(self, payload: str) -> RemoteInvokeIterableResponseTy yield RemoteInvokeResponse(event.get(PAYLOAD_CHUNK).get(PAYLOAD).decode("utf-8")) if INVOKE_COMPLETE in event: if LOG_RESULT in event.get(INVOKE_COMPLETE): - yield RemoteInvokeLogOutput( - base64.b64decode(event.get(INVOKE_COMPLETE).get(LOG_RESULT)).decode("utf-8") - ) + yield self._process_log_result(event.get(INVOKE_COMPLETE).get(LOG_RESULT)) class DefaultConvertToJSON(RemoteInvokeRequestResponseMapper[RemoteInvokeExecutionInfo]): diff --git a/samcli/lib/sync/flows/function_sync_flow.py b/samcli/lib/sync/flows/function_sync_flow.py index bf007eb71f8..e331b6e008d 100644 --- a/samcli/lib/sync/flows/function_sync_flow.py +++ b/samcli/lib/sync/flows/function_sync_flow.py @@ -3,6 +3,8 @@ import logging import time from abc import ABC +from contextlib import ExitStack +from dataclasses import asdict, dataclass from enum import Enum from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast @@ -24,6 +26,42 @@ FUNCTION_SLEEP = 1 # used to wait for lambda function last update to be successful +@dataclass +class FunctionUpdateParams: + FunctionName: str + ZipFile: Optional[bytes] = None + S3Bucket: Optional[str] = None + S3Key: Optional[str] = None + S3ObjectVersion: Optional[str] = None + ImageUri: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + k: v for k, v in asdict(self).items() if v is not None and v != "" and not (isinstance(v, list) and not v) + } + + +class FunctionPublishTarget: + """Constants for function version publishing options""" + + LATEST_PUBLISHED = "LATEST_PUBLISHED" + + +@dataclass +class FunctionPublishVersionParams: + FunctionName: str + CodeSha256: Optional[str] = None + Description: Optional[str] = None + PublishTo: str = FunctionPublishTarget.LATEST_PUBLISHED + RevisionId: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + # Filter out None, empty strings, and empty lists + return { + k: v for k, v in asdict(self).items() if v is not None and v != "" and not (isinstance(v, list) and not v) + } + + class FunctionSyncFlow(SyncFlow, ABC): _function_identifier: str _function_provider: SamFunctionProvider @@ -81,6 +119,11 @@ def set_up(self) -> None: self._lambda_client = self._boto_client("lambda") self._lambda_waiter = self._lambda_client.get_waiter("function_updated") + @property + def auto_publish_latest_invocable(self) -> bool: + # Publish when a function has capacity provider configuration and publish_to_latest_published:true + return bool(self._function.publish_to_latest_published) and bool(self._function.capacity_provider_configuration) + @property def sync_state_identifier(self) -> str: """ @@ -128,6 +171,60 @@ def gather_dependencies(self) -> List[SyncFlow]: def _equality_keys(self): return self._function_identifier + def update_function_with_lock(self, update_params: FunctionUpdateParams) -> None: + """ + Helper function to update Lambda function code with lock management + + Parameters: + update_params: FunctionUpdateParams - Object containing function update parameters + """ + with ExitStack() as exit_stack: + if self.has_locks(): + exit_stack.enter_context(self._get_lock_chain()) + + self._lambda_client.update_function_code(**update_params.to_dict()) + + # We need to wait for the cloud side update to finish + # Otherwise even if the call is finished and lockchain is released + # It is still possible that we have a race condition on cloud updating the same function + wait_for_function_update_complete(self._lambda_client, update_params.FunctionName) + + def publish_function_version_with_lock(self, publish_params: FunctionPublishVersionParams) -> None: + """ + Publishes a new version of the Lambda function. + + Parameters + ---------- + function_name : str + The name or ARN of the Lambda function + description : Optional[str] + Description for the version + + Returns + ------- + Dict + Response from the publish_version API call + """ + LOG.debug("%sPublishing new version for function %s", self.log_prefix, publish_params.FunctionName) + + with ExitStack() as exit_stack: + if self.has_locks(): + exit_stack.enter_context(self._get_lock_chain()) + + response = self._lambda_client.publish_version(**publish_params.to_dict()) + + new_version = response["Version"] + LOG.debug( + "%sPublishing new version for function %s: %s", + self.log_prefix, + publish_params.FunctionName, + new_version, + ) + + # Wait for the publish version to complete to prevent a new publishVersion call + # before previous one completes. + wait_for_function_update_complete(self._lambda_client, publish_params.FunctionName, new_version) + class FunctionUpdateStatus(Enum): """Function update return types""" @@ -137,7 +234,9 @@ class FunctionUpdateStatus(Enum): IN_PROGRESS = "InProgress" -def wait_for_function_update_complete(lambda_client: BaseClient, physical_id: str) -> None: +def wait_for_function_update_complete( + lambda_client: BaseClient, physical_id: str, qualifier: Optional[str] = None +) -> None: """ Checks on cloud side to wait for the function update status to be complete @@ -147,11 +246,15 @@ def wait_for_function_update_complete(lambda_client: BaseClient, physical_id: st Lambda client that performs get_function API call. physical_id : str Physical identifier of the function resource + qualifier : str + A string indicating a version or an alias of the function """ - + get_function_params = ( + {"FunctionName": physical_id} if not qualifier else {"FunctionName": physical_id, "Qualifier": qualifier} + ) status = FunctionUpdateStatus.IN_PROGRESS.value while status == FunctionUpdateStatus.IN_PROGRESS.value: - response = lambda_client.get_function(FunctionName=physical_id) # type: ignore + response = lambda_client.get_function(**get_function_params) # type: ignore status = response.get("Configuration", {}).get("LastUpdateStatus", "") if status == FunctionUpdateStatus.IN_PROGRESS.value: diff --git a/samcli/lib/sync/flows/image_function_sync_flow.py b/samcli/lib/sync/flows/image_function_sync_flow.py index ff846bb4e3c..4f17854fc23 100644 --- a/samcli/lib/sync/flows/image_function_sync_flow.py +++ b/samcli/lib/sync/flows/image_function_sync_flow.py @@ -1,7 +1,6 @@ """SyncFlow for Image based Lambda Functions""" import logging -from contextlib import ExitStack from typing import TYPE_CHECKING, Any, Dict, List, Optional from docker.client import DockerClient @@ -9,7 +8,12 @@ from samcli.lib.build.app_builder import ApplicationBuilder, ApplicationBuildResult from samcli.lib.package.ecr_uploader import ECRUploader from samcli.lib.providers.provider import Stack -from samcli.lib.sync.flows.function_sync_flow import FunctionSyncFlow, wait_for_function_update_complete +from samcli.lib.sync.flows.function_sync_flow import ( + FunctionPublishTarget, + FunctionPublishVersionParams, + FunctionSyncFlow, + FunctionUpdateParams, +) from samcli.lib.sync.sync_flow import ApiCallTypes, ResourceAPICall from samcli.local.docker.utils import get_validated_container_client @@ -145,24 +149,33 @@ def sync(self) -> None: ecr_uploader = ECRUploader(self._get_docker_client(), self._get_ecr_client(), ecr_repo, None) image_uri = ecr_uploader.upload(self._image_name, self._function_identifier) - with ExitStack() as exit_stack: - if self.has_locks(): - exit_stack.enter_context(self._get_lock_chain()) + update_params = FunctionUpdateParams(FunctionName=function_physical_id, ImageUri=image_uri) - self._lambda_client.update_function_code(FunctionName=function_physical_id, ImageUri=image_uri) + self.update_function_with_lock(update_params) - # We need to wait for the cloud side update to finish - # Otherwise even if the call is finished and lockchain is released - # It is still possible that we have a race condition on cloud updating the same function - wait_for_function_update_complete(self._lambda_client, self.get_physical_id(self._function_identifier)) + # Publish the latest change to PublishTarget.LATEST_PUBLISHED to reflect in invocations. + if self.auto_publish_latest_invocable: + LOG.debug("%sPublishing new version for function %s", self.log_prefix, function_physical_id) + self.publish_function_version_with_lock( + FunctionPublishVersionParams( + FunctionName=function_physical_id, + PublishTo=FunctionPublishTarget.LATEST_PUBLISHED, + ) + ) def _get_resource_api_calls(self) -> List[ResourceAPICall]: # We need to acquire lock for both API calls since they would conflict on cloud # Any UPDATE_FUNCTION_CODE and UPDATE_FUNCTION_CONFIGURATION on the same function # Cannot take place in parallel + api_call_types = [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION] + + # Add PUBLISH_VERSION to API call types for Lambda Managed Instance (LMI) functions + if self.auto_publish_latest_invocable: + api_call_types.append(ApiCallTypes.PUBLISH_VERSION) + return [ ResourceAPICall( self._function_identifier, - [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION], + api_call_types, ) ] diff --git a/samcli/lib/sync/flows/zip_function_sync_flow.py b/samcli/lib/sync/flows/zip_function_sync_flow.py index 4c051c10746..239c0ff3b21 100644 --- a/samcli/lib/sync/flows/zip_function_sync_flow.py +++ b/samcli/lib/sync/flows/zip_function_sync_flow.py @@ -15,7 +15,12 @@ from samcli.lib.package.s3_uploader import S3Uploader from samcli.lib.package.utils import make_zip_with_lambda_permissions from samcli.lib.providers.provider import Stack -from samcli.lib.sync.flows.function_sync_flow import FunctionSyncFlow, wait_for_function_update_complete +from samcli.lib.sync.flows.function_sync_flow import ( + FunctionPublishTarget, + FunctionPublishVersionParams, + FunctionSyncFlow, + FunctionUpdateParams, +) from samcli.lib.sync.sync_flow import ApiCallTypes, ResourceAPICall from samcli.lib.utils.colors import Colored from samcli.lib.utils.hash import file_checksum @@ -142,26 +147,15 @@ def sync(self) -> None: return zip_file_size = os.path.getsize(self._zip_file) + function_name = self.get_physical_id(self._function_identifier) + if zip_file_size < MAXIMUM_FUNCTION_ZIP_SIZE: # Direct upload through Lambda API LOG.debug("%sUploading Function Directly", self.log_prefix) with open(self._zip_file, "rb") as zip_file: data = zip_file.read() - with ExitStack() as exit_stack: - if self.has_locks(): - exit_stack.enter_context(self._get_lock_chain()) - - self._lambda_client.update_function_code( - FunctionName=self.get_physical_id(self._function_identifier), ZipFile=data - ) - - # We need to wait for the cloud side update to finish - # Otherwise even if the call is finished and lockchain is released - # It is still possible that we have a race condition on cloud updating the same function - wait_for_function_update_complete( - self._lambda_client, self.get_physical_id(self._function_identifier) - ) + self.update_function_with_lock(FunctionUpdateParams(FunctionName=function_name, ZipFile=data)) else: # Upload to S3 first for oversized ZIPs @@ -177,20 +171,17 @@ def sync(self) -> None: s3_url = uploader.upload_with_dedup(self._zip_file) s3_key = s3_url[5:].split("/", 1)[1] - with ExitStack() as exit_stack: - if self.has_locks(): - exit_stack.enter_context(self._get_lock_chain()) - - self._lambda_client.update_function_code( - FunctionName=self.get_physical_id(self._function_identifier), - S3Bucket=self._deploy_context.s3_bucket, - S3Key=s3_key, + self.update_function_with_lock( + FunctionUpdateParams(FunctionName=function_name, S3Bucket=self._deploy_context.s3_bucket, S3Key=s3_key) + ) + # Publish the latest change to PublishTarget.LATEST_PUBLISHED to reflect in invocations. + if self.auto_publish_latest_invocable: + LOG.debug("%sPublishing new version for function %s", self.log_prefix, function_name) + self.publish_function_version_with_lock( + FunctionPublishVersionParams( + FunctionName=function_name, PublishTo=FunctionPublishTarget.LATEST_PUBLISHED ) - - # We need to wait for the cloud side update to finish - # Otherwise even if the call is finished and lockchain is released - # It is still possible that we have a race condition on cloud updating the same function - wait_for_function_update_complete(self._lambda_client, self.get_physical_id(self._function_identifier)) + ) if os.path.exists(self._zip_file): os.remove(self._zip_file) @@ -218,10 +209,16 @@ def _get_function_api_calls(self) -> List[ResourceAPICall]: # We need to acquire lock for both API calls since they would conflict on cloud # Any UPDATE_FUNCTION_CODE and UPDATE_FUNCTION_CONFIGURATION on the same function # Cannot take place in parallel + api_call_types = [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION] + + # Add PUBLISH_VERSION to API call types for Lambda Managed Instance (LMI) functions + if self.auto_publish_latest_invocable: + api_call_types.append(ApiCallTypes.PUBLISH_VERSION) + return [ ResourceAPICall( self._function_identifier, - [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION], + api_call_types, ) ] diff --git a/samcli/lib/sync/sync_flow.py b/samcli/lib/sync/sync_flow.py index 3f0539e2cc6..b7dc94ac3b7 100644 --- a/samcli/lib/sync/sync_flow.py +++ b/samcli/lib/sync/sync_flow.py @@ -42,6 +42,7 @@ class ApiCallTypes(Enum): BUILD = "Build" UPDATE_FUNCTION_CONFIGURATION = "UpdateFunctionConfiguration" UPDATE_FUNCTION_CODE = "UpdateFunctionCode" + PUBLISH_VERSION = "PublishVersion" class ResourceAPICall(NamedTuple): diff --git a/samcli/lib/telemetry/metric.py b/samcli/lib/telemetry/metric.py index 383ef33e16a..f573ddc4467 100644 --- a/samcli/lib/telemetry/metric.py +++ b/samcli/lib/telemetry/metric.py @@ -248,6 +248,10 @@ def _send_command_run_metrics(ctx: Context, duration: int, exit_reason: str, exi # Add admin container preference from global storage (set by container client factory) metric_specific_attributes["adminContainerPreference"] = metric._get_container_admin_preference() + # Add function fields usage if available in context + if hasattr(ctx, "function_fields_used"): + metric_specific_attributes["functionFieldsUsed"] = ctx.function_fields_used + metric.add_data("metricSpecificAttributes", metric_specific_attributes) # Metric about command's execution characteristics metric.add_data("duration", duration) diff --git a/samcli/local/docker/container.py b/samcli/local/docker/container.py index 34bd311928e..a998b90d6c1 100644 --- a/samcli/local/docker/container.py +++ b/samcli/local/docker/container.py @@ -42,11 +42,6 @@ CONTAINER_CONNECTION_TIMEOUT = float(os.environ.get("SAM_CLI_CONTAINER_CONNECTION_TIMEOUT", "20")) DEFAULT_CONTAINER_HOST_INTERFACE = "127.0.0.1" -# Keep a lock instance to access the locks for individual containers (see dict below) -CONCURRENT_CALL_MANAGER_LOCK = threading.Lock() -# Keeps locks per container (aka per function) so that one function can be invoked one at a time -CONCURRENT_CALL_MANAGER: Dict[str, threading.Lock] = {} - class ContainerResponseException(Exception): """ @@ -103,6 +98,7 @@ def __init__( extra_hosts: Optional[dict] = None, mount_symlinks: Optional[bool] = False, labels: Optional[dict] = None, + debug_options=None, ): """ Initializes the class with given configuration. This does not automatically create or run the container. @@ -126,6 +122,7 @@ def __init__( :param string host_tmp_dir: Optional. Temporary directory on the host when mounting with write permissions. :param dict extra_hosts: Optional. Dict of hostname to IP resolutions :param bool mount_symlinks: Optional. True if symlinks should be mounted in the container + :param debug_options: Optional. Debug context containing debug configuration """ self._image = image @@ -162,6 +159,10 @@ def __init__( self._mount_with_write = mount_with_write self._host_tmp_dir = host_tmp_dir self._mount_symlinks = mount_symlinks + self.debug_options = debug_options + # Container-level concurrency management + self._concurrency_semaphore: Optional[threading.Semaphore] = None # Controls concurrent Lambda executions + self._max_concurrency: int = 1 # Default to 1 for normal functions try: self.rapid_port_host = find_free_port( @@ -293,6 +294,9 @@ def create(self, context): self._logs_thread = None self._logs_thread_event = None + # Initialize concurrency control now that container is created and env vars are available + self._initialize_concurrency_control() + if self.network_id and self.network_id != "host": try: network = self.docker_client.networks.get(self.network_id) @@ -447,35 +451,99 @@ def start(self, input_data=None): raise PortAlreadyInUse(ex.explanation.decode()) from ex raise ex + def _initialize_concurrency_control(self): + """ + Initialize concurrency control for this container based on its environment variables. + In debug mode, force concurrency to 1 to avoid debugging conflicts. + Called once during container creation. + """ + if self._concurrency_semaphore is not None: + return # Already initialized + + # Check if we're in debug mode + is_not_debug_mode = not (self.debug_options and self.debug_options.debug_ports) + + max_concurrency_str = ( + self._env_vars.get("AWS_LAMBDA_MAX_CONCURRENCY", "1") if self._env_vars and is_not_debug_mode else "1" + ) + + try: + self._max_concurrency = int(max_concurrency_str) + except (ValueError, TypeError): + LOG.warning("Invalid AWS_LAMBDA_MAX_CONCURRENCY value: %s, defaulting to 1", max_concurrency_str) + self._max_concurrency = 1 + + # Create semaphore with the determined max concurrency + self._concurrency_semaphore = threading.Semaphore(self._max_concurrency) + LOG.debug("Initialized container %s with max_concurrency=%d", self.id or "unknown", self._max_concurrency) + + def get_max_concurrency(self) -> int: + """ + Get the maximum concurrency limit for this container. + + Returns + ------- + int + Maximum number of concurrent requests this container can handle + """ + # Concurrency control is initialized during create(), so this should always be available + return self._max_concurrency + @retry(exc=requests.exceptions.RequestException, exc_raise=ContainerResponseException) def wait_for_http_response(self, name, event, stdout, tenant_id=None) -> Tuple[Union[str, bytes], bool]: # TODO(sriram-mv): `aws-lambda-rie` is in a mode where the function_name is always "function" # NOTE(sriram-mv): There is a connection timeout set on the http call to `aws-lambda-rie`, however there is not # a read time out for the response received from the server. - # generate a lock key with host-port combination which is unique per function - lock_key = f"{self._container_host}-{self.rapid_port_host}" - LOG.debug("Getting lock for the key %s", lock_key) - with CONCURRENT_CALL_MANAGER_LOCK: - lock = CONCURRENT_CALL_MANAGER.get(lock_key) - if not lock: - lock = threading.Lock() - CONCURRENT_CALL_MANAGER[lock_key] = lock - LOG.debug("Waiting to retrieve the lock (%s) to start invocation", lock_key) + """ + Concurrency is handled entirely by the semaphore: + - Traditional functions: Semaphore(1) = single-threaded execution allowed + - LMI functions: Semaphore(N) = N concurrent executions allowed + """ + + # Concurrency control is initialized during create(), so semaphore should always be available + if self._concurrency_semaphore: + # Use context manager for automatic concurrency control + # This ensures that concurrent requests are properly limited and queued + # Debug logging: Show semaphore state + available_permits = self._concurrency_semaphore._value + LOG.debug( + "Container-%s (concurrency available: %d/%d) - %s for request (%s)", + self.id[:12] if self.id else "unknown", + available_permits, + self._max_concurrency, + "ALLOWED" if available_permits > 0 else "QUEUED", + event, + ) + with self._concurrency_semaphore: + return self._make_http_request(event, tenant_id) + else: + LOG.warning("Container concurrency control not initiated properly during container creation") + return self._make_http_request(event, tenant_id) + + def _make_http_request(self, event, tenant_id=None) -> Tuple[Union[str, bytes], bool]: + """ + Makes the actual HTTP request to the container. + Separated from concurrency control logic for clarity. + + Note: The Content-Type header is required when using the requests library with the new MC RIE. + While the RIE itself doesn't strictly require this header (curl works without it), + the requests library's HTTP formatting without Content-Type causes the container to hang. + This appears to be a compatibility issue between the requests library and MC RIE. + """ # Prepare headers for the request - headers = {} + headers = {"Content-Type": "application/json"} if tenant_id is not None: headers["X-Amz-Tenant-Id"] = tenant_id LOG.debug("Adding tenant-id header: %s", tenant_id) - with lock: - resp = requests.post( - self.URL.format(host=self._container_host, port=self.rapid_port_host, function_name="function"), - data=event.encode("utf-8"), - headers=headers, - timeout=(self.RAPID_CONNECTION_TIMEOUT, None), - ) + resp = requests.post( + self.URL.format(host=self._container_host, port=self.rapid_port_host, function_name="function"), + data=event.encode("utf-8"), + headers=headers, + timeout=(self.RAPID_CONNECTION_TIMEOUT, None), + ) try: # if response is an image then json.loads/dumps will throw a UnicodeDecodeError so return raw content diff --git a/samcli/local/docker/lambda_container.py b/samcli/local/docker/lambda_container.py index 761cca7f1d8..b4353b4796b 100644 --- a/samcli/local/docker/lambda_container.py +++ b/samcli/local/docker/lambda_container.py @@ -161,6 +161,7 @@ def __init__( extra_hosts=extra_hosts, mount_symlinks=mount_symlinks, labels=container_labels, + debug_options=debug_options, ) @staticmethod diff --git a/samcli/local/lambdafn/config.py b/samcli/local/lambdafn/config.py index 1d940aed2df..0a68e1cceac 100644 --- a/samcli/local/lambdafn/config.py +++ b/samcli/local/lambdafn/config.py @@ -33,6 +33,7 @@ def __init__( runtime_management_config=None, env_vars=None, code_real_path=None, + capacity_provider_configuration=None, ): """ Parameters @@ -67,6 +68,8 @@ def __init__( env_vars : str, optional Environment variables, by default None If it not provided, this class will generate one for you based on the function properties + capacity_provider_configuration : object, optional + Capacity provider configuration for lmi functions, by default None Raises ------ @@ -89,6 +92,7 @@ def __init__( self.timeout = timeout or self._DEFAULT_TIMEOUT_SECONDS self.runtime_management_config = runtime_management_config + self.capacity_provider_configuration = capacity_provider_configuration if not isinstance(self.timeout, int): try: diff --git a/samcli/local/lambdafn/env_vars.py b/samcli/local/lambdafn/env_vars.py index 87d0ae280b2..902c9e1c414 100644 --- a/samcli/local/lambdafn/env_vars.py +++ b/samcli/local/lambdafn/env_vars.py @@ -4,6 +4,9 @@ import sys from enum import IntEnum +from typing import Optional + +from samcli.lib.providers.provider import CapacityProviderConfig class Python(IntEnum): @@ -49,6 +52,8 @@ def __init__( shell_env_values=None, override_values=None, aws_creds=None, + capacity_provider_configuration: Optional[CapacityProviderConfig] = None, + is_debugging=False, ): """ Initializes this class. It takes in two sets of properties: @@ -68,6 +73,8 @@ def __init__( from ``default_values`` and ``shell_env_values``. :param dict aws_creds: Optional. Dictionary containing AWS credentials passed to the Lambda runtime through environment variables. It should contain "key", "secret", "region" and optional "sessiontoken" keys + :param CapacityProviderConfig capacity_provider_configuration: Optional. Configuration for LMI function + capacity provider """ self._function = { @@ -75,6 +82,7 @@ def __init__( "timeout": function_timeout, "handler": function_handler, "name": function_name, + "capacity_provider_configuration": capacity_provider_configuration, } self.variables = variables or {} @@ -82,6 +90,7 @@ def __init__( self.override_values = override_values or {} self.aws_creds = aws_creds or {} self.logging_config = function_logging_config or {} + self.is_debugging = is_debugging def resolve(self): """ @@ -127,6 +136,10 @@ def timeout(self): def timeout(self, value): self._function["timeout"] = value + @property + def capacity_provider_configuration(self) -> Optional[CapacityProviderConfig]: + return self._function.get("capacity_provider_configuration") + @property def memory(self): return self._function["memory"] @@ -183,6 +196,14 @@ def _get_aws_variables(self): if self.aws_creds.get("sessiontoken"): result["AWS_SESSION_TOKEN"] = self.aws_creds.get("sessiontoken") + # Set AWS_LAMBDA_MAX_CONCURRENCY based on capacity provider configuration + # If no capacity provider configuration, AWS_LAMBDA_MAX_CONCURRENCY is not set + if self.capacity_provider_configuration and not self.is_debugging: + # If capacity provider configuration exists, use its max concurrency value (default is 4) + result["AWS_LAMBDA_MAX_CONCURRENCY"] = str( + self.capacity_provider_configuration.execution_environment_max_concurrency + ) + # Add the ApplicationLogLevel as a env variable and also update the function's LogGroup name log_group = self.logging_config.get("LogGroup") if log_group: diff --git a/samcli/local/lambdafn/runtime.py b/samcli/local/lambdafn/runtime.py index 3bfca43cfdb..c413472d47a 100644 --- a/samcli/local/lambdafn/runtime.py +++ b/samcli/local/lambdafn/runtime.py @@ -214,6 +214,9 @@ def invoke( take care to invoke the function in a separate thread. Co-Routines or micro-threads might not perform well because the underlying implementation essentially blocks on a socket, which is synchronous. + Note: Concurrency control is now handled at the container level. Each container manages its own + semaphore based on AWS_LAMBDA_MAX_CONCURRENCY environment variable. + :param FunctionConfig function_config: Configuration of the function to invoke :param event: String input event passed to Lambda function :param DebugContext debug_context: Debugging context for the function (includes port, args, and path) @@ -253,6 +256,7 @@ def invoke( # Block on waiting for result from the init process on the container, below method also # starts another thread to stream logs. This method will terminate # either successfully or be killed by one of the interrupt handlers above. + # The container handles concurrency control internally via its semaphore. container.wait_for_result( full_path=function_config.full_path, event=event, @@ -428,6 +432,7 @@ def __init__(self, container_manager, image_builder, observer=None, mount_symlin """ self._function_configs = {} self._containers = {} + self._container_lock = threading.Lock() # Thread-safe container creation self._observer = observer if observer else LambdaFunctionObserver(self._on_code_change) @@ -463,44 +468,51 @@ def create( the created container """ - # reuse the cached container if it is created, and if the function configuration is not changed - exist_function_config = self._function_configs.get(function_config.full_path, None) - container = self._containers.get(function_config.full_path, None) - if exist_function_config and _require_container_reloading(exist_function_config, function_config): - LOG.info( - "Lambda Function '%s' definition has been changed in the stack template, " - "terminate the created warm container.", - function_config.full_path, - ) - self._function_configs.pop(exist_function_config.full_path, None) - if container: - self._container_manager.stop(container) - self._containers.pop(exist_function_config.full_path, None) - self._observer.unwatch(exist_function_config) - elif container and container.is_created(): - LOG.info("Reuse the created warm container for Lambda function '%s'", function_config.full_path) - return container + # Thread-safe container check and creation + with self._container_lock: + function_path = function_config.full_path + + # Filter debug_context: only apply if this function is the debug target + effective_debug_context = None + if debug_context and debug_context.debug_function == function_config.name: + effective_debug_context = debug_context + + # Check existing container and whether it needs reloading + exist_function_config = self._function_configs.get(function_path, None) + container = self._containers.get(function_path, None) - # debug_context should be used only if the function name is the one defined - # in debug-function option - if debug_context and debug_context.debug_function != function_config.name: - LOG.debug( - "Disable the debugging for Lambda Function %s, as the passed debug function is %s", - function_config.name, - debug_context.debug_function, + # Check if we need to reload the container + needs_reload = _should_reload_container( + exist_function_config, function_config, container, effective_debug_context ) - debug_context = None - self._observer.watch(function_config) - self._observer.start() + if needs_reload: + # Clean up existing container + self._function_configs.pop(function_path, None) + if container: + self._container_manager.stop(container) + self._containers.pop(function_path, None) + if exist_function_config: + self._observer.unwatch(exist_function_config) + container = None + + # Reuse existing container if available and compatible + elif container and container.is_created(): + return container + + # Create new container + self._observer.watch(function_config) + self._observer.start() + + container = super().create( + function_config, effective_debug_context, container_host, container_host_interface, extra_hosts + ) - container = super().create( - function_config, debug_context, container_host, container_host_interface, extra_hosts - ) - self._function_configs[function_config.full_path] = function_config - self._containers[function_config.full_path] = container + # Store container and config + self._function_configs[function_path] = function_config + self._containers[function_path] = container - return container + return container def _on_invoke_done(self, container): """ @@ -567,6 +579,11 @@ def clean_running_containers_and_related_resources(self): for function_name, container in self._containers.items(): LOG.debug("Terminate running warm container for Lambda Function '%s'", function_name) self._container_manager.stop(container) + + # Clear all stored state + self._containers.clear() + self._function_configs.clear() + self._clean_decompressed_paths() self._observer.stop() @@ -633,3 +650,34 @@ def _require_container_reloading(exist_function_config, function_config): or sorted(exist_function_config.layers, key=lambda x: x.full_path) != sorted(function_config.layers, key=lambda x: x.full_path) ) + + +def _should_reload_container(exist_function_config, function_config, container, effective_debug_context): + """ + Determine if a container needs to be reloaded based on configuration changes or debug context changes. + + Parameters + ---------- + exist_function_config : FunctionConfig or None + The existing function configuration, if any + function_config : FunctionConfig + The new function configuration + container : Container or None + The existing container, if any + effective_debug_context : DebugContext or None + The effective debug context for this function + + Returns + ------- + bool + True if the container needs to be reloaded, False otherwise + """ + # Check if function configuration has changed + if exist_function_config and _require_container_reloading(exist_function_config, function_config): + return True + + # Check if debug context has changed + if container and container.debug_options != effective_debug_context: + return True + + return False diff --git a/samcli/local/rapid/aws-lambda-rie-arm64 b/samcli/local/rapid/aws-lambda-rie-arm64 index 66429f5dd02..22d3e3812b4 100755 Binary files a/samcli/local/rapid/aws-lambda-rie-arm64 and b/samcli/local/rapid/aws-lambda-rie-arm64 differ diff --git a/samcli/local/rapid/aws-lambda-rie-x86_64 b/samcli/local/rapid/aws-lambda-rie-x86_64 index 100370d7277..7c3eb28d7b1 100755 Binary files a/samcli/local/rapid/aws-lambda-rie-x86_64 and b/samcli/local/rapid/aws-lambda-rie-x86_64 differ diff --git a/tests/integration/deploy/test_deploy_command.py b/tests/integration/deploy/test_deploy_command.py index 3172e67e8ad..3fd75c7d5e9 100644 --- a/tests/integration/deploy/test_deploy_command.py +++ b/tests/integration/deploy/test_deploy_command.py @@ -1749,3 +1749,36 @@ def test_deploy_with_valid_config_capabilities_string(self, template_file, confi deploy_process_execute = self.run_command(deploy_command_list) self.assertEqual(deploy_process_execute.process.returncode, 0) + + @skipIf(RUNNING_ON_CI, "Skip LMI tests when running on canary") + def test_deploy_lmi_function(self): + """Test deployment of LMI (Lambda Managed Infrastructure) functions with capacity providers.""" + # Validate LMI environment variables are set + lmi_subnet_id = os.environ.get("LMI_SUBNET_ID") + lmi_security_group_id = os.environ.get("LMI_SECURITY_GROUP_ID") + lmi_operator_role_arn = os.environ.get("LMI_OPERATOR_ROLE_ARN") + + self.assertTrue(lmi_subnet_id, "LMI_SUBNET_ID environment variable must be set") + self.assertTrue(lmi_security_group_id, "LMI_SECURITY_GROUP_ID environment variable must be set") + self.assertTrue(lmi_operator_role_arn, "LMI_OPERATOR_ROLE_ARN environment variable must be set") + + template_path = self.test_data_path.joinpath("lmi_function", "template.yaml") + stack_name = self._method_to_stack_name(self.id()) + self.stacks.append({"name": stack_name}) + + # Deploy LMI function with capacity providers + deploy_command_list = self.get_deploy_command_list( + template_file=template_path, + stack_name=stack_name, + capabilities="CAPABILITY_IAM", + s3_prefix=self.s3_prefix, + s3_bucket=self.s3_bucket.name, + force_upload=True, + parameter_overrides=f"SubnetId={lmi_subnet_id} SecurityGroupId={lmi_security_group_id} OperatorRoleArn={lmi_operator_role_arn}", + no_execute_changeset=False, + tags="integ=true lmi=yes", + confirm_changeset=False, + ) + + deploy_process_execute = self.run_command(deploy_command_list) + self.assertEqual(deploy_process_execute.process.returncode, 0) diff --git a/tests/integration/init/test_init_command.py b/tests/integration/init/test_init_command.py index 13efa024c2d..6c11c36e456 100644 --- a/tests/integration/init/test_init_command.py +++ b/tests/integration/init/test_init_command.py @@ -1142,7 +1142,7 @@ def test_graceful_exit(self): # Send SIGINT signal process_execute.send_signal(signal.CTRL_C_EVENT if platform.system().lower() == "windows" else signal.SIGINT) - (_, stderr) = process_execute.communicate(timeout=10) + (_, stderr) = process_execute.communicate(timeout=20) # Process should exit gracefully with an exit code of 1. self.assertEqual(process_execute.returncode, 1) self.assertIn("Aborted!", stderr.decode("utf-8")) diff --git a/tests/integration/local/invoke/test_integrations_cli.py b/tests/integration/local/invoke/test_integrations_cli.py index 3e4c63f64fc..2971ab00773 100644 --- a/tests/integration/local/invoke/test_integrations_cli.py +++ b/tests/integration/local/invoke/test_integrations_cli.py @@ -25,6 +25,35 @@ TIMEOUT = 300 +@parameterized_class( + ("template",), + [ + (Path("template-capacity-provider.yml"),), + ], +) +class TestSamPythonHelloWorldCapacityProviderIntegration(IntegrationCliIntegBase): + @pytest.mark.flaky(reruns=3) + def test_invoke_capacity_provider_function(self): + command_list = InvokeIntegBase.get_command_list( + "HelloWorldCapacityProviderFunction", template_path=self.template_path, event_path=self.event_path + ) + + process = Popen(command_list, stdout=PIPE) + try: + stdout, _ = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + self.assertEqual(process.returncode, 0) + process_stdout = stdout.strip() + response = json.loads(process_stdout.decode("utf-8")) + self.assertEqual(response["statusCode"], 200) + body = json.loads(response["body"]) + self.assertEqual(body["message"], "Hello world capacity provider") + self.assertIn("max_concurrency", body) + + @parameterized_class( ("template",), [ @@ -1277,7 +1306,7 @@ def test_function_exception(self): stack_trace_lines = [ "[ERROR] Exception: Lambda is raising an exception", "Traceback (most recent call last):", - '\xa0\xa0File "/var/task/main.py", line 65, in raise_exception', + '\xa0\xa0File "/var/task/main.py", line 81, in raise_exception', '\xa0\xa0\xa0\xa0raise Exception("Lambda is raising an exception")', ] diff --git a/tests/integration/local/start_api/test_start_api.py b/tests/integration/local/start_api/test_start_api.py index 18a784380a4..e53b2540126 100644 --- a/tests/integration/local/start_api/test_start_api.py +++ b/tests/integration/local/start_api/test_start_api.py @@ -154,6 +154,38 @@ def test_large_input_request_http10(self): self.assertEqual(response.raw.version, 11) +@parameterized_class( + ("template_path",), + [ + ("/testdata/start_api/template.yaml",), + ("/testdata/start_api/nested-templates/template-parent.yaml",), + ], +) +class TestServiceHTTP10LMI(StartApiIntegBaseClass): + """ + Testing general requirements around the Service that powers `sam local start-api` with LMI + """ + + def setUp(self): + self.url = "http://127.0.0.1:{}".format(self.port) + self.current_svn_str = HTTPConnection._http_vsn_str + HTTPConnection._http_vsn_str = "HTTP/1.0" + + def tearDown(self) -> None: + HTTPConnection._http_vsn_str = self.current_svn_str # type: ignore + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_capacity_provider_api_http10(self): + response = requests.get(self.url + "/capacity-provider/anyandall", timeout=300) + + self.assertEqual(response.status_code, 200) + response_json = response.json() + self.assertEqual(response_json["message"], "Hello world capacity provider") + self.assertIn("max_concurrency", response_json) + self.assertEqual(response.raw.version, 11) + + @parameterized_class( ("template_path", "container_mode", "endpoint"), [ @@ -456,6 +488,17 @@ def test_calling_proxy_endpoint(self): self.assertEqual(response.json(), {"hello": "world"}) self.assertEqual(response.raw.version, 11) + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_capacity_provider_http_api(self): + response = requests.get(self.url + "/capacity-provider/anyandall", timeout=300) + + self.assertEqual(response.status_code, 200) + response_json = response.json() + self.assertEqual(response_json["message"], "Hello world capacity provider") + self.assertIn("max_concurrency", response_json) + self.assertEqual(response.raw.version, 11) + @pytest.mark.flaky(reruns=3) @pytest.mark.timeout(timeout=600, method="thread") def test_get_call_with_path_setup_with_any_implicit_api(self): diff --git a/tests/integration/local/start_lambda/test_start_lambda.py b/tests/integration/local/start_lambda/test_start_lambda.py index 5b4eedd098c..1cfd59f4e27 100644 --- a/tests/integration/local/start_lambda/test_start_lambda.py +++ b/tests/integration/local/start_lambda/test_start_lambda.py @@ -1876,3 +1876,36 @@ def test_invalid_function_names_error(self): # Should match sam local invoke error pattern: "function not found. Possible options in your template:" for expected in ["not found", "InvalidFunction1, InvalidFunction2", "Possible options in your template"]: self.assertIn(expected, error_output) + + +class TestCapacityProviderFunction(StartLambdaIntegBaseClass): + """Test capacity provider functionality with a dedicated template""" + + template_path = "/testdata/invoke/template-capacity-provider.yml" + + def setUp(self): + self.url = "http://127.0.0.1:{}".format(self.port) + self.lambda_client = boto3.client( + "lambda", + endpoint_url=self.url, + region_name="us-east-1", + use_ssl=False, + verify=False, + config=Config(signature_version=UNSIGNED, read_timeout=120, retries={"max_attempts": 0}), + ) + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=300, method="thread") + def test_invoke_capacity_provider_function(self): + """Test invoking a function with capacity provider configuration""" + response = self.lambda_client.invoke( + FunctionName="HelloWorldCapacityProviderFunction", + Payload='{"key1": "value1", "key2": "value2", "key3": "value3"}', + ) + self.assertEqual(response.get("StatusCode"), 200) + self.assertIsNone(response.get("FunctionError")) + response_data = json.loads(response.get("Payload").read().decode("utf-8")) + self.assertEqual(response_data["statusCode"], 200) + body = json.loads(response_data["body"]) + self.assertEqual(body["message"], "Hello world capacity provider") + self.assertIn("max_concurrency", body) diff --git a/tests/integration/pipeline/test_init_command.py b/tests/integration/pipeline/test_init_command.py index 0b1da915434..c41b675dbd1 100644 --- a/tests/integration/pipeline/test_init_command.py +++ b/tests/integration/pipeline/test_init_command.py @@ -104,7 +104,8 @@ def test_failed_when_generated_file_already_exist_not_override(self): self.assertEqual(expected.read(), output.read()) # also check the Jenkinsfile is not overridden - self.assertEqual("", open("Jenkinsfile", "r").read()) + with open("Jenkinsfile", "r") as f: + self.assertEqual("", f.read()) def test_custom_template_with_manifest(self): generated_file = Path("weather") diff --git a/tests/integration/remote/invoke/remote_invoke_integ_base.py b/tests/integration/remote/invoke/remote_invoke_integ_base.py index 05ee9bdf564..52647458e18 100644 --- a/tests/integration/remote/invoke/remote_invoke_integ_base.py +++ b/tests/integration/remote/invoke/remote_invoke_integ_base.py @@ -33,13 +33,20 @@ def tearDownClass(cls): def get_integ_dir(): return Path(__file__).resolve().parents[2] - @staticmethod - def remote_invoke_deploy_stack(stack_name, template_path): + @classmethod + def remote_invoke_deploy_stack(cls, stack_name, template_path): + parameter_overrides = None + if hasattr(cls, "parameter_overrides") and cls.parameter_overrides: + # Convert dict to space-separated key=value pairs + param_list = [f"{key}={value}" for key, value in cls.parameter_overrides.items()] + parameter_overrides = " ".join(param_list) + deploy_cmd = DeployIntegBase.get_deploy_command_list( stack_name=stack_name, template_file=template_path, resolve_s3=True, capabilities_list=["CAPABILITY_IAM", "CAPABILITY_AUTO_EXPAND"], + parameter_overrides=parameter_overrides, ) run_command(deploy_cmd) diff --git a/tests/integration/remote/invoke/test_lambda_invoke_response_stream.py b/tests/integration/remote/invoke/test_lambda_invoke_response_stream.py index ae07fc0a97a..0d058a89039 100644 --- a/tests/integration/remote/invoke/test_lambda_invoke_response_stream.py +++ b/tests/integration/remote/invoke/test_lambda_invoke_response_stream.py @@ -1,8 +1,10 @@ import json +import os import uuid +from unittest import skipIf from tests.integration.remote.invoke.remote_invoke_integ_base import RemoteInvokeIntegBase -from tests.testing_utils import run_command +from tests.testing_utils import run_command, RUNNING_ON_CI from pathlib import Path import pytest @@ -101,3 +103,41 @@ def test_invoke_different_boto_options(self): response_event_stream = remote_invoke_result_stdout["EventStream"] self.assertEqual(response_event_stream, expected_output_result) + + +@skipIf(RUNNING_ON_CI, "Skip LMI tests when running on canary") +class TestInvokeResponseStreamingCapacityProvider(RemoteInvokeIntegBase): + template = Path("template-lambda-response-capacity-provider-stream-fn.yaml") + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.stack_name = f"{cls.__name__}-{uuid.uuid4().hex}" + # LMI is Lambda Managed Instance + assert os.environ.get("LMI_SUBNET_ID"), "LMI_SUBNET_ID environment variable must be set" + assert os.environ.get("LMI_SECURITY_GROUP_ID"), "LMI_SECURITY_GROUP_ID environment variable must be set" + assert os.environ.get("LMI_OPERATOR_ROLE_ARN"), "LMI_OPERATOR_ROLE_ARN environment variable must be set" + + # Read LMI infrastructure from environment variables + cls.parameter_overrides = { + "SubnetId": os.environ.get("LMI_SUBNET_ID", ""), + "SecurityGroupId": os.environ.get("LMI_SECURITY_GROUP_ID", ""), + "OperatorRoleArn": os.environ.get("LMI_OPERATOR_ROLE_ARN", ""), + } + cls.create_resources_and_boto_clients() + + def test_invoke_with_only_event_provided(self): + command_list = self.get_command_list( + stack_name=self.stack_name, + resource_id="NodeStreamingFunction", + event='{"key1": "Hello", "key2": "serverless", "key3": "world"}', + ) + + remote_invoke_result = run_command(command_list) + + expected_streamed_responses = "LambdaFunctionStreamingResponsesTestDone!" + remote_invoke_result = run_command(command_list) + + self.assertEqual(0, remote_invoke_result.process.returncode) + remote_invoke_result_stdout = remote_invoke_result.stdout.strip().decode() + self.assertIn(expected_streamed_responses, remote_invoke_result_stdout) diff --git a/tests/integration/remote/invoke/test_remote_invoke.py b/tests/integration/remote/invoke/test_remote_invoke.py index 3db28474079..74a54d4e17b 100644 --- a/tests/integration/remote/invoke/test_remote_invoke.py +++ b/tests/integration/remote/invoke/test_remote_invoke.py @@ -3,12 +3,13 @@ import base64 import time import math +import os from parameterized import parameterized -from unittest import skip +from unittest import skipIf from tests.integration.remote.invoke.remote_invoke_integ_base import RemoteInvokeIntegBase -from tests.testing_utils import run_command +from tests.testing_utils import run_command, RUNNING_ON_CI from pathlib import Path import pytest @@ -16,6 +17,44 @@ SQS_WAIT_TIME_SECONDS = 20 +@skipIf(RUNNING_ON_CI, "Skip LMI tests when running on canary") +class TestingInvokeWithCapacityProvider(RemoteInvokeIntegBase): + template = Path("template-single-lambda-capacity-provider.yaml") + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.stack_name = f"{cls.__name__}-{uuid.uuid4().hex}" + # LMI is Lambda Managed Instance + assert os.environ.get("LMI_SUBNET_ID"), "LMI_SUBNET_ID environment variable must be set" + assert os.environ.get("LMI_SECURITY_GROUP_ID"), "LMI_SECURITY_GROUP_ID environment variable must be set" + assert os.environ.get("LMI_OPERATOR_ROLE_ARN"), "LMI_OPERATOR_ROLE_ARN environment variable must be set" + + # Read LMI infrastructure from environment variables + cls.parameter_overrides = { + "SubnetId": os.environ.get("LMI_SUBNET_ID", ""), + "SecurityGroupId": os.environ.get("LMI_SECURITY_GROUP_ID", ""), + "OperatorRoleArn": os.environ.get("LMI_OPERATOR_ROLE_ARN", ""), + } + cls.create_resources_and_boto_clients() + + def test_invoke_with_event_provided(self): + command_list = self.get_command_list( + stack_name=self.stack_name, + event='{"key1": "Hello", "key2": "World", "key3": "CapacityProvider"}', + ) + + remote_invoke_result = run_command(command_list) + + self.assertEqual(0, remote_invoke_result.process.returncode) + remote_invoke_result_stdout = json.loads(remote_invoke_result.stdout.strip().decode()) + expected_response = { + "statusCode": 200, + "body": '{"message": "Hello World CapacityProvider", "max_concurrency": "10"}', + } + self.assertEqual(remote_invoke_result_stdout, expected_response) + + @pytest.mark.xdist_group(name="sam_remote_invoke_single_lambda_resource") class TestSingleLambdaInvoke(RemoteInvokeIntegBase): template = Path("template-single-lambda.yaml") diff --git a/tests/integration/sync/test_sync_code.py b/tests/integration/sync/test_sync_code.py index c462339336e..5b62cc8ed04 100644 --- a/tests/integration/sync/test_sync_code.py +++ b/tests/integration/sync/test_sync_code.py @@ -11,8 +11,10 @@ from typing import Dict from unittest import skipIf + import pytest import boto3 +from botocore.exceptions import ClientError from parameterized import parameterized_class, parameterized from samcli.lib.utils.resources import ( @@ -792,3 +794,79 @@ def test_skip_build(self): lambda_response = json.loads(self._get_lambda_response(lambda_function)) self.assertIn("message", lambda_response) self.assertEqual(lambda_response.get("message"), "hello mars") + + +@skipIf(RUNNING_ON_CI, "Skip LMI tests when running on canary") +@parameterized_class( + [ + {"dependency_layer": True}, + {"dependency_layer": False}, + ] +) +@pytest.mark.timeout(300) +class TestSyncCodeLMI(TestSyncCodeBase): + """Test sync code operations with Lambda Managed Instance functions""" + + template = "template-lmi-with-publish.yaml" + folder = "code" + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + # Validate LMI environment variables are set + assert os.environ.get("LMI_SUBNET_ID"), "LMI_SUBNET_ID environment variable must be set" + assert os.environ.get("LMI_SECURITY_GROUP_ID"), "LMI_SECURITY_GROUP_ID environment variable must be set" + assert os.environ.get("LMI_OPERATOR_ROLE_ARN"), "LMI_OPERATOR_ROLE_ARN environment variable must be set" + + # Read LMI infrastructure from environment variables + cls.parameter_overrides = { + "SubnetId": os.environ.get("LMI_SUBNET_ID", ""), + "SecurityGroupId": os.environ.get("LMI_SECURITY_GROUP_ID", ""), + "OperatorRoleArn": os.environ.get("LMI_OPERATOR_ROLE_ARN", ""), + } + + def test_sync_lmi_with_publish_to_latest_published(self): + """Test sync with PublishToLatestPublished=true""" + + # Get function name from stack + self.stack_resources = self._get_stacks(TestSyncCodeBase.stack_name) + lambda_functions = self.stack_resources.get(AWS_LAMBDA_FUNCTION) + function_name = next((f for f in lambda_functions if "LMIFunction" in f), None) + + # Verify function exist + self.assertIsNotNone(function_name, "LMIFunction not found in stack resources") + + # Verify initial function state + lambda_response_body = self._get_lambda_response(function_name) + body = json.loads(lambda_response_body) + self.assertEqual(body.get("message", ""), "Hello from LMI function") + + # Modify function code using shutil.rmtree and shutil.copytree (before/after pattern) + shutil.rmtree(self.test_data_path.joinpath(self.folder, "before", "lmi_function")) + shutil.copytree( + self.test_data_path.joinpath(self.folder, "after", "lmi_function"), + self.test_data_path.joinpath(self.folder, "before", "lmi_function"), + ) + + # Run sam sync --code + sync_command_list = self.get_sync_command_list( + template_file=TestSyncCodeBase.template_path, + code=True, + watch=False, + resource_list=["AWS::Serverless::Function"], + dependency_layer=self.dependency_layer, + stack_name=TestSyncCodeBase.stack_name, + image_repository=self.ecr_repo_name, + s3_prefix=self.s3_prefix, + kms_key_id=self.kms_key, + tags="integ=true clarity=yes foo_bar=baz", + debug=True, + ) + sync_process_execute = run_command_with_input(sync_command_list, "y\n".encode(), cwd=self.test_data_path) + self.assertEqual(sync_process_execute.process.returncode, 0) + + # Verify updated function code + lambda_response_body = self._get_lambda_response(function_name) + body = json.loads(lambda_response_body) + self.assertEqual(body["message"], "Hello from LMI function - UPDATE 1") + self.assertEqual(body["version"], "v1") diff --git a/tests/integration/sync/test_sync_infra.py b/tests/integration/sync/test_sync_infra.py index da4d304b8df..7cadaf503df 100644 --- a/tests/integration/sync/test_sync_infra.py +++ b/tests/integration/sync/test_sync_infra.py @@ -585,3 +585,109 @@ def test_sync_infra_esbuild(self, template_file): for lambda_function in lambda_functions: lambda_response = json.loads(self._get_lambda_response(lambda_function)) self.assertEqual(lambda_response.get("message"), "hello world") + + +@skipIf(RUNNING_ON_CI, "Skip LMI tests when running on canary") +@parameterized_class( + [ + {"dependency_layer": True}, + {"dependency_layer": False}, + ] +) +@pytest.mark.timeout(600) # 10 minutes timeout for LMI operations (VPC and capacity provider setup) +class TestSyncInfraLMI(SyncIntegBase): + """Test sync infra operations with Lambda Managed Instance functions""" + + dependency_layer = None + parameter_overrides: Dict[str, str] = {} + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + # Validate LMI environment variables are set + assert os.environ.get("LMI_SUBNET_ID"), "LMI_SUBNET_ID environment variable must be set" + assert os.environ.get("LMI_SECURITY_GROUP_ID"), "LMI_SECURITY_GROUP_ID environment variable must be set" + assert os.environ.get("LMI_OPERATOR_ROLE_ARN"), "LMI_OPERATOR_ROLE_ARN environment variable must be set" + + # Read LMI infrastructure from environment variables + cls.parameter_overrides = { + "SubnetId": os.environ.get("LMI_SUBNET_ID", ""), + "SecurityGroupId": os.environ.get("LMI_SECURITY_GROUP_ID", ""), + "OperatorRoleArn": os.environ.get("LMI_OPERATOR_ROLE_ARN", ""), + } + + def test_sync_infra_lmi_with_capacity_provider_config(self): + """Test infra sync with LMI function and capacity provider configuration changes""" + + template_path = str(self.test_data_path.joinpath("infra/before/Python/lmi_function/template-lmi.yaml")) + stack_name = self._method_to_stack_name(self.id()) + self.stacks.append({"name": stack_name}) + + # Run initial infra sync + sync_command_list = self.get_sync_command_list( + template_file=template_path, + code=False, + watch=False, + dependency_layer=self.dependency_layer, + stack_name=stack_name, + parameter_overrides=self.parameter_overrides, + image_repository=self.ecr_repo_name, + s3_prefix=self.s3_prefix, + kms_key_id=self.kms_key, + tags="integ=true clarity=yes foo_bar=baz", + ) + + sync_process_execute = run_command_with_input(sync_command_list, "y\n".encode(), cwd=self.test_data_path) + self.assertEqual(sync_process_execute.process.returncode, 0) + self.assertIn("Stack creation succeeded. Sync infra completed.", str(sync_process_execute.stderr)) + + # Verify initial deployment + self.stack_resources = self._get_stacks(stack_name) + lambda_functions = self.stack_resources.get(AWS_LAMBDA_FUNCTION) + function_name = next((f for f in lambda_functions if "LMIFunction" in f), None) + self.assertIsNotNone(function_name, "LMIFunction not found in stack resources") + + # Count initial resources + initial_resource_count = sum(len(resources) for resources in self.stack_resources.values()) + + # Verify initial function response + lambda_response_body = self._get_lambda_response(function_name) + body = json.loads(lambda_response_body) + self.assertEqual(body["message"], "Hello from LMI function") + self.assertEqual(body["max_concurrency"], "10") + + # Update template with different capacity provider configuration + template_path = str(self.test_data_path.joinpath("infra/after/Python/lmi_function/template-lmi.yaml")) + + # Run infra sync with updated template + sync_command_list = self.get_sync_command_list( + template_file=template_path, + code=False, + watch=False, + dependency_layer=self.dependency_layer, + stack_name=stack_name, + parameter_overrides=self.parameter_overrides, + image_repository=self.ecr_repo_name, + s3_prefix=self.s3_prefix, + kms_key_id=self.kms_key, + tags="integ=true clarity=yes foo_bar=baz", + ) + + sync_process_execute = run_command_with_input(sync_command_list, "y\n".encode(), cwd=self.test_data_path) + self.assertEqual(sync_process_execute.process.returncode, 0) + self.assertIn("Stack update succeeded. Sync infra completed.", str(sync_process_execute.stderr)) + + # Get updated stack resources + updated_stack_resources = self._get_stacks(stack_name) + updated_resource_count = sum(len(resources) for resources in updated_stack_resources.values()) + + # Verify a new capacity provider was added (resource count should increase) + self.assertGreater( + updated_resource_count, + initial_resource_count, + "Expected resource count to increase after adding second capacity provider", + ) + + lambda_response_body = self._get_lambda_response(function_name) + body = json.loads(lambda_response_body) + self.assertEqual(body["max_concurrency"], "20") # Updated from 10 to 20 diff --git a/tests/integration/sync/test_sync_watch.py b/tests/integration/sync/test_sync_watch.py index 2a7c7eaebfd..918fce0faf8 100644 --- a/tests/integration/sync/test_sync_watch.py +++ b/tests/integration/sync/test_sync_watch.py @@ -861,3 +861,89 @@ def test_sync_watch_code_excludes(self): lambda_response = json.loads(self._get_lambda_response(lambda_function)) self.assertNotIn("extra_message", lambda_response) self.assertEqual(lambda_response.get("message"), "hello world") + + +@skipIf(RUNNING_ON_CI, "Skip LMI tests when running on canary") +@parameterized_class([{"dependency_layer": True}, {"dependency_layer": False}]) +@pytest.mark.timeout(600) # 10 minutes timeout for LMI operations +class TestSyncWatchCodeLMI(TestSyncWatchBase): + """Test sync watch code operations with Lambda Managed Instance functions""" + + dependency_layer = None + parameter_overrides: Dict[str, str] = {} + + @classmethod + def setUpClass(cls) -> None: + # Validate LMI environment variables are set + assert os.environ.get("LMI_SUBNET_ID"), "LMI_SUBNET_ID environment variable must be set" + assert os.environ.get("LMI_SECURITY_GROUP_ID"), "LMI_SECURITY_GROUP_ID environment variable must be set" + assert os.environ.get("LMI_OPERATOR_ROLE_ARN"), "LMI_OPERATOR_ROLE_ARN environment variable must be set" + + cls.template_before = str(Path("code", "before", "template-lmi-with-publish.yaml")) + cls.parameter_overrides = { + "SubnetId": os.environ.get("LMI_SUBNET_ID", ""), + "SecurityGroupId": os.environ.get("LMI_SECURITY_GROUP_ID", ""), + "OperatorRoleArn": os.environ.get("LMI_OPERATOR_ROLE_ARN", ""), + } + super().setUpClass() + + def run_initial_infra_validation(self) -> None: + """Runs initial infra validation after deployment is completed""" + self.stack_resources = self._get_stacks(self.stack_name) + lambda_functions = self.stack_resources.get(AWS_LAMBDA_FUNCTION) + function_name = next((f for f in lambda_functions if "LMIFunction" in f), None) + self.assertIsNotNone(function_name, "LMIFunction not found in stack resources") + + # Verify initial function response + lambda_response_body = self._get_lambda_response(function_name) + body = json.loads(lambda_response_body) + self.assertEqual(body["message"], "Hello from LMI function") + + def test_sync_watch_code_lmi(self): + """Test that sam sync --watch handles concurrent file changes and publishes to $LATEST.PUBLISHED correctly""" + self.stack_resources = self._get_stacks(self.stack_name) + lambda_functions = self.stack_resources.get(AWS_LAMBDA_FUNCTION) + function_name = next((f for f in lambda_functions if "LMIFunction" in f), None) + + # Simulate rapid file edits by a developer - make 3 changes with 30 second intervals + # This tests how sync handles multiple file change events while previous syncs may still be in progress + + # First update - version 1 + self.update_file( + self.test_data_path.joinpath("code/after/lmi_function/app_v1.py"), + self.test_data_path.joinpath("code/before/lmi_function/app.py"), + ) + + # Wait for first sync to complete + read_until_string(self.watch_process, "Finished syncing Lambda Function LMIFunction.\x1b[0m\n", timeout=300) + + # Wait 30 seconds, then make second update + time.sleep(30) + + # Second update - version 2 + self.update_file( + self.test_data_path.joinpath("code/after/lmi_function/app_v2.py"), + self.test_data_path.joinpath("code/before/lmi_function/app.py"), + ) + + # Wait for second sync to complete + read_until_string(self.watch_process, "Finished syncing Lambda Function LMIFunction.\x1b[0m\n", timeout=300) + + # Wait 30 seconds, then make third update + time.sleep(30) + + # Third update - version 3 (final) + self.update_file( + self.test_data_path.joinpath("code/after/lmi_function/app_v3.py"), + self.test_data_path.joinpath("code/before/lmi_function/app.py"), + ) + + # Wait for final sync to complete + read_until_string(self.watch_process, "Finished syncing Lambda Function LMIFunction.\x1b[0m\n", timeout=300) + + # Verify final function response reflects the last update (version 3) + # This validates that sync correctly handled all file changes and published the final version + lambda_response_body = self._get_lambda_response(function_name) + body = json.loads(lambda_response_body) + self.assertEqual(body["message"], "Hello from LMI function - UPDATE 3") + self.assertEqual(body["version"], "v3") diff --git a/tests/integration/telemetry/test_experimental_metric.py b/tests/integration/telemetry/test_experimental_metric.py index 195c5c2ddc5..d3dcf96be92 100644 --- a/tests/integration/telemetry/test_experimental_metric.py +++ b/tests/integration/telemetry/test_experimental_metric.py @@ -194,6 +194,7 @@ def test_must_send_cdk_project_type_metrics(self): "projectName": ANY, "initialCommit": ANY, "adminContainerPreference": ANY, + "functionFieldsUsed": ANY, }, "duration": ANY, "exitReason": ANY, diff --git a/tests/integration/testdata/deploy/lmi_function/app.py b/tests/integration/testdata/deploy/lmi_function/app.py new file mode 100644 index 00000000000..c2344571755 --- /dev/null +++ b/tests/integration/testdata/deploy/lmi_function/app.py @@ -0,0 +1,21 @@ +import json +import time +import os + + +def lambda_handler_one(event, context): + + return { + "statusCode": 200, + "body": json.dumps({ + "message": "hello world from function one update 1.", + }), + } + +def lambda_handler_two(event, context): + return { + "statusCode": 200, + "body": json.dumps({ + "message": "hello world from function 2", + }), + } \ No newline at end of file diff --git a/tests/integration/testdata/invoke/main.py b/tests/integration/testdata/invoke/main.py index 26be18d4e46..3dc6c00e981 100644 --- a/tests/integration/testdata/invoke/main.py +++ b/tests/integration/testdata/invoke/main.py @@ -2,6 +2,7 @@ import os import sys import subprocess +import json print ("Loading function") @@ -15,6 +16,21 @@ def handler(event, context): return "Hello world" +def cap_pro_handler(event, context): + print ("value1 = " + event["key1"]) + print ("value2 = " + event["key2"]) + print ("value3 = " + event["key3"]) + + max_concurrency = os.environ.get('AWS_LAMBDA_MAX_CONCURRENCY', 'not set') + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello world capacity provider', + 'max_concurrency': max_concurrency + }) + } + def intrinsics_handler(event, context): return os.environ.get("ApplicationId") diff --git a/tests/integration/testdata/invoke/template-capacity-provider.yml b/tests/integration/testdata/invoke/template-capacity-provider.yml new file mode 100644 index 00000000000..fb1c2dac916 --- /dev/null +++ b/tests/integration/testdata/invoke/template-capacity-provider.yml @@ -0,0 +1,38 @@ +AWSTemplateFormatVersion : '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Template for testing capacity provider functionality. + +Parameters: + SecurityGroupId: + Type: String + Default: "sg-12345" + + SubnetId: + Type: String + Default: "subnet-12345" + +Resources: + HelloWorldCapacityProvider: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - !Ref SecurityGroupId + SubnetIds: + - !Ref SubnetId + InstanceRequirements: + Architectures: + - x86_64 + ScalingConfig: + MaxVCpuCount: 100 + + HelloWorldCapacityProviderFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.cap_pro_handler + Runtime: python3.13 + CodeUri: . + Timeout: 600 + CapacityProviderConfig: + Arn: !GetAtt HelloWorldCapacityProvider.Arn + PerExecutionEnvironmentMaxConcurrency: 10 diff --git a/tests/integration/testdata/invoke/template.yml b/tests/integration/testdata/invoke/template.yml index 47331e0675d..fc7a6b39e52 100644 --- a/tests/integration/testdata/invoke/template.yml +++ b/tests/integration/testdata/invoke/template.yml @@ -243,5 +243,6 @@ Resources: Runtime: python3.9 Layers: - !Sub "arn:aws:lambda:us-east-1:553035198032:layer:git-lambda2:${LayerVersion}" - + + # UTF-8 Test 😐 diff --git a/tests/integration/testdata/list/test_stack_creation_template.yaml b/tests/integration/testdata/list/test_stack_creation_template.yaml index 2b1317072dd..054eeb47a2d 100644 --- a/tests/integration/testdata/list/test_stack_creation_template.yaml +++ b/tests/integration/testdata/list/test_stack_creation_template.yaml @@ -24,6 +24,7 @@ Resources: Path: /hello Method: get + Outputs: # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function # Find out more about other implicit resources you can reference within SAM diff --git a/tests/integration/testdata/package/lmi_function/app.py b/tests/integration/testdata/package/lmi_function/app.py new file mode 100644 index 00000000000..414de00eb04 --- /dev/null +++ b/tests/integration/testdata/package/lmi_function/app.py @@ -0,0 +1,21 @@ +import json +import time +import os + + +def lambda_handler_one(event, context): + return { + "statusCode": 200, + "body": json.dumps({ + "message": "hello world from function one update 1.", + }), + } + + +def lambda_handler_two(event, context): + return { + "statusCode": 200, + "body": json.dumps({ + "message": "hello world from function 2", + }), + } \ No newline at end of file diff --git a/tests/integration/testdata/package/lmi_function/template.yaml b/tests/integration/testdata/package/lmi_function/template.yaml new file mode 100644 index 00000000000..bd5b917c8b5 --- /dev/null +++ b/tests/integration/testdata/package/lmi_function/template.yaml @@ -0,0 +1,97 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: LMI integration test template - BEFORE version + +Parameters: + SubnetId: + Type: String + Description: Subnet ID from infrastructure stack + SecurityGroupId: + Type: String + Description: Security Group ID from infrastructure stack + OperatorRoleArn: + Type: String + Description: Operator Role ARN from infrastructure stack + Team: + Type: String + Default: lambda-tooling-team + MemoryGiBPerVCpu: + Type: Number + Default: 2 + MaxConcurrency: + Type: Number + Default: 10 + +Globals: + CapacityProvider: + VpcConfig: + SecurityGroupIds: + - !Ref SecurityGroupId + SubnetIds: + - !Ref SubnetId + ScalingConfig: + AverageCPUUtilization: 50 + Tags: + Team: !Ref Team + ManagedBy: SAM + InstanceRequirements: + ExcludedTypes: + - t2.micro + - t2.small + + Function: + CodeUri: . + Runtime: python3.13 + MemorySize: 10240 + CapacityProviderConfig: + ExecutionEnvironmentMemoryGiBPerVCpu: !Ref MemoryGiBPerVCpu + PerExecutionEnvironmentMaxConcurrency: !Ref MaxConcurrency + FunctionScalingConfig: + MinExecutionEnvironments: 3 + MaxExecutionEnvironments: 12 + Tags: + Team: !Ref Team + ManagedBy: SAM + PublishToLatestPublished: True + +Resources: + # Global Capacity Provider with reduced properties since globals handle common ones + LMICapacityProviderOne: + Type: AWS::Serverless::CapacityProvider + Properties: + OperatorRole: !Ref OperatorRoleArn + InstanceRequirements: + Architectures: + - x86_64 + ScalingConfig: + MaxVCpuCount: 100 + + LMICapacityProviderTwo: + Type: AWS::Serverless::CapacityProvider + Properties: + OperatorRole: !Ref OperatorRoleArn + InstanceRequirements: + Architectures: + - arm64 + ScalingConfig: + + LMIFunctionOne: + Type: AWS::Serverless::Function + Properties: + Architectures: + - x86_64 + Handler: app.lambda_handler_one + CapacityProviderConfig: + Arn: !GetAtt LMICapacityProviderOne.Arn + + LMIFunctionTwo: + Type: AWS::Serverless::Function + Properties: + CapacityProviderConfig: + Arn: !GetAtt LMICapacityProviderTwo.Arn + ExecutionEnvironmentMemoryGiBPerVCpu: 4 + Handler: app.lambda_handler_two + FunctionScalingConfig: + MinExecutionEnvironments: 1 + Architectures: + - arm64 \ No newline at end of file diff --git a/tests/integration/testdata/remote_invoke/lambda-fns/main.py b/tests/integration/testdata/remote_invoke/lambda-fns/main.py index 9cb1e3c4036..7f981f35f89 100644 --- a/tests/integration/testdata/remote_invoke/lambda-fns/main.py +++ b/tests/integration/testdata/remote_invoke/lambda-fns/main.py @@ -1,5 +1,6 @@ import os import logging +import json LOG = logging.getLogger(__name__) @@ -12,6 +13,22 @@ def default_handler(event, context): "message": f'{event["key1"]} {event["key3"]}' } +def cap_pro_handler(event, context): + print("value1 = " + event["key1"]) + print("value2 = " + event["key2"]) + print("value3 = " + event["key3"]) + + max_concurrency = os.environ.get('AWS_LAMBDA_MAX_CONCURRENCY', 'not set') + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': f'{event["key1"]} {event["key2"]} {event["key3"]}', + 'max_concurrency': max_concurrency + }) + } + + def custom_env_var_echo_handler(event, context): return os.environ.get("CustomEnvVar") diff --git a/tests/integration/testdata/remote_invoke/template-lambda-response-capacity-provider-stream-fn.yaml b/tests/integration/testdata/remote_invoke/template-lambda-response-capacity-provider-stream-fn.yaml new file mode 100644 index 00000000000..ef2d17cf7df --- /dev/null +++ b/tests/integration/testdata/remote_invoke/template-lambda-response-capacity-provider-stream-fn.yaml @@ -0,0 +1,41 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 + +Description: > + Testing application for lambda functions with response streaming + +Parameters: + SubnetId: + Type: String + Description: Subnet ID from infrastructure stack + SecurityGroupId: + Type: String + Description: Security Group ID from infrastructure stack + OperatorRoleArn: + Type: String + Description: Operator Role ARN from infrastructure stack + + +Resources: + NodeStreamingCapacityProvider: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - !Ref SecurityGroupId + SubnetIds: + - !Ref SubnetId + OperatorRole: !Ref OperatorRoleArn + + NodeStreamingFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: ./lambda-fns/src/ + Handler: index.handler + Runtime: nodejs22.x + Timeout: 10 + FunctionUrlConfig: + AuthType: AWS_IAM + InvokeMode: RESPONSE_STREAM + CapacityProviderConfig: + Arn: !GetAtt NodeStreamingCapacityProvider.Arn \ No newline at end of file diff --git a/tests/integration/testdata/remote_invoke/template-single-lambda-capacity-provider.yaml b/tests/integration/testdata/remote_invoke/template-single-lambda-capacity-provider.yaml new file mode 100644 index 00000000000..8975f82e8b3 --- /dev/null +++ b/tests/integration/testdata/remote_invoke/template-single-lambda-capacity-provider.yaml @@ -0,0 +1,36 @@ +AWSTemplateFormatVersion : '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: A hello world application with single lambda function with capacity provider. + + +Parameters: + SubnetId: + Type: String + Description: Subnet ID from infrastructure stack + SecurityGroupId: + Type: String + Description: Security Group ID from infrastructure stack + OperatorRoleArn: + Type: String + Description: Operator Role ARN from infrastructure stack + +Resources: + HelloWorldCapacityProvider: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - !Ref SecurityGroupId + SubnetIds: + - !Ref SubnetId + OperatorRole: !Ref OperatorRoleArn + + HelloWorldCapacityProviderFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.cap_pro_handler + Runtime: python3.13 + CodeUri: ./lambda-fns + CapacityProviderConfig: + Arn: !GetAtt HelloWorldCapacityProvider.Arn + PerExecutionEnvironmentMaxConcurrency: 10 diff --git a/tests/integration/testdata/remote_test_event/template-multiple-lambdas.yaml b/tests/integration/testdata/remote_test_event/template-multiple-lambdas.yaml index 3417d3d099e..856bc16f343 100644 --- a/tests/integration/testdata/remote_test_event/template-multiple-lambdas.yaml +++ b/tests/integration/testdata/remote_test_event/template-multiple-lambdas.yaml @@ -22,4 +22,11 @@ Resources: Properties: Handler: main.default_handler Runtime: python3.9 - CodeUri: ./lambda-fns \ No newline at end of file + CodeUri: ./lambda-fns + + HelloWorldFunction4: + Type: AWS::Serverless::Function + Properties: + Handler: main.default_handler + Runtime: python3.9 + CodeUri: ./lambda-fns diff --git a/tests/integration/testdata/start_api/main.py b/tests/integration/testdata/start_api/main.py index 40617bd8167..2e3f8f028a9 100644 --- a/tests/integration/testdata/start_api/main.py +++ b/tests/integration/testdata/start_api/main.py @@ -1,6 +1,7 @@ import json import sys import time +import os GIF_IMAGE_BASE64 = "R0lGODdhAQABAJEAAAAAAP///wAAAAAAACH5BAkAAAIALAAAAAABAAEAAAICRAEAOw==" @@ -8,6 +9,16 @@ def handler(event, context): return {"statusCode": 200, "body": json.dumps({"hello": "world"})} +def cap_pro_handler(event, context): + max_concurrency = os.environ.get('AWS_LAMBDA_MAX_CONCURRENCY', 'not set') + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello world capacity provider', + 'max_concurrency': max_concurrency + }) + } def operation_name_handler(event, context): return {"statusCode": 200, "body": json.dumps({"operation_name": event["requestContext"].get("operationName", "")})} diff --git a/tests/integration/testdata/start_api/template-http-api.yaml b/tests/integration/testdata/start_api/template-http-api.yaml index 6dca43b6f2f..0981ccfa50e 100644 --- a/tests/integration/testdata/start_api/template-http-api.yaml +++ b/tests/integration/testdata/start_api/template-http-api.yaml @@ -37,6 +37,51 @@ Resources: Properties: Method: GET Path: /proxypath/{proxy+} + + HelloWorldCapacityProvider: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - !Ref SecurityGroupId + SubnetIds: + - !Ref SubnetId + InstanceRequirements: + Architectures: + - x86_64 + ScalingConfig: + MaxVCpuCount: 100 + + HelloWorldCapacityProviderFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.cap_pro_handler + Runtime: python3.9 + FunctionName: customname + CodeUri: . + Timeout: 600 + Events: + IdBasePath: + Type: HttpApi + Properties: + Method: POST + Path: /capacity-provider/id + + PathWithAnyMethod: + Type: HttpApi + Properties: + Method: ANY + Path: /capacity-provider/anyandall + + ProxyPath: + Type: HttpApi + Properties: + Method: GET + Path: /capacity-provider/proxypath/{proxy+} + + CapacityProviderConfig: + Arn: !GetAtt HelloWorldCapacityProvider.Arn + PerExecutionEnvironmentMaxConcurrency: 10 EchoEventFunction: Type: AWS::Serverless::Function diff --git a/tests/integration/testdata/start_api/template.yaml b/tests/integration/testdata/start_api/template.yaml index bd26c21c0b3..e18cf2cfe1f 100644 --- a/tests/integration/testdata/start_api/template.yaml +++ b/tests/integration/testdata/start_api/template.yaml @@ -37,6 +37,51 @@ Resources: Properties: Method: GET Path: /proxypath/{proxy+} + + HelloWorldCapacityProvider: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - !Ref SecurityGroupId + SubnetIds: + - !Ref SubnetId + InstanceRequirements: + Architectures: + - x86_64 + ScalingConfig: + MaxVCpuCount: 100 + + HelloWorldCapacityProviderFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.cap_pro_handler + Runtime: python3.9 + FunctionName: customname + CodeUri: . + Timeout: 600 + Events: + IdBasePath: + Type: Api + Properties: + Method: POST + Path: /capacity-provider/id + + PathWithAnyMethod: + Type: Api + Properties: + Method: ANY + Path: /capacity-provider/anyandall + + ProxyPath: + Type: Api + Properties: + Method: GET + Path: /capacity-provider/proxypath/{proxy+} + + CapacityProviderConfig: + Arn: !GetAtt HelloWorldCapacityProvider.Arn + PerExecutionEnvironmentMaxConcurrency: 10 EchoEventFunction: Type: AWS::Serverless::Function diff --git a/tests/integration/testdata/sync/code/after/lmi_function/app.py b/tests/integration/testdata/sync/code/after/lmi_function/app.py new file mode 100644 index 00000000000..1ac03ffbb43 --- /dev/null +++ b/tests/integration/testdata/sync/code/after/lmi_function/app.py @@ -0,0 +1,24 @@ +""" +Lambda functions for LMI integration tests - Version 1. +""" +import json +import os + + +def env_var_handler(event, context): + """ + Returns environment variables, particularly AWS_LAMBDA_MAX_CONCURRENCY. + Used for testing capacity provider configuration and version publishing. + + This is version 1 with a code change. + """ + max_concurrency = os.environ.get('AWS_LAMBDA_MAX_CONCURRENCY', 'not set') + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello from LMI function - UPDATE 1', + 'max_concurrency': max_concurrency, + 'version': 'v1' + }) + } diff --git a/tests/integration/testdata/sync/code/after/lmi_function/app_v1.py b/tests/integration/testdata/sync/code/after/lmi_function/app_v1.py new file mode 100644 index 00000000000..1ac03ffbb43 --- /dev/null +++ b/tests/integration/testdata/sync/code/after/lmi_function/app_v1.py @@ -0,0 +1,24 @@ +""" +Lambda functions for LMI integration tests - Version 1. +""" +import json +import os + + +def env_var_handler(event, context): + """ + Returns environment variables, particularly AWS_LAMBDA_MAX_CONCURRENCY. + Used for testing capacity provider configuration and version publishing. + + This is version 1 with a code change. + """ + max_concurrency = os.environ.get('AWS_LAMBDA_MAX_CONCURRENCY', 'not set') + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello from LMI function - UPDATE 1', + 'max_concurrency': max_concurrency, + 'version': 'v1' + }) + } diff --git a/tests/integration/testdata/sync/code/after/lmi_function/app_v2.py b/tests/integration/testdata/sync/code/after/lmi_function/app_v2.py new file mode 100644 index 00000000000..4a4ce04f6d7 --- /dev/null +++ b/tests/integration/testdata/sync/code/after/lmi_function/app_v2.py @@ -0,0 +1,24 @@ +""" +Lambda functions for LMI integration tests - Version 2. +""" +import json +import os + + +def env_var_handler(event, context): + """ + Returns environment variables, particularly AWS_LAMBDA_MAX_CONCURRENCY. + Used for testing capacity provider configuration and version publishing. + + This is version 2 with a code change. + """ + max_concurrency = os.environ.get('AWS_LAMBDA_MAX_CONCURRENCY', 'not set') + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello from LMI function - UPDATE 2', + 'max_concurrency': max_concurrency, + 'version': 'v2' + }) + } diff --git a/tests/integration/testdata/sync/code/after/lmi_function/app_v3.py b/tests/integration/testdata/sync/code/after/lmi_function/app_v3.py new file mode 100644 index 00000000000..3317f04dda4 --- /dev/null +++ b/tests/integration/testdata/sync/code/after/lmi_function/app_v3.py @@ -0,0 +1,24 @@ +""" +Lambda functions for LMI integration tests - Version 3 (Final). +""" +import json +import os + + +def env_var_handler(event, context): + """ + Returns environment variables, particularly AWS_LAMBDA_MAX_CONCURRENCY. + Used for testing capacity provider configuration and version publishing. + + This is version 3 - the final version. + """ + max_concurrency = os.environ.get('AWS_LAMBDA_MAX_CONCURRENCY', 'not set') + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello from LMI function - UPDATE 3', + 'max_concurrency': max_concurrency, + 'version': 'v3' + }) + } diff --git a/tests/integration/testdata/sync/code/before/lmi_function/app.py b/tests/integration/testdata/sync/code/before/lmi_function/app.py new file mode 100644 index 00000000000..df8773db66b --- /dev/null +++ b/tests/integration/testdata/sync/code/before/lmi_function/app.py @@ -0,0 +1,21 @@ +""" +Lambda functions for LMI integration tests. +""" +import json +import os + + +def env_var_handler(event, context): + """ + Returns environment variables, particularly AWS_LAMBDA_MAX_CONCURRENCY. + Used for testing capacity provider configuration and version publishing. + """ + max_concurrency = os.environ.get('AWS_LAMBDA_MAX_CONCURRENCY', 'not set') + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello from LMI function', + 'max_concurrency': max_concurrency, + }) + } diff --git a/tests/integration/testdata/sync/code/before/template-lmi-with-publish.yaml b/tests/integration/testdata/sync/code/before/template-lmi-with-publish.yaml new file mode 100644 index 00000000000..1daa241f6ed --- /dev/null +++ b/tests/integration/testdata/sync/code/before/template-lmi-with-publish.yaml @@ -0,0 +1,46 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Lambda Managed Instance function with configurable PublishToLatestPublished + +Parameters: + SubnetId: + Type: String + Description: Subnet ID from infrastructure stack + SecurityGroupId: + Type: String + Description: Security Group ID from infrastructure stack + OperatorRoleArn: + Type: String + Description: Operator Role ARN from infrastructure stack + +Resources: + LMICapacityProvider: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - !Ref SecurityGroupId + SubnetIds: + - !Ref SubnetId + OperatorRole: !Ref OperatorRoleArn + + LMIFunction: + Type: AWS::Serverless::Function + Properties: + Handler: app.env_var_handler + Runtime: python3.13 + CodeUri: lmi_function/ + CapacityProviderConfig: + Arn: !GetAtt LMICapacityProvider.Arn + PerExecutionEnvironmentMaxConcurrency: 10 + PublishToLatestPublished: true + Timeout: 30 + +Outputs: + LMIFunctionName: + Description: Name of the LMI function + Value: !Ref LMIFunction + + LMIFunctionArn: + Description: ARN of the LMI function + Value: !GetAtt LMIFunction.Arn diff --git a/tests/integration/testdata/sync/infra/after/Python/lmi_function/app.py b/tests/integration/testdata/sync/infra/after/Python/lmi_function/app.py new file mode 100644 index 00000000000..e5b611b29c0 --- /dev/null +++ b/tests/integration/testdata/sync/infra/after/Python/lmi_function/app.py @@ -0,0 +1,24 @@ +""" +Lambda functions for LMI integration tests - AFTER version. +""" +import json +import os + + +def env_var_handler(event, context): + """ + Returns environment variables, particularly AWS_LAMBDA_MAX_CONCURRENCY. + Used for testing capacity provider configuration and version publishing. + + This is the AFTER version with a code change. + """ + max_concurrency = os.environ.get('AWS_LAMBDA_MAX_CONCURRENCY', 'not set') + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello from LMI function - UPDATED', + 'max_concurrency': max_concurrency, + 'version': 'after' + }) + } diff --git a/tests/integration/testdata/sync/infra/after/Python/lmi_function/template-lmi.yaml b/tests/integration/testdata/sync/infra/after/Python/lmi_function/template-lmi.yaml new file mode 100644 index 00000000000..2c2190213c5 --- /dev/null +++ b/tests/integration/testdata/sync/infra/after/Python/lmi_function/template-lmi.yaml @@ -0,0 +1,56 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: LMI integration test template - AFTER version (updated MaxConcurrency) + +Parameters: + SubnetId: + Type: String + Description: Subnet ID from infrastructure stack + SecurityGroupId: + Type: String + Description: Security Group ID from infrastructure stack + OperatorRoleArn: + Type: String + Description: Operator Role ARN from infrastructure stack + +Resources: + LMICapacityProvider: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - !Ref SecurityGroupId + SubnetIds: + - !Ref SubnetId + OperatorRole: !Ref OperatorRoleArn + + LMICapacityProviderTwo: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - !Ref SecurityGroupId + SubnetIds: + - !Ref SubnetId + OperatorRole: !Ref OperatorRoleArn + + LMIFunction: + Type: AWS::Serverless::Function + Properties: + Handler: app.env_var_handler + Runtime: python3.13 + CodeUri: ./ + CapacityProviderConfig: + Arn: !GetAtt LMICapacityProviderTwo.Arn + PerExecutionEnvironmentMaxConcurrency: 20 # Updated from 10 to 20 + PublishToLatestPublished: true + Timeout: 30 + +Outputs: + LMIFunctionName: + Description: Name of the LMI function + Value: !Ref LMIFunction + + LMIFunctionArn: + Description: ARN of the LMI function + Value: !GetAtt LMIFunction.Arn diff --git a/tests/integration/testdata/sync/infra/before/Python/lmi_function/app.py b/tests/integration/testdata/sync/infra/before/Python/lmi_function/app.py new file mode 100644 index 00000000000..df8773db66b --- /dev/null +++ b/tests/integration/testdata/sync/infra/before/Python/lmi_function/app.py @@ -0,0 +1,21 @@ +""" +Lambda functions for LMI integration tests. +""" +import json +import os + + +def env_var_handler(event, context): + """ + Returns environment variables, particularly AWS_LAMBDA_MAX_CONCURRENCY. + Used for testing capacity provider configuration and version publishing. + """ + max_concurrency = os.environ.get('AWS_LAMBDA_MAX_CONCURRENCY', 'not set') + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello from LMI function', + 'max_concurrency': max_concurrency, + }) + } diff --git a/tests/integration/testdata/sync/infra/before/Python/lmi_function/template-lmi.yaml b/tests/integration/testdata/sync/infra/before/Python/lmi_function/template-lmi.yaml new file mode 100644 index 00000000000..9eca035b1aa --- /dev/null +++ b/tests/integration/testdata/sync/infra/before/Python/lmi_function/template-lmi.yaml @@ -0,0 +1,46 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: LMI integration test template - BEFORE version + +Parameters: + SubnetId: + Type: String + Description: Subnet ID from infrastructure stack + SecurityGroupId: + Type: String + Description: Security Group ID from infrastructure stack + OperatorRoleArn: + Type: String + Description: Operator Role ARN from infrastructure stack + +Resources: + LMICapacityProvider: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - !Ref SecurityGroupId + SubnetIds: + - !Ref SubnetId + OperatorRole: !Ref OperatorRoleArn + + LMIFunction: + Type: AWS::Serverless::Function + Properties: + Handler: app.env_var_handler + Runtime: python3.13 + CodeUri: ./ + CapacityProviderConfig: + Arn: !GetAtt LMICapacityProvider.Arn + PerExecutionEnvironmentMaxConcurrency: 10 + PublishToLatestPublished: true + Timeout: 30 + +Outputs: + LMIFunctionName: + Description: Name of the LMI function + Value: !Ref LMIFunction + + LMIFunctionArn: + Description: ARN of the LMI function + Value: !GetAtt LMIFunction.Arn diff --git a/tests/unit/commands/local/lib/test_debug_context.py b/tests/unit/commands/local/lib/test_debug_context.py index 9eb0a4d5def..53bb105d58b 100644 --- a/tests/unit/commands/local/lib/test_debug_context.py +++ b/tests/unit/commands/local/lib/test_debug_context.py @@ -74,3 +74,51 @@ def test_nonzero_falsy(self, port, debug_path, debug_ars): debug_context = DebugContext(port, debug_path, debug_ars) self.assertFalse(debug_context.__nonzero__()) + + def test_equality_same_contexts(self): + """Test that identical debug contexts are equal""" + context1 = DebugContext( + debug_ports=(5858,), + debugger_path="/usr/bin/debugger", + debug_args="--wait", + debug_function="MyFunction", + container_env_vars={"VAR1": "value1"}, + ) + context2 = DebugContext( + debug_ports=(5858,), + debugger_path="/usr/bin/debugger", + debug_args="--wait", + debug_function="MyFunction", + container_env_vars={"VAR1": "value1"}, + ) + + self.assertEqual(context1, context2) + self.assertEqual(hash(context1), hash(context2)) + + def test_equality_different_contexts(self): + """Test that different debug contexts are not equal""" + context1 = DebugContext(debug_ports=(5858,), debug_function="MyFunction") + context2 = DebugContext(debug_ports=(9229,), debug_function="MyFunction") # Different port + context3 = DebugContext(debug_ports=(5858,), debug_function="OtherFunction") # Different function + + self.assertNotEqual(context1, context2) + self.assertNotEqual(context1, context3) + self.assertNotEqual(context2, context3) + + def test_equality_with_none(self): + """Test that debug context is not equal to None or other types""" + context = DebugContext(debug_ports=(5858,)) + + self.assertNotEqual(context, None) + self.assertNotEqual(context, "string") + self.assertNotEqual(context, 123) + self.assertNotEqual(context, {}) + + def test_equality_none_values(self): + """Test equality with None values in attributes""" + context1 = DebugContext(debug_ports=None, debug_function=None) + context2 = DebugContext(debug_ports=None, debug_function=None) + context3 = DebugContext(debug_ports=(5858,), debug_function=None) + + self.assertEqual(context1, context2) + self.assertNotEqual(context1, context3) diff --git a/tests/unit/commands/local/lib/test_local_lambda.py b/tests/unit/commands/local/lib/test_local_lambda.py index a862fead274..14df52874e2 100644 --- a/tests/unit/commands/local/lib/test_local_lambda.py +++ b/tests/unit/commands/local/lib/test_local_lambda.py @@ -12,7 +12,7 @@ from samcli.lib.utils.architecture import X86_64, ARM64 from samcli.commands.local.lib.local_lambda import RUST_LOCAL_INVOKE_DISCLAIMER, LocalLambdaRunner -from samcli.lib.providers.provider import Function, FunctionBuildInfo +from samcli.lib.providers.provider import Function, FunctionBuildInfo, CapacityProviderConfig from samcli.lib.utils.colors import Colored from samcli.lib.utils.packagetype import ZIP, IMAGE from samcli.local.docker.container import ContainerResponseException, ContainerConnectionTimeoutException @@ -261,6 +261,10 @@ def test_must_work_with_override_values( runtime_management_config=None, function_build_info=FunctionBuildInfo.BuildableZip, logging_config={"LogFormat": "JSON"}, + capacity_provider_config={ + "Arn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name", + "PerExecutionEnvironmentMaxConcurrency": 8, + }, ) self.local_lambda.env_vars_values = env_vars_values @@ -277,6 +281,11 @@ def test_must_work_with_override_values( shell_env_values=os_environ, override_values=expected_override_value, aws_creds=self.aws_creds, + capacity_provider_configuration=CapacityProviderConfig( + arn="arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name", + execution_environment_max_concurrency=8, + ), + is_debugging=False, ) @parameterized.expand( @@ -376,6 +385,8 @@ def test_must_work_with_invalid_environment_variable(self, environment_variable, shell_env_values=os_environ, override_values={}, aws_creds=self.aws_creds, + capacity_provider_configuration=None, + is_debugging=False, ) @@ -466,6 +477,7 @@ def test_must_work(self, FunctionConfigMock, is_debugging_mock, resolve_code_pat full_path=function.full_path, runtime_management_config=function.runtime_management_config, code_real_path=codepath, + capacity_provider_configuration=function.capacity_provider_configuration, ) resolve_code_path_patch.assert_called_with(self.real_path, function.codeuri) @@ -534,6 +546,7 @@ def test_must_work_with_runtime_option(self, FunctionConfigMock, is_debugging_mo full_path=function.full_path, runtime_management_config=function.runtime_management_config, code_real_path=codepath, + capacity_provider_configuration=function.capacity_provider_configuration, ) resolve_code_path_patch.assert_called_with(self.real_path, function.codeuri) @@ -604,6 +617,7 @@ def test_timeout_set_to_max_during_debugging( full_path=function.full_path, runtime_management_config=function.runtime_management_config, code_real_path=codepath, + capacity_provider_configuration=function.capacity_provider_configuration, ) resolve_code_path_patch.assert_called_with(self.real_path, "codeuri") diff --git a/tests/unit/commands/local/lib/test_provider.py b/tests/unit/commands/local/lib/test_provider.py index 908f060241f..0e48c1d9e91 100644 --- a/tests/unit/commands/local/lib/test_provider.py +++ b/tests/unit/commands/local/lib/test_provider.py @@ -297,6 +297,9 @@ def setUp(self) -> None: FunctionBuildInfo.BuildableZip, "stackpath", None, + None, + None, + None, ) @parameterized.expand( @@ -331,6 +334,53 @@ def test_skip_build_is_false_if_skip_build_metadata_flag_is_true(self): self.function = self.function._replace(metadata={"SkipBuild": True}) self.assertTrue(self.function.skip_build) + def test_capacity_provider_configuration_is_none_if_capacity_provider_config_is_none(self): + self.function = self.function._replace(capacity_provider_config=None) + self.assertIsNone(self.function.capacity_provider_configuration) + + def test_capacity_provider_configuration_returns_config_object(self): + capacity_provider_config = { + "Arn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name", + "PerExecutionEnvironmentMaxConcurrency": 8, + "ExecutionEnvironmentMemoryToVCpuRatio": 2.0, + } + self.function = self.function._replace(capacity_provider_config=capacity_provider_config) + config = self.function.capacity_provider_configuration + self.assertIsNotNone(config) + self.assertEqual( + config.arn, "arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name" + ) + self.assertEqual(config.execution_environment_max_concurrency, 8) + self.assertEqual(config.execution_environment_memory_to_vcpu_ratio, 2.0) + + def test_capacity_provider_configuration_handles_cdk_structure(self): + # CDK synthesizes nested structure + capacity_provider_config = { + "LambdaManagedInstancesCapacityProviderConfig": { + "CapacityProviderArn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name", + "PerExecutionEnvironmentMaxConcurrency": 6, + "ExecutionEnvironmentMemoryGiBPerVCpu": 4.0, + } + } + self.function = self.function._replace(capacity_provider_config=capacity_provider_config) + config = self.function.capacity_provider_configuration + self.assertIsNotNone(config) + self.assertEqual( + config.arn, "arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name" + ) + self.assertEqual(config.execution_environment_max_concurrency, 6) + self.assertEqual(config.execution_environment_memory_to_vcpu_ratio, 4.0) + + def test_publish_to_latest_published_default_is_none(self): + self.assertIsNone(self.function.publish_to_latest_published) + + def test_publish_to_latest_published_can_be_set(self): + self.function = self.function._replace(publish_to_latest_published=True) + self.assertTrue(self.function.publish_to_latest_published) + + self.function = self.function._replace(publish_to_latest_published=False) + self.assertFalse(self.function.publish_to_latest_published) + class TestLayerVersion(TestCase): @parameterized.expand( diff --git a/tests/unit/commands/local/lib/test_sam_function_provider.py b/tests/unit/commands/local/lib/test_sam_function_provider.py index 0173817b6f7..b4699fe4bc6 100644 --- a/tests/unit/commands/local/lib/test_sam_function_provider.py +++ b/tests/unit/commands/local/lib/test_sam_function_provider.py @@ -39,6 +39,17 @@ class TestSamFunctionProviderEndToEnd(TestCase): "Handler": "index.handler", }, }, + # TODO: Enable test after schema update + # "SamFuncWithCapacityProvider": { + # "Type": "AWS::Serverless::Function", + # "Properties": { + # "FunctionName": "SamFuncWithCapacityProvider", + # "CodeUri": "/usr/foo/bar", + # "Runtime": "nodejs4.3", + # "Handler": "index.handler", + # "capacity_provider_config": {"ClusterArn": 'arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name'}, + # }, + # }, "SamFuncWithInlineCode": { "Type": "AWS::Serverless::Function", "Properties": { @@ -373,6 +384,35 @@ def setUp(self): function_build_info=FunctionBuildInfo.BuildableZip, ), ), + # TODO: Enable test after schema update + # ( + # "SamFuncWithCapacityProvider", + # Function( + # function_id="SamFunctions", + # name="SamFunctions", + # functionname="SamFunc1", + # runtime="nodejs4.3", + # handler="index.handler", + # codeuri="/usr/foo/bar", + # memory=None, + # timeout=None, + # environment=None, + # rolearn=None, + # layers=[], + # events=None, + # metadata={"SamResourceId": "SamFunctions"}, + # inlinecode=None, + # imageuri=None, + # imageconfig=None, + # packagetype=ZIP, + # codesign_config_arn=None, + # architectures=None, + # function_url_config=None, + # stack_path="", + # function_build_info=FunctionBuildInfo.BuildableZip, + # capacity_provider_config={"ClusterArn": 'arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name'}, + # ), + # ), ("SamFunc2", None), # codeuri is a s3 location, ignored ("SamFunc3", None), # codeuri is a s3 location, ignored ( @@ -1090,6 +1130,7 @@ def test_get_all_must_return_all_functions(self): result = {f.full_path for f in self.provider.get_all()} expected = { "SamFunctions", + # "SamFuncWithCapacityProvider", TODO: Enable after schema update "SamFuncWithImage1", "SamFuncWithImage2", "SamFuncWithImage4", @@ -2767,3 +2808,204 @@ def test_extract_functions_deduplicates_filter(self, convert_mock, resources_moc self.assertEqual(len(result), 2) self.assertEqual(sum(1 for key in result.keys() if key == "Function1"), 1) + + +class TestSamFunctionProvider_track_function_field_usage(TestCase): + @patch("samcli.lib.providers.sam_function_provider.Context.get_current_context") + def test_tracks_non_excluded_fields(self, mock_get_context): + """Test that non-excluded fields with values are tracked""" + mock_ctx = Mock() + mock_get_context.return_value = mock_ctx + + func = Function( + function_id="TestFunc", + name="TestFunc", + functionname="test-func", + runtime="python3.12", + memory=512, + timeout=30, + handler="app.handler", + imageuri=None, + packagetype="Zip", + imageconfig=None, + codeuri="./src", + environment={"Variables": {"KEY": "value"}}, + rolearn=None, + layers=[LayerVersion("arn:aws:lambda:us-east-1:123456789012:layer:test:1", None)], + events=[{"Type": "Api"}], + metadata=None, + inlinecode=None, + codesign_config_arn=None, + architectures=["x86_64"], + function_url_config=None, + function_build_info=FunctionBuildInfo.BuildableZip, + stack_path="", + runtime_management_config=None, + logging_config={"LogFormat": "JSON"}, + capacity_provider_config={"ClusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/test"}, + ) + + functions = {"TestFunc": func} + SamFunctionProvider._track_function_field_usage(functions) + + tracked_fields = mock_ctx.function_fields_used + self.assertIn("logging_config", tracked_fields) + self.assertIn("capacity_provider_config", tracked_fields) + self.assertIn("environment", tracked_fields) + self.assertIn("layers", tracked_fields) + self.assertIn("events", tracked_fields) + + @patch("samcli.lib.providers.sam_function_provider.Context.get_current_context") + def test_excludes_fields(self, mock_get_context): + """Test that some fields are excluded from tracking""" + mock_ctx = Mock() + mock_get_context.return_value = mock_ctx + + func = Function( + function_id="TestFunc", + name="TestFunc", + functionname="test-func", + runtime="python3.12", + memory=None, + timeout=None, + handler="app.handler", + imageuri="123456789012.dkr.ecr.us-east-1.amazonaws.com/my-image:latest", + packagetype="Image", + imageconfig=None, + codeuri="./src", + environment={"Variables": {"KEY": "value"}}, + rolearn="arn:aws:iam::123456789012:role/MyRole", + layers=[LayerVersion("arn:aws:lambda:us-east-1:123456789012:layer:test:1", None)], + events=[{"Type": "Api"}], + metadata={"DockerContext": "./"}, + inlinecode=None, + codesign_config_arn=None, + architectures=None, + function_url_config=None, + function_build_info=FunctionBuildInfo.BuildableZip, + ) + + functions = {"TestFunc": func} + SamFunctionProvider._track_function_field_usage(functions) + + tracked_fields = mock_ctx.function_fields_used + + # Ensure excluded fields are not tracked + self.assertNotIn("function_id", tracked_fields) + self.assertNotIn("name", tracked_fields) + self.assertNotIn("functionname", tracked_fields) + self.assertNotIn("runtime", tracked_fields) + self.assertNotIn("rolearn", tracked_fields) + self.assertNotIn("handler", tracked_fields) + self.assertNotIn("metadata", tracked_fields) + + # Ensure non-excluded fields ARE tracked + self.assertIn("codeuri", tracked_fields) + self.assertIn("imageuri", tracked_fields) + self.assertIn("environment", tracked_fields) + self.assertIn("events", tracked_fields) + self.assertIn("layers", tracked_fields) + + @patch("samcli.lib.providers.sam_function_provider.Context.get_current_context") + def test_ignores_none_and_empty_values(self, mock_get_context): + """Test that None and empty values are not tracked""" + mock_ctx = Mock() + mock_get_context.return_value = mock_ctx + + func = Function( + function_id="TestFunc", + name="TestFunc", + functionname="test-func", + runtime="python3.12", + memory=None, + timeout=None, + handler="app.handler", + imageuri=None, + packagetype="Zip", + imageconfig=None, + codeuri="./src", + environment=None, + rolearn=None, + layers=[], + events=None, + metadata=None, + inlinecode=None, + codesign_config_arn=None, + architectures=[], + function_url_config={}, + function_build_info=FunctionBuildInfo.BuildableZip, + ) + + functions = {"TestFunc": func} + SamFunctionProvider._track_function_field_usage(functions) + + tracked_fields = mock_ctx.function_fields_used + + # Only codeuri should be tracked (has value and not excluded) + self.assertIn("codeuri", tracked_fields) + self.assertEqual(len(tracked_fields), 1) + + @patch("samcli.lib.providers.sam_function_provider.Context.get_current_context") + def test_tracks_across_multiple_functions(self, mock_get_context): + """Test that fields are tracked across multiple functions""" + mock_ctx = Mock() + mock_get_context.return_value = mock_ctx + + func1 = Function( + function_id="Func1", + name="Func1", + functionname="func1", + runtime="python3.12", + memory=512, + timeout=None, + handler="app.handler", + imageuri=None, + packagetype="Zip", + imageconfig=None, + codeuri="./src", + environment=None, + rolearn=None, + layers=[], + events=None, + metadata=None, + inlinecode=None, + codesign_config_arn=None, + architectures=None, + function_url_config=None, + function_build_info=FunctionBuildInfo.BuildableZip, + capacity_provider_config={"ClusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/test"}, + ) + + func2 = Function( + function_id="Func2", + name="Func2", + functionname="func2", + runtime="nodejs20.x", + memory=None, + timeout=30, + handler="index.handler", + imageuri=None, + packagetype="Zip", + imageconfig=None, + codeuri="./src", + environment=None, + rolearn=None, + layers=[], + events=None, + metadata=None, + inlinecode=None, + codesign_config_arn=None, + architectures=None, + function_url_config=None, + function_build_info=FunctionBuildInfo.BuildableZip, + logging_config={"LogFormat": "JSON"}, + ) + + functions = {"Func1": func1, "Func2": func2} + SamFunctionProvider._track_function_field_usage(functions) + + tracked_fields = mock_ctx.function_fields_used + + # Should track fields from both functions + self.assertIn("capacity_provider_config", tracked_fields) + self.assertIn("logging_config", tracked_fields) diff --git a/tests/unit/commands/samconfig/test_samconfig.py b/tests/unit/commands/samconfig/test_samconfig.py index 8717f23a189..adbdff333de 100644 --- a/tests/unit/commands/samconfig/test_samconfig.py +++ b/tests/unit/commands/samconfig/test_samconfig.py @@ -15,6 +15,8 @@ from unittest.mock import patch, ANY import logging +from parameterized import parameterized + from samcli.lib.config.samconfig import SamConfig, DEFAULT_ENV from samcli.lib.utils.packagetype import ZIP, IMAGE @@ -1200,6 +1202,23 @@ def test_info_must_not_read_from_config(self, deps_info_mock, system_info_mock): info_result = json.loads(result.output) self.assertTrue("version" in info_result) + @parameterized.expand( + # (use_container, [override_params], expect_use_container) + [ + # Default case when flags not specified in samconfig.toml + (None, [], False), + (False, [], False), + (True, [], True), + # Flags not specified and override + (None, ["--use-container"], True), + (None, ["--no-use-container"], False), + # Flags specified and override + (False, ["--use-container"], True), + (True, ["--use-container"], True), + (False, ["--use-container"], True), + (True, ["--no-use-container"], False), + ] + ) @patch("samcli.commands._utils.experimental.is_experimental_enabled") @patch("samcli.lib.cli_validation.image_repository_validation._is_all_image_funcs_provided") @patch("samcli.lib.cli_validation.image_repository_validation.get_template_artifacts_format") @@ -1208,188 +1227,9 @@ def test_info_must_not_read_from_config(self, deps_info_mock, system_info_mock): @patch("samcli.commands.sync.command.do_cli") def test_sync( self, - do_cli_mock, - template_artifacts_mock1, - template_artifacts_mock2, - template_artifacts_mock3, - is_all_image_funcs_provided_mock, - experimental_mock, - ): - template_artifacts_mock1.return_value = [ZIP] - template_artifacts_mock2.return_value = [ZIP] - template_artifacts_mock3.return_value = [ZIP] - is_all_image_funcs_provided_mock.return_value = True - experimental_mock.return_value = True - - config_values = { - "template_file": "mytemplate.yaml", - "stack_name": "mystack", - "image_repository": "123456789012.dkr.ecr.us-east-1.amazonaws.com/test1", - "base_dir": "path", - "use_container": True, - "s3_bucket": "mybucket", - "s3_prefix": "myprefix", - "kms_key_id": "mykms", - "parameter_overrides": 'Key1=Value1 Key2="Multiple spaces in the value"', - "capabilities": "cap1 cap2", - "no_execute_changeset": True, - "role_arn": "arn", - "notification_arns": "notify1 notify2", - "tags": 'a=tag1 b="tag with spaces"', - "metadata": '{"m1": "value1", "m2": "value2"}', - "container_env_var_file": "file", - "guided": True, - "confirm_changeset": True, - "region": "myregion", - "signing_profiles": "function=profile:owner", - "watch_exclude": {"HelloWorld": ["file.txt", "other.txt"], "HelloMars": ["single.file"]}, - } - - with samconfig_parameters(["sync"], self.scratch_dir, **config_values) as config_path: - from samcli.commands.sync.command import cli - - LOG.debug(Path(config_path).read_text()) - runner = CliRunner() - result = runner.invoke(cli, []) - - LOG.info(result.output) - LOG.info(result.exception) - if result.exception: - LOG.exception("Command failed", exc_info=result.exc_info) - self.assertIsNone(result.exception) - - do_cli_mock.assert_called_with( - str(Path(os.getcwd(), "mytemplate.yaml")), - False, - False, - (), - (), - True, - True, - "mystack", - "myregion", - None, - "path", - {"Key1": "Value1", "Key2": "Multiple spaces in the value"}, - None, - "123456789012.dkr.ecr.us-east-1.amazonaws.com/test1", - None, - "mybucket", - "myprefix", - "mykms", - ["cap1", "cap2"], - "arn", - ["notify1", "notify2"], - {"a": "tag1", "b": "tag with spaces"}, - {"m1": "value1", "m2": "value2"}, - True, - "file", - (), - "samconfig.toml", - "default", - False, - {"HelloWorld": ["file.txt", "other.txt"], "HelloMars": ["single.file"]}, - ) - - @patch("samcli.commands._utils.experimental.is_experimental_enabled") - @patch("samcli.lib.cli_validation.image_repository_validation._is_all_image_funcs_provided") - @patch("samcli.lib.cli_validation.image_repository_validation.get_template_artifacts_format") - @patch("samcli.commands._utils.template.get_template_artifacts_format") - @patch("samcli.commands._utils.options.get_template_artifacts_format") - @patch("samcli.commands.sync.command.do_cli") - def test_sync_with_no_use_container( - self, - do_cli_mock, - template_artifacts_mock1, - template_artifacts_mock2, - template_artifacts_mock3, - is_all_image_funcs_provided_mock, - experimental_mock, - ): - template_artifacts_mock1.return_value = [ZIP] - template_artifacts_mock2.return_value = [ZIP] - template_artifacts_mock3.return_value = [ZIP] - is_all_image_funcs_provided_mock.return_value = True - experimental_mock.return_value = True - - config_values = { - "template_file": "mytemplate.yaml", - "stack_name": "mystack", - "image_repository": "123456789012.dkr.ecr.us-east-1.amazonaws.com/test1", - "base_dir": "path", - "use_container": False, - "s3_bucket": "mybucket", - "s3_prefix": "myprefix", - "kms_key_id": "mykms", - "parameter_overrides": 'Key1=Value1 Key2="Multiple spaces in the value"', - "capabilities": "cap1 cap2", - "no_execute_changeset": True, - "role_arn": "arn", - "notification_arns": "notify1 notify2", - "tags": 'a=tag1 b="tag with spaces"', - "metadata": '{"m1": "value1", "m2": "value2"}', - "container_env_var_file": "file", - "guided": True, - "confirm_changeset": True, - "region": "myregion", - "signing_profiles": "function=profile:owner", - "watch_exclude": {"HelloWorld": ["file.txt", "other.txt"], "HelloMars": ["single.file"]}, - } - - with samconfig_parameters(["sync"], self.scratch_dir, **config_values) as config_path: - from samcli.commands.sync.command import cli - - LOG.debug(Path(config_path).read_text()) - runner = CliRunner() - result = runner.invoke(cli, []) - - LOG.info(result.output) - LOG.info(result.exception) - if result.exception: - LOG.exception("Command failed", exc_info=result.exc_info) - self.assertIsNone(result.exception) - - do_cli_mock.assert_called_with( - str(Path(os.getcwd(), "mytemplate.yaml")), - False, - False, - (), - (), - True, - True, - "mystack", - "myregion", - None, - "path", - {"Key1": "Value1", "Key2": "Multiple spaces in the value"}, - None, - "123456789012.dkr.ecr.us-east-1.amazonaws.com/test1", - None, - "mybucket", - "myprefix", - "mykms", - ["cap1", "cap2"], - "arn", - ["notify1", "notify2"], - {"a": "tag1", "b": "tag with spaces"}, - {"m1": "value1", "m2": "value2"}, - False, - "file", - (), - "samconfig.toml", - "default", - False, - {"HelloWorld": ["file.txt", "other.txt"], "HelloMars": ["single.file"]}, - ) - - @patch("samcli.commands._utils.experimental.is_experimental_enabled") - @patch("samcli.lib.cli_validation.image_repository_validation._is_all_image_funcs_provided") - @patch("samcli.lib.cli_validation.image_repository_validation.get_template_artifacts_format") - @patch("samcli.commands._utils.template.get_template_artifacts_format") - @patch("samcli.commands._utils.options.get_template_artifacts_format") - @patch("samcli.commands.sync.command.do_cli") - def test_sync_with_no_use_container_options( - self, + use_container, + override_params, + expect_use_container, do_cli_mock, template_artifacts_mock1, template_artifacts_mock2, @@ -1426,103 +1266,15 @@ def test_sync_with_no_use_container_options( "watch_exclude": {"HelloWorld": ["file.txt", "other.txt"], "HelloMars": ["single.file"]}, } - with samconfig_parameters(["sync"], self.scratch_dir, **config_values) as config_path: - from samcli.commands.sync.command import cli - - LOG.debug(Path(config_path).read_text()) - runner = CliRunner() - result = runner.invoke(cli, ["--no-use-container"]) - - LOG.info(result.output) - LOG.info(result.exception) - if result.exception: - LOG.exception("Command failed", exc_info=result.exc_info) - self.assertIsNone(result.exception) - - do_cli_mock.assert_called_with( - str(Path(os.getcwd(), "mytemplate.yaml")), - False, - False, - (), - (), - True, - True, - "mystack", - "myregion", - None, - "path", - {"Key1": "Value1", "Key2": "Multiple spaces in the value"}, - None, - "123456789012.dkr.ecr.us-east-1.amazonaws.com/test1", - None, - "mybucket", - "myprefix", - "mykms", - ["cap1", "cap2"], - "arn", - ["notify1", "notify2"], - {"a": "tag1", "b": "tag with spaces"}, - {"m1": "value1", "m2": "value2"}, - False, - "file", - (), - "samconfig.toml", - "default", - False, - {"HelloWorld": ["file.txt", "other.txt"], "HelloMars": ["single.file"]}, - ) - - @patch("samcli.commands._utils.experimental.is_experimental_enabled") - @patch("samcli.lib.cli_validation.image_repository_validation._is_all_image_funcs_provided") - @patch("samcli.lib.cli_validation.image_repository_validation.get_template_artifacts_format") - @patch("samcli.commands._utils.template.get_template_artifacts_format") - @patch("samcli.commands._utils.options.get_template_artifacts_format") - @patch("samcli.commands.sync.command.do_cli") - def test_sync_with_no_use_container_override( - self, - do_cli_mock, - template_artifacts_mock1, - template_artifacts_mock2, - template_artifacts_mock3, - is_all_image_funcs_provided_mock, - experimental_mock, - ): - template_artifacts_mock1.return_value = [ZIP] - template_artifacts_mock2.return_value = [ZIP] - template_artifacts_mock3.return_value = [ZIP] - is_all_image_funcs_provided_mock.return_value = True - experimental_mock.return_value = True - - config_values = { - "template_file": "mytemplate.yaml", - "stack_name": "mystack", - "image_repository": "123456789012.dkr.ecr.us-east-1.amazonaws.com/test1", - "base_dir": "path", - "use_container": True, - "s3_bucket": "mybucket", - "s3_prefix": "myprefix", - "kms_key_id": "mykms", - "parameter_overrides": 'Key1=Value1 Key2="Multiple spaces in the value"', - "capabilities": "cap1 cap2", - "no_execute_changeset": True, - "role_arn": "arn", - "notification_arns": "notify1 notify2", - "tags": 'a=tag1 b="tag with spaces"', - "metadata": '{"m1": "value1", "m2": "value2"}', - "container_env_var_file": "file", - "guided": True, - "confirm_changeset": True, - "region": "myregion", - "signing_profiles": "function=profile:owner", - "watch_exclude": {"HelloWorld": ["file.txt", "other.txt"], "HelloMars": ["single.file"]}, - } + if use_container is not None: + config_values["use_container"] = use_container with samconfig_parameters(["sync"], self.scratch_dir, **config_values) as config_path: from samcli.commands.sync.command import cli LOG.debug(Path(config_path).read_text()) runner = CliRunner() - result = runner.invoke(cli, ["--no-use-container"]) + result = runner.invoke(cli, override_params) LOG.info(result.output) LOG.info(result.exception) @@ -1554,7 +1306,7 @@ def test_sync_with_no_use_container_override( ["notify1", "notify2"], {"a": "tag1", "b": "tag with spaces"}, {"m1": "value1", "m2": "value2"}, - False, + expect_use_container, "file", (), "samconfig.toml", diff --git a/tests/unit/commands/sync/test_command.py b/tests/unit/commands/sync/test_command.py index 442bf63e35f..ba6147149dc 100644 --- a/tests/unit/commands/sync/test_command.py +++ b/tests/unit/commands/sync/test_command.py @@ -62,6 +62,7 @@ def setUp(self): MOCK_SAM_CONFIG.reset_mock() @parameterized.expand( + # code, watch, auto_dependency_layer, skip_deploy_sync, use_container, infra_sync_result, [ (False, False, True, True, False, InfraSyncResult(False, {ResourceIdentifier("Function")})), (False, False, True, True, False, InfraSyncResult(True)), @@ -239,7 +240,12 @@ def test_infra_must_succeed_sync( execute_code_sync_mock.assert_not_called() @parameterized.expand( - [(False, True, False, True, False), (False, True, False, False, True), (False, False, False, False, True)] + # watch, auto_dependency_layer, skip_deploy_sync, use_container, + [ + (False, True, False, True, False), + (False, True, False, False, True), + (False, False, False, False, True), + ] ) @patch("samcli.commands.sync.command.click") @patch("samcli.commands.sync.command.execute_watch") diff --git a/tests/unit/commands/sync/test_sync_context.py b/tests/unit/commands/sync/test_sync_context.py index 5bb43aeb8bf..39b6eaa4710 100644 --- a/tests/unit/commands/sync/test_sync_context.py +++ b/tests/unit/commands/sync/test_sync_context.py @@ -201,7 +201,10 @@ def test_none_toml_table_should_return_none(self): @parameterized_class( - [{"dependency_layer": True, "skip_deploy_sync": True}, {"dependency_layer": False, "skip_deploy_sync": False}] + [ + {"dependency_layer": True, "skip_deploy_sync": True}, + {"dependency_layer": False, "skip_deploy_sync": False}, + ] ) class TestSyncContext(TestCase): dependency_layer: bool diff --git a/tests/unit/lib/remote_invoke/test_lambda_invoke_executors.py b/tests/unit/lib/remote_invoke/test_lambda_invoke_executors.py index 4a6b6c86db8..cf0523c1291 100644 --- a/tests/unit/lib/remote_invoke/test_lambda_invoke_executors.py +++ b/tests/unit/lib/remote_invoke/test_lambda_invoke_executors.py @@ -1,8 +1,10 @@ import base64 +import json from abc import ABC, abstractmethod +from io import BytesIO from typing import Any from unittest import TestCase -from unittest.mock import Mock +from unittest.mock import Mock, patch from parameterized import parameterized @@ -26,7 +28,11 @@ RemoteInvokeOutputFormat, _is_function_invoke_mode_response_stream, ) -from samcli.lib.remote_invoke.remote_invoke_executors import RemoteInvokeExecutionInfo, RemoteInvokeResponse +from samcli.lib.remote_invoke.remote_invoke_executors import ( + RemoteInvokeExecutionInfo, + RemoteInvokeLogOutput, + RemoteInvokeResponse, +) class CommonTestsLambdaInvokeExecutor: @@ -90,6 +96,53 @@ def test_validate_action_parameters(self, parameters, expected_boto_parameters): self.lambda_invoke_executor.validate_action_parameters(parameters) self.assertEqual(self.lambda_invoke_executor.request_parameters, expected_boto_parameters) + def test_process_log_result_with_raw_tail_log(self): + # Traditional format: Base64-encoded string containing logs + log_content = "START RequestId: 123\nThis is a log message\nEND RequestId: 123" + log_result = base64.b64encode(log_content.encode("utf-8")) + + result = self.lambda_invoke_executor._process_log_result(log_result) + + self.assertIsInstance(result, RemoteInvokeLogOutput) + self.assertEqual(result.log_output, log_content) + + def test_process_log_result_with_json_format(self): + # New format: Base64-encoded JSON containing logGroup and logStreamName + log_data = { + "logGroup": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/my-function:*", + "logStreamName": "2023/04/18/[$LATEST]abcdef123456", + } + log_result = base64.b64encode(json.dumps(log_data).encode("utf-8")) + + result = self.lambda_invoke_executor._process_log_result(log_result) + + self.assertIsInstance(result, RemoteInvokeLogOutput) + self.assertIn("Function logs are available in CloudWatch Logs", result.log_output) + self.assertIn( + "Log Group: arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/my-function:*", result.log_output + ) + self.assertIn("Log Stream: 2023/04/18/[$LATEST]abcdef123456", result.log_output) + + def test_process_log_result_with_invalid_json(self): + # Invalid JSON but still a valid log message + log_content = '{"This is not valid JSON but should be treated as a log' + log_result = base64.b64encode(log_content.encode("utf-8")) + + result = self.lambda_invoke_executor._process_log_result(log_result) + + self.assertIsInstance(result, RemoteInvokeLogOutput) + self.assertEqual(result.log_output, log_content) + + def test_process_log_result_with_json_missing_fields(self): + # JSON without the required fields + log_data = {"someOtherField": "value"} + log_result = base64.b64encode(json.dumps(log_data).encode("utf-8")) + + result = self.lambda_invoke_executor._process_log_result(log_result) + + self.assertIsInstance(result, RemoteInvokeLogOutput) + self.assertEqual(result.log_output, json.dumps(log_data)) + class TestLambdaInvokeExecutor(CommonTestsLambdaInvokeExecutor.AbstractLambdaInvokeExecutorTest): def setUp(self) -> None: @@ -306,3 +359,104 @@ def test_is_function_invoke_mode_response_stream(self, boto_response, expected_r else: given_boto_client.get_function_url_config.return_value = boto_response self.assertEqual(_is_function_invoke_mode_response_stream(given_boto_client, "function_id"), expected_result) + + +class TestLambdaInvokeExecutorWithCapacityProvider(TestCase): + """Test retry logic for functions with CapacityProviderConfig""" + + def test_executor_retries_without_log_type_on_capacity_provider_error(self): + """Test that executor retries without LogType when capacity provider error occurs""" + lambda_client = Mock() + function_name = "test-function-with-cp" + + executor = LambdaInvokeExecutor(lambda_client, function_name, RemoteInvokeOutputFormat.TEXT) + + # Verify LogType IS initially in request_parameters + self.assertIn("LogType", executor.request_parameters) + self.assertEqual(executor.request_parameters["LogType"], "Tail") + + # Mock first invoke call to raise capacity provider error + capacity_provider_error = ClientError( + { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Tail logs are not supported for functions configured with capacity provider", + } + }, + "Invoke", + ) + + # Mock second invoke call to succeed (without LogResult since LogType was removed) + payload_bytes = BytesIO(b"test response") + success_response = {"StatusCode": 200, "Payload": payload_bytes} + lambda_client.invoke.side_effect = [capacity_provider_error, success_response] + + # Execute the action and consume the iterator + result = executor._execute_action("{}") + + # Verify the result contains only the successful response (no log output since LogType was removed) + self.assertEqual(list(result), [RemoteInvokeResponse("test response")]) + + # Verify LogType was removed after first failure + self.assertNotIn("LogType", executor.request_parameters) + + # Verify invoke was called twice + self.assertEqual(lambda_client.invoke.call_count, 2) + # Verify first call had LogType, second call didn't + first_call_params = lambda_client.invoke.call_args_list[0][1] + second_call_params = lambda_client.invoke.call_args_list[1][1] + self.assertIn("LogType", first_call_params) + self.assertNotIn("LogType", second_call_params) + + def test_executor_succeeds_on_first_try_for_regular_function(self): + """Test that regular functions succeed on first try with LogType""" + lambda_client = Mock() + function_name = "test-function-regular" + + executor = LambdaInvokeExecutor(lambda_client, function_name, RemoteInvokeOutputFormat.TEXT) + + # Verify LogType IS in request_parameters + self.assertIn("LogType", executor.request_parameters) + + # Mock successful invoke with logs + payload_bytes = BytesIO(b"success response") + # Base64 encoded log: "START RequestId: 123\nEND RequestId: 123" + log_result = base64.b64encode(b"START RequestId: 123\nEND RequestId: 123").decode("utf-8") + success_response = {"StatusCode": 200, "Payload": payload_bytes, "LogResult": log_result} + lambda_client.invoke.return_value = success_response + + # Execute the action + result = executor._execute_action("{}") + + # Validate Response + self.assertEqual( + list(result), + [ + RemoteInvokeLogOutput("START RequestId: 123\nEND RequestId: 123"), + RemoteInvokeResponse("success response"), + ], + ) + + # Verify invoke was called only once + self.assertEqual(lambda_client.invoke.call_count, 1) + self.assertIn("LogType", executor.request_parameters) + + def test_executor_raises_other_errors_without_retry(self): + """Test that other errors are raised without retry""" + lambda_client = Mock() + function_name = "test-function" + + executor = LambdaInvokeExecutor(lambda_client, function_name, RemoteInvokeOutputFormat.TEXT) + + # Mock invoke to raise a different error + other_error = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Function not found"}}, "Invoke" + ) + lambda_client.invoke.side_effect = other_error + + # Execute and expect error + with self.assertRaises(ErrorBotoApiCallException): + list(executor._execute_action("{}")) + + # Verify invoke was called only once (no retry) + self.assertEqual(lambda_client.invoke.call_count, 1) diff --git a/tests/unit/lib/sync/flows/test_function_sync_flow.py b/tests/unit/lib/sync/flows/test_function_sync_flow.py index 7dbb051355e..a042ccd599b 100644 --- a/tests/unit/lib/sync/flows/test_function_sync_flow.py +++ b/tests/unit/lib/sync/flows/test_function_sync_flow.py @@ -1,9 +1,15 @@ from unittest import TestCase -from unittest.mock import ANY, MagicMock, patch, Mock +from unittest.mock import ANY, MagicMock, patch, Mock, call -from parameterized import parameterized_class +from parameterized import parameterized, parameterized_class -from samcli.lib.sync.flows.function_sync_flow import FunctionSyncFlow +from samcli.lib.sync.flows.function_sync_flow import ( + FunctionPublishTarget, + FunctionPublishVersionParams, + FunctionSyncFlow, + FunctionUpdateParams, +) +from samcli.lib.sync.sync_flow import ApiCallTypes @parameterized_class( @@ -16,6 +22,14 @@ class TestFunctionSyncFlow(TestCase): build_artifacts = None + def setUp(self): + # Create a mock Lambda client that will be used across all tests + self.lambda_client_mock = MagicMock() + self.lambda_client_mock.get_waiter.return_value = MagicMock() + + # Set up common mock responses + self.lambda_client_mock.publish_version.return_value = {"Version": "3"} + def create_function_sync_flow(self): sync_flow = FunctionSyncFlow( "Function1", @@ -30,26 +44,30 @@ def create_function_sync_flow(self): sync_flow.compare_remote = MagicMock() sync_flow.sync = MagicMock() sync_flow._get_resource_api_calls = MagicMock() + + # Directly set the lambda client to avoid boto3 session issues + sync_flow._lambda_client = self.lambda_client_mock + sync_flow._lambda_waiter = self.lambda_client_mock.get_waiter.return_value + + # Mock has_locks to return True for testing + sync_flow.has_locks = MagicMock(return_value=True) + return sync_flow - @patch("samcli.lib.sync.sync_flow.get_boto_client_provider_from_session_with_config") - @patch("samcli.lib.sync.sync_flow.Session") @patch.multiple(FunctionSyncFlow, __abstractmethods__=set()) - def test_sets_up_clients(self, session_mock, client_provider_mock): + def test_sets_up_clients(self): sync_flow = self.create_function_sync_flow() - sync_flow.set_up() - client_provider_mock.return_value.assert_called_once_with("lambda") - sync_flow._lambda_client.get_waiter.assert_called_once_with("function_updated") + # No need to call set_up() since we're directly setting the client in create_function_sync_flow + self.assertIsNotNone(sync_flow._lambda_client) + self.assertIsNotNone(sync_flow._lambda_waiter) @patch("samcli.lib.sync.flows.function_sync_flow.AliasVersionSyncFlow") - @patch("samcli.lib.sync.sync_flow.Session") @patch.multiple(FunctionSyncFlow, __abstractmethods__=set()) - def test_gather_dependencies(self, session_mock, alias_version_mock): + def test_gather_dependencies(self, alias_version_mock): sync_flow = self.create_function_sync_flow() sync_flow.get_physical_id = lambda x: "PhysicalFunction1" sync_flow._get_resource = lambda x: MagicMock() - sync_flow.set_up() result = sync_flow.gather_dependencies() sync_flow._lambda_waiter.wait.assert_called_once_with(FunctionName="PhysicalFunction1", WaiterConfig=ANY) @@ -59,3 +77,108 @@ def test_gather_dependencies(self, session_mock, alias_version_mock): def test_equality_keys(self): sync_flow = self.create_function_sync_flow() self.assertEqual(sync_flow._equality_keys(), "Function1") + + @patch("samcli.lib.sync.flows.function_sync_flow.wait_for_function_update_complete") + @patch.multiple(FunctionSyncFlow, __abstractmethods__=set()) + def test_publish_function_version_with_lock(self, wait_mock): + function_physical_id = "myFirstFunction" + publish_target = FunctionPublishTarget.LATEST_PUBLISHED + response_version = "$LATEST.PUBLISHED" + + sync_flow = self.create_function_sync_flow() + sync_flow._get_lock_chain = MagicMock() + + # Configure the mock response for this specific test + self.lambda_client_mock.publish_version.return_value = {"Version": response_version} + + # Create a mock for the publish params + publish_params = FunctionPublishVersionParams(FunctionName=function_physical_id, PublishTo=publish_target) + + # Call the method + sync_flow.publish_function_version_with_lock(publish_params) + + sync_flow._get_lock_chain.assert_called_once() + sync_flow._get_lock_chain.return_value.__enter__.assert_called_once() + + # Verify the publish_version call with the correct parameters + expected_params = publish_params.to_dict() + sync_flow._lambda_client.publish_version.assert_called_once_with(**expected_params) + + # Verify that PublishTo parameter is passed as the string "LATEST_PUBLISHED" + call_args = sync_flow._lambda_client.publish_version.call_args + self.assertEqual(call_args.kwargs.get("PublishTo"), "LATEST_PUBLISHED") + + wait_mock.assert_called_once_with(sync_flow._lambda_client, function_physical_id, response_version) + + @parameterized.expand( + [ + ("myFirstFunction", b"code1", None, None, None), + ("mySecondFunction", None, "bucket1", "key1", None), + ("myThirdFunction", None, None, None, "image:latest"), + ] + ) + @patch("samcli.lib.sync.flows.function_sync_flow.wait_for_function_update_complete") + @patch.multiple(FunctionSyncFlow, __abstractmethods__=set()) + def test_update_function_with_lock(self, function_physical_id, zip_file, s3_bucket, s3_key, image_uri, wait_mock): + sync_flow = self.create_function_sync_flow() + sync_flow._get_lock_chain = MagicMock() + sync_flow.get_physical_id = MagicMock(return_value=function_physical_id) + sync_flow._sync_context = MagicMock() + sync_flow.publish_function_version_with_lock = MagicMock() + + # Configure the mock response + self.lambda_client_mock.update_function_code.return_value = {"FunctionName": function_physical_id} + + # Create actual FunctionUpdateParams object + update_params = FunctionUpdateParams( + FunctionName=function_physical_id, ZipFile=zip_file, S3Bucket=s3_bucket, S3Key=s3_key, ImageUri=image_uri + ) + + # Mock the to_dict method to return expected dictionary + expected_params = {} + if function_physical_id: + expected_params["FunctionName"] = function_physical_id + if zip_file: + expected_params["ZipFile"] = zip_file + if s3_bucket: + expected_params["S3Bucket"] = s3_bucket + if s3_key: + expected_params["S3Key"] = s3_key + if image_uri: + expected_params["ImageUri"] = image_uri + + # Call the method + sync_flow.update_function_with_lock(update_params) + + # Verify the Lambda client was called with the correct parameters + sync_flow._get_lock_chain.assert_called_once() + sync_flow._get_lock_chain.return_value.__enter__.assert_called_once() + self.lambda_client_mock.update_function_code.assert_called_once_with(**expected_params) + + # Check wait was called + wait_mock.assert_called_once_with(self.lambda_client_mock, function_physical_id) + + @parameterized.expand( + [ + # publish_to_latest_published, capacity_provider_config, expected_result + (True, True, True), + (True, False, False), + (False, True, False), + (False, False, False), + ] + ) + @patch.multiple(FunctionSyncFlow, __abstractmethods__=set()) + def test_auto_publish_latest_invocable_property( + self, publish_to_latest_published, has_capacity_provider, expected_result + ): + sync_flow = self.create_function_sync_flow() + + # Mock the function properties + sync_flow._function.publish_to_latest_published = publish_to_latest_published + if has_capacity_provider: + sync_flow._function.capacity_provider_configuration = MagicMock() + else: + sync_flow._function.capacity_provider_configuration = None + + result = sync_flow.auto_publish_latest_invocable + self.assertEqual(result, expected_result) diff --git a/tests/unit/lib/sync/flows/test_image_function_sync_flow.py b/tests/unit/lib/sync/flows/test_image_function_sync_flow.py index 70508a9c51d..f2308fe127a 100644 --- a/tests/unit/lib/sync/flows/test_image_function_sync_flow.py +++ b/tests/unit/lib/sync/flows/test_image_function_sync_flow.py @@ -1,28 +1,54 @@ from unittest import TestCase +import unittest from unittest.mock import MagicMock, patch, Mock -from parameterized import parameterized_class +from parameterized import parameterized, parameterized_class +from samcli.lib.providers.provider import CapacityProviderConfig from samcli.lib.sync.flows.image_function_sync_flow import ImageFunctionSyncFlow from samcli.lib.sync.sync_flow import ApiCallTypes @parameterized_class( - ("build_artifacts"), + ("build_artifacts", "has_capacity_provider_config"), [ - (None,), - (Mock(),), + (None, False), + (Mock(), True), ], ) class TestImageFunctionSyncFlow(TestCase): build_artifacts = None + has_capacity_provider_config = None + + def create_function_sync_flow(self, publish_to_latest_published=False): + sync_context = MagicMock() + + function_mock = MagicMock() + function_mock.codeuri = "CodeUri/" + function_mock.capacity_provider_config = ( + { + "Arn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name", + "PerExecutionEnvironmentMaxConcurrency": 8, + } + if self.has_capacity_provider_config + else None + ) + # Mock the capacity_provider_configuration property to return the correct value + function_mock.capacity_provider_configuration = ( + CapacityProviderConfig.from_dict(function_mock.capacity_provider_config) + if self.has_capacity_provider_config + else None + ) + function_mock.publish_to_latest_published = publish_to_latest_published + + build_context = MagicMock() + build_context.function_provider.get.return_value = function_mock - def create_function_sync_flow(self): sync_flow = ImageFunctionSyncFlow( "Function1", - build_context=MagicMock(), + build_context=build_context, deploy_context=MagicMock(), - sync_context=MagicMock(), + sync_context=sync_context, physical_id_mapping={}, stacks=[MagicMock()], application_build_result=self.build_artifacts, @@ -87,7 +113,7 @@ def test_gather_resources(self, session_mock, builder_mock): ) @patch("samcli.lib.sync.flows.image_function_sync_flow.get_validated_container_client") - @patch("samcli.lib.sync.flows.image_function_sync_flow.wait_for_function_update_complete") + @patch("samcli.lib.sync.flows.function_sync_flow.wait_for_function_update_complete") @patch("samcli.lib.sync.flows.image_function_sync_flow.ECRUploader") @patch("samcli.lib.sync.sync_flow.Session") def test_sync_context_image_repo(self, session_mock, uploader_mock, wait_mock, mock_get_validated_client): @@ -122,7 +148,7 @@ def test_sync_context_image_repo(self, session_mock, uploader_mock, wait_mock, m sync_flow._get_lock_chain.return_value.__exit__.assert_called_once() @patch("samcli.lib.sync.flows.image_function_sync_flow.get_validated_container_client") - @patch("samcli.lib.sync.flows.image_function_sync_flow.wait_for_function_update_complete") + @patch("samcli.lib.sync.flows.function_sync_flow.wait_for_function_update_complete") @patch("samcli.lib.sync.flows.image_function_sync_flow.ECRUploader") @patch("samcli.lib.sync.sync_flow.Session") def test_sync_context_image_repos(self, session_mock, uploader_mock, wait_mock, mock_get_validated_client): @@ -158,7 +184,7 @@ def test_sync_context_image_repos(self, session_mock, uploader_mock, wait_mock, sync_flow._get_lock_chain.return_value.__exit__.assert_called_once() @patch("samcli.lib.sync.flows.image_function_sync_flow.get_validated_container_client") - @patch("samcli.lib.sync.flows.image_function_sync_flow.wait_for_function_update_complete") + @patch("samcli.lib.sync.flows.function_sync_flow.wait_for_function_update_complete") @patch("samcli.lib.sync.flows.image_function_sync_flow.ECRUploader") @patch("samcli.lib.sync.sync_flow.Session") def test_sync_remote_image_repo(self, session_mock, uploader_mock, wait_mock, mock_get_validated_client): @@ -197,6 +223,54 @@ def test_sync_remote_image_repo(self, session_mock, uploader_mock, wait_mock, mo wait_mock.assert_called_once_with(sync_flow._lambda_client, "PhysicalFunction1") sync_flow._get_lock_chain.return_value.__exit__.assert_called_once() + @unittest.skipIf( + lambda self: not self.has_capacity_provider_config, + "Skip publish latest invocable test for function withouth capacity provider config", + ) + @patch("samcli.lib.sync.flows.function_sync_flow.wait_for_function_update_complete") + @patch("samcli.lib.sync.flows.image_function_sync_flow.ECRUploader") + @patch("samcli.lib.sync.sync_flow.Session") + def test_sync_remote_image_repo_with_publish_function(self, session_mock, uploader_mock, wait_mock): + sync_flow = self.create_function_sync_flow(publish_to_latest_published=True) + sync_flow._image_name = "ImageName1" + + uploader_mock.return_value.upload.return_value = "image_uri" + + sync_flow._get_lock_chain = MagicMock() + sync_flow.has_locks = MagicMock() + + sync_flow.get_physical_id = MagicMock() + sync_flow.get_physical_id.return_value = "PhysicalFunction1" + sync_flow._deploy_context.image_repository = "" + sync_flow._deploy_context.image_repositories = {} + + sync_flow.set_up() + + sync_flow._lambda_client.get_function = MagicMock() + sync_flow._lambda_client.get_function.return_value = {"Code": {"ImageUri": "repo_uri:tag"}} + sync_flow._lambda_client.publish_version = MagicMock() + sync_flow._lambda_client.publish_version.return_value = {"Version": "$LATEST.PUBLISHED"} + + sync_flow.sync() + + uploader_mock.return_value.upload.assert_called_once_with("ImageName1", "Function1") + uploader_mock.assert_called_once_with(sync_flow._docker_client, sync_flow._ecr_client, "repo_uri", None) + + self.assertEqual(sync_flow._get_lock_chain.call_count, 2) + self.assertEqual(sync_flow._get_lock_chain.return_value.__enter__.call_count, 2) + sync_flow._lambda_client.update_function_code.assert_called_once_with( + FunctionName="PhysicalFunction1", ImageUri="image_uri" + ) + self.assertEqual(wait_mock.call_count, 2) + self.assertEqual( + wait_mock.call_args_list, + [ + ((sync_flow._lambda_client, "PhysicalFunction1"),), + ((sync_flow._lambda_client, "PhysicalFunction1", "$LATEST.PUBLISHED"),), + ], + ) + self.assertEqual(sync_flow._get_lock_chain.return_value.__exit__.call_count, 2) + @patch("samcli.lib.sync.flows.image_function_sync_flow.ECRUploader") @patch("samcli.lib.sync.sync_flow.Session") def test_sync_with_no_image(self, session_mock, uploader_mock): @@ -209,11 +283,60 @@ def test_compare_remote(self): sync_flow = self.create_function_sync_flow() self.assertFalse(sync_flow.compare_remote()) + @parameterized.expand( + [ + # publish_to_latest_published, has_capacity_provider_config, expect_api_list + (False, False, [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION]), + (False, True, [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION]), + (True, False, [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION]), + ( + True, + True, + [ + ApiCallTypes.UPDATE_FUNCTION_CODE, + ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION, + ApiCallTypes.PUBLISH_VERSION, + ], + ), + ] + ) @patch("samcli.lib.sync.flows.image_function_sync_flow.ResourceAPICall") - def test_get_resource_api_calls(self, resource_api_call_mock): - sync_flow = self.create_function_sync_flow() + def test_get_resource_api_calls( + self, publish_to_latest_published, has_capacity_provider_config, expect_api_list, resource_api_call_mock + ): + sync_context = MagicMock() + + function_mock = MagicMock() + function_mock.codeuri = "CodeUri/" + function_mock.capacity_provider_config = ( + { + "Arn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name", + "PerExecutionEnvironmentMaxConcurrency": 8, + } + if has_capacity_provider_config + else None + ) + # Mock the capacity_provider_configuration property to return the correct value + function_mock.capacity_provider_configuration = ( + CapacityProviderConfig.from_dict(function_mock.capacity_provider_config) + if has_capacity_provider_config + else None + ) + function_mock.publish_to_latest_published = publish_to_latest_published + + build_context = MagicMock() + build_context.function_provider.get.return_value = function_mock + + sync_flow = ImageFunctionSyncFlow( + "Function1", + build_context=build_context, + deploy_context=MagicMock(), + sync_context=sync_context, + physical_id_mapping={}, + stacks=[MagicMock()], + application_build_result=self.build_artifacts, + ) + result = sync_flow._get_resource_api_calls() self.assertEqual(len(result), 1) - resource_api_call_mock.assert_any_call( - "Function1", [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION] - ) + resource_api_call_mock.assert_any_call("Function1", expect_api_list) diff --git a/tests/unit/lib/sync/flows/test_zip_function_sync_flow.py b/tests/unit/lib/sync/flows/test_zip_function_sync_flow.py index 977845fefb7..1b79849fc5f 100644 --- a/tests/unit/lib/sync/flows/test_zip_function_sync_flow.py +++ b/tests/unit/lib/sync/flows/test_zip_function_sync_flow.py @@ -1,7 +1,9 @@ import os +import unittest -from parameterized import parameterized_class +from parameterized import parameterized_class, parameterized +from samcli.lib.providers.provider import CapacityProviderConfig from samcli.lib.sync.flows.function_sync_flow import wait_for_function_update_complete from samcli.lib.sync.sync_flow import ApiCallTypes @@ -12,23 +14,46 @@ @parameterized_class( - ("build_artifacts"), + ("build_artifacts", "has_capacity_provider_config"), [ - (None,), - (Mock(),), + (None, False), + (Mock(), True), ], ) class TestZipFunctionSyncFlow(TestCase): build_artifacts = None + has_capacity_provider_config = None + + def create_function_sync_flow(self, publish_to_latest_published=False): + sync_context = MagicMock() - def create_function_sync_flow(self): - self.build_context_mock = MagicMock() self.function_identifier = "Function1" + + # Use a mock instead of a real Function object since we need to mock methods + function_mock = MagicMock() + function_mock.capacity_provider_config = ( + { + "Arn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name", + "PerExecutionEnvironmentMaxConcurrency": 8, + } + if self.has_capacity_provider_config + else None + ) + function_mock.capacity_provider_configuration = ( + CapacityProviderConfig.from_dict(function_mock.capacity_provider_config) + if self.has_capacity_provider_config + else None + ) + function_mock.publish_to_latest_published = publish_to_latest_published + + self.build_context = MagicMock() + self.build_context.function_provider.get.return_value = function_mock + sync_flow = ZipFunctionSyncFlow( self.function_identifier, - build_context=self.build_context_mock, + build_context=self.build_context, deploy_context=MagicMock(), - sync_context=MagicMock(), + sync_context=sync_context, physical_id_mapping={}, stacks=[MagicMock()], application_build_result=self.build_artifacts, @@ -78,7 +103,7 @@ def test_gather_resources( sync_flow.set_up() sync_flow.gather_resources() - function_object = self.build_context_mock.function_provider.get(self.function_identifier) + function_object = self.build_context.function_provider.get(self.function_identifier) if self.build_artifacts: build_folder = self.build_artifacts.artifacts.get(self.function_identifier) @@ -90,9 +115,7 @@ def test_gather_resources( sync_flow._get_lock_chain.return_value.__enter__.assert_not_called() sync_flow._get_lock_chain.return_value.__exit__.assert_not_called() else: - rmtree_if_exists_mock.assert_called_once_with( - function_object.get_build_dir(self.build_context_mock.build_dir) - ) + rmtree_if_exists_mock.assert_called_once_with(function_object.get_build_dir(self.build_context.build_dir)) get_mock.assert_called_once_with("Function1") self.assertEqual(sync_flow._artifact_folder, "ArtifactFolder1") make_zip_mock.assert_called_once_with("temp_folder" + os.sep + "data-uuid_value", "ArtifactFolder1") @@ -143,7 +166,7 @@ def test_compare_remote_false(self, session_mock, b64decode_mock): b64decode_mock.assert_called_once_with("sha256_value_b64") self.assertFalse(result) - @patch("samcli.lib.sync.flows.zip_function_sync_flow.wait_for_function_update_complete") + @patch("samcli.lib.sync.flows.function_sync_flow.wait_for_function_update_complete") @patch("samcli.lib.sync.flows.zip_function_sync_flow.open", mock_open(read_data=b"zip_content"), create=True) @patch("samcli.lib.sync.flows.zip_function_sync_flow.os.remove") @patch("samcli.lib.sync.flows.zip_function_sync_flow.os.path.exists") @@ -174,7 +197,56 @@ def test_sync_direct(self, session_mock, getsize_mock, uploader_mock, exists_moc sync_flow._get_lock_chain.return_value.__exit__.assert_called_once() remove_mock.assert_called_once_with("zip_file") - @patch("samcli.lib.sync.flows.zip_function_sync_flow.wait_for_function_update_complete") + @unittest.skipIf( + lambda self: not self.has_capacity_provider_config, + "Skip publish latest invocable test for function withouth capacity provider config", + ) + @patch("samcli.lib.sync.flows.function_sync_flow.wait_for_function_update_complete") + @patch("samcli.lib.sync.flows.zip_function_sync_flow.open", mock_open(read_data=b"zip_content"), create=True) + @patch("samcli.lib.sync.flows.zip_function_sync_flow.os.remove") + @patch("samcli.lib.sync.flows.zip_function_sync_flow.os.path.exists") + @patch("samcli.lib.sync.flows.zip_function_sync_flow.S3Uploader") + @patch("samcli.lib.sync.flows.zip_function_sync_flow.os.path.getsize") + @patch("samcli.lib.sync.sync_flow.Session") + def test_sync_direct_with_publish_version( + self, session_mock, getsize_mock, uploader_mock, exists_mock, remove_mock, wait_mock + ): + getsize_mock.return_value = 49 * 1024 * 1024 + exists_mock.return_value = True + sync_flow = self.create_function_sync_flow(publish_to_latest_published=True) + sync_flow._zip_file = "zip_file" + + sync_flow._get_lock_chain = MagicMock() + sync_flow.has_locks = MagicMock() + sync_flow.get_physical_id = MagicMock() + sync_flow.get_physical_id.return_value = "PhysicalFunction1" + + sync_flow.set_up() + + sync_flow._lambda_client.publish_version = MagicMock() + sync_flow._lambda_client.publish_version.return_value = {"Version": "$LATEST.PUBLISHED"} + + sync_flow.sync() + + self.assertEqual(sync_flow._get_lock_chain.call_count, 2) + self.assertEqual(sync_flow._get_lock_chain.return_value.__enter__.call_count, 2) + sync_flow._lambda_client.update_function_code.assert_called_once_with( + FunctionName="PhysicalFunction1", ZipFile=b"zip_content" + ) + + self.assertEqual(wait_mock.call_count, 2) + self.assertEqual( + wait_mock.call_args_list, + [ + ((sync_flow._lambda_client, "PhysicalFunction1"),), + ((sync_flow._lambda_client, "PhysicalFunction1", "$LATEST.PUBLISHED"),), + ], + ) + self.assertEqual(sync_flow._get_lock_chain.return_value.__exit__.call_count, 2) + + remove_mock.assert_called_once_with("zip_file") + + @patch("samcli.lib.sync.flows.function_sync_flow.wait_for_function_update_complete") @patch("samcli.lib.sync.flows.zip_function_sync_flow.open", mock_open(read_data=b"zip_content"), create=True) @patch("samcli.lib.sync.flows.zip_function_sync_flow.os.remove") @patch("samcli.lib.sync.flows.zip_function_sync_flow.os.path.exists") @@ -209,8 +281,27 @@ def test_sync_s3(self, session_mock, getsize_mock, uploader_mock, exists_mock, r sync_flow._get_lock_chain.return_value.__exit__.assert_called_once() remove_mock.assert_called_once_with("zip_file") + @parameterized.expand( + [ + # publish_to_latest_published, has_capacity_provider_config, expect_api_list + (False, False, [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION]), + (False, True, [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION]), + (True, False, [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION]), + ( + True, + True, + [ + ApiCallTypes.UPDATE_FUNCTION_CODE, + ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION, + ApiCallTypes.PUBLISH_VERSION, + ], + ), + ] + ) @patch("samcli.lib.sync.flows.zip_function_sync_flow.ResourceAPICall") - def test_get_resource_api_calls(self, resource_api_call_mock): + def test_get_resource_api_calls( + self, publish_to_latest_published, has_capacity_provider_config, expect_api_list, resource_api_call_mock + ): build_context = MagicMock() layer1 = MagicMock() layer2 = MagicMock() @@ -219,12 +310,27 @@ def test_get_resource_api_calls(self, resource_api_call_mock): function_mock = MagicMock() function_mock.layers = [layer1, layer2] function_mock.codeuri = "CodeUri/" + function_mock.capacity_provider_config = ( + { + "Arn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name", + "PerExecutionEnvironmentMaxConcurrency": 8, + } + if has_capacity_provider_config + else None + ) + function_mock.capacity_provider_configuration = ( + CapacityProviderConfig.from_dict(function_mock.capacity_provider_config) + if has_capacity_provider_config + else None + ) + function_mock.publish_to_latest_published = publish_to_latest_published build_context.function_provider.get.return_value = function_mock + sync_context = Mock() sync_flow = ZipFunctionSyncFlow( "Function1", build_context=build_context, deploy_context=MagicMock(), - sync_context=MagicMock(), + sync_context=sync_context, physical_id_mapping={}, stacks=[MagicMock()], application_build_result=self.build_artifacts, @@ -235,9 +341,7 @@ def test_get_resource_api_calls(self, resource_api_call_mock): resource_api_call_mock.assert_any_call("Layer1", [ApiCallTypes.BUILD]) resource_api_call_mock.assert_any_call("Layer2", [ApiCallTypes.BUILD]) resource_api_call_mock.assert_any_call("CodeUri/", [ApiCallTypes.BUILD]) - resource_api_call_mock.assert_any_call( - "Function1", [ApiCallTypes.UPDATE_FUNCTION_CODE, ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION] - ) + resource_api_call_mock.assert_any_call("Function1", expect_api_list) def test_combine_dependencies(self): sync_flow = self.create_function_sync_flow() diff --git a/tests/unit/lib/sync/test_sync_flow.py b/tests/unit/lib/sync/test_sync_flow.py index 290957f6c8b..f4a4a6f85bc 100644 --- a/tests/unit/lib/sync/test_sync_flow.py +++ b/tests/unit/lib/sync/test_sync_flow.py @@ -295,3 +295,18 @@ def test_compare_local(self, patched_session, patched_sync_state_identifier): sync_flow._sync_context.get_resource_latest_sync_hash.return_value = "hash" self.assertEqual(sync_flow.compare_local(), True) + + def test_api_call_types(self): + """Test cases for ApiCallTypes enum values""" + # Test enum members are unique + self.assertEqual(len(set(ApiCallTypes)), 4) + + # Test enum members exist + self.assertTrue(hasattr(ApiCallTypes, "BUILD")) + self.assertEqual(ApiCallTypes.BUILD.value, "Build") + self.assertTrue(hasattr(ApiCallTypes, "UPDATE_FUNCTION_CONFIGURATION")) + self.assertEqual(ApiCallTypes.UPDATE_FUNCTION_CONFIGURATION.value, "UpdateFunctionConfiguration") + self.assertTrue(hasattr(ApiCallTypes, "UPDATE_FUNCTION_CODE")) + self.assertEqual(ApiCallTypes.UPDATE_FUNCTION_CODE.value, "UpdateFunctionCode") + self.assertTrue(hasattr(ApiCallTypes, "PUBLISH_VERSION")) + self.assertEqual(ApiCallTypes.PUBLISH_VERSION.value, "PublishVersion") diff --git a/tests/unit/local/docker/test_container.py b/tests/unit/local/docker/test_container.py index 8e53490efcf..aab885282fd 100644 --- a/tests/unit/local/docker/test_container.py +++ b/tests/unit/local/docker/test_container.py @@ -4,6 +4,8 @@ import base64 import json +import threading +import time from unittest import TestCase from unittest.mock import MagicMock, Mock, call, patch, ANY from parameterized import parameterized @@ -12,6 +14,7 @@ from docker.errors import NotFound, APIError from requests import RequestException +from samcli.commands.local.lib.debug_context import DebugContext from samcli.lib.utils.packagetype import IMAGE from samcli.lib.utils.stream_writer import StreamWriter from samcli.local.docker.container import ( @@ -59,6 +62,7 @@ def test_init_must_store_all_values(self): self.assertEqual(self.memory_mb, container._memory_limit_mb) self.assertEqual(None, container._network_id) self.assertEqual(None, container.id) + self.assertIsNone(container._concurrency_semaphore) self.assertEqual(self.mock_docker_client, container.docker_client) @@ -109,6 +113,7 @@ def test_must_create_container_with_required_values(self, mock_resolve_symlinks) container_id = container.create(ContainerContext.INVOKE) self.assertEqual(container_id, generated_id) self.assertEqual(container.id, generated_id) + self.assertIsNotNone(container._concurrency_semaphore) self.mock_docker_client.containers.create.assert_called_with( self.image, @@ -161,6 +166,7 @@ def test_must_create_container_including_all_optional_values(self, mock_resolve_ container_id = container.create(ContainerContext.BUILD) self.assertEqual(container_id, generated_id) self.assertEqual(container.id, generated_id) + self.assertIsNotNone(container._concurrency_semaphore) self.mock_docker_client.containers.create.assert_called_with( self.image, @@ -180,6 +186,7 @@ def test_must_create_container_including_all_optional_values(self, mock_resolve_ ) self.mock_docker_client.networks.get.assert_not_called() mock_resolve_symlinks.assert_not_called() # When context is BUILD + self.assertIsNotNone(container._concurrency_semaphore) @patch("samcli.local.docker.utils.os") @patch("samcli.local.docker.container.Container._create_mapped_symlink_files") @@ -223,6 +230,7 @@ def test_must_create_container_translate_volume_path(self, mock_resolve_symlinks container_id = container.create(self.container_context) self.assertEqual(container_id, generated_id) self.assertEqual(container.id, generated_id) + self.assertIsNotNone(container._concurrency_semaphore) self.mock_docker_client.containers.create.assert_called_with( self.image, @@ -267,6 +275,7 @@ def test_must_connect_to_network_on_create(self, mock_resolve_symlinks): container_id = container.create(self.container_context) self.assertEqual(container_id, generated_id) + self.assertIsNotNone(container._concurrency_semaphore) self.mock_docker_client.containers.create.assert_called_with( self.image, @@ -281,6 +290,39 @@ def test_must_connect_to_network_on_create(self, mock_resolve_symlinks): self.mock_docker_client.networks.get.assert_called_with(network_id) network_mock.connect.assert_called_with(container_id) + @patch("samcli.local.docker.container.Container._initialize_concurrency_control") + def test_must_initialize_concurrency_control(self, mock_concurrency_control): + generated_id = "fooobar" + self.mock_docker_client.containers.create.return_value = Mock() + self.mock_docker_client.containers.create.return_value.id = generated_id + + # Configure the mock to set _concurrency_semaphore when called + def side_effect(container_obj=None): + # This simulates what the real method would do + self.container._concurrency_semaphore = threading.Semaphore(1) + return self.container._concurrency_semaphore + + mock_concurrency_control.side_effect = side_effect + + container = Container( + self.image, + self.cmd, + self.working_dir, + self.host_dir, + docker_client=self.mock_docker_client, + exposed_ports=self.exposed_ports, + ) + + # Store the container for the side_effect to use + self.container = container + container_id = container.create(ContainerContext.INVOKE) + self.assertEqual(container_id, generated_id) + self.assertEqual(container.id, generated_id) + mock_concurrency_control.assert_called_once() + self.assertIsNotNone(container._concurrency_semaphore) + + mock_concurrency_control.assert_called_once() + @patch("samcli.local.docker.container.Container._create_mapped_symlink_files") def test_must_connect_to_host_network_on_create(self, mock_resolve_symlinks): """ @@ -306,6 +348,7 @@ def test_must_connect_to_host_network_on_create(self, mock_resolve_symlinks): container_id = container.create(self.container_context) self.assertEqual(container_id, generated_id) + self.assertIsNotNone(container._concurrency_semaphore) self.mock_docker_client.containers.create.assert_called_with( self.image, @@ -329,6 +372,7 @@ def test_must_fail_if_already_created(self): with self.assertRaises(RuntimeError): container.create(self.container_context) + self.assertIsNone(container._concurrency_semaphore) @patch("samcli.local.docker.container.os.path.exists") @patch("samcli.local.docker.container.os.makedirs") @@ -830,6 +874,7 @@ def setUp(self): container_host=self.container_host, ) self.container.id = "someid" + self.container._initialize_concurrency_control() self.container.is_created = Mock() self.timeout = 1 @@ -888,7 +933,7 @@ def test_wait_for_result_no_error_image_response(self, mock_requests, patched_so mock_requests.post.assert_called_with( self.container.URL.format(host=host, port=port, function_name="function"), data=b"{}", - headers={}, + headers={"Content-Type": "application/json"}, timeout=(self.container.RAPID_CONNECTION_TIMEOUT, None), ) stdout_mock.write_bytes.assert_called_with(rie_response) @@ -950,7 +995,7 @@ def test_wait_for_result_no_error( mock_requests.post.assert_called_with( self.container.URL.format(host=host, port=port, function_name="function"), data=b"{}", - headers={}, + headers={"Content-Type": "application/json"}, timeout=(self.container.RAPID_CONNECTION_TIMEOUT, None), ) if response_deserializable: @@ -970,7 +1015,6 @@ def test_wait_for_result_error_retried(self, patched_sleep, mock_requests, patch output_itr = Mock() real_container_mock.attach.return_value = output_itr self.container._write_container_output = Mock() - stdout_mock = Mock() stderr_mock = Mock() self.container.rapid_port_host = "7077" @@ -991,19 +1035,19 @@ def test_wait_for_result_error_retried(self, patched_sleep, mock_requests, patch call( "http://localhost:7077/2015-03-31/functions/function/invocations", data=b"{}", - headers={}, + headers={"Content-Type": "application/json"}, timeout=(self.timeout, None), ), call( "http://localhost:7077/2015-03-31/functions/function/invocations", data=b"{}", - headers={}, + headers={"Content-Type": "application/json"}, timeout=(self.timeout, None), ), call( "http://localhost:7077/2015-03-31/functions/function/invocations", data=b"{}", - headers={}, + headers={"Content-Type": "application/json"}, timeout=(self.timeout, None), ), ], @@ -1029,7 +1073,6 @@ def test_wait_for_result_error(self, patched_sleep, mock_requests, patched_socke mock_requests.post.side_effect = ContainerResponseException() patched_socket.return_value = self.socket_mock - with self.assertRaises(ContainerResponseException): self.container.wait_for_result( event=self.event, full_path=self.name, stdout=stdout_mock, stderr=stderr_mock @@ -1056,7 +1099,6 @@ def test_wait_for_result_waits_for_socket_before_post_request(self, patched_time unsuccessful_socket_mock = Mock() unsuccessful_socket_mock.connect_ex.return_value = 22 patched_socket.return_value = unsuccessful_socket_mock - with self.assertRaises(ContainerConnectionTimeoutException): self.container.wait_for_result( event=self.event, full_path=self.name, stdout=stdout_mock, stderr=stderr_mock @@ -1403,3 +1445,331 @@ def test_resolves_symlink(self, mock_path, mock_realpath, mock_basename, mock_sc volumes = self.container._create_mapped_symlink_files() self.assertEqual(volumes, {host_path: {"bind": container_path, "mode": ANY}}) + + +class TestContainer_concurrency_control(TestCase): + def setUp(self): + self.container = Container( + image="test-image", + cmd=["test-cmd"], + working_dir="/test", + host_dir="/host", + memory_limit_mb=128, + exposed_ports={"8080": "8080"}, + entrypoint=["test-entrypoint"], + env_vars={"TEST_VAR": "test_value"}, + docker_client=Mock(), + container_host="127.0.0.1", + container_host_interface="127.0.0.1", + ) + self.assertIsNone(self.container._concurrency_semaphore) + + def test_initialize_concurrency_control_default(self): + """Test concurrency control initialization with default values""" + # No AWS_LAMBDA_MAX_CONCURRENCY in env vars + self.container._env_vars = {} + + self.container._initialize_concurrency_control() + + self.assertEqual(self.container._max_concurrency, 1) + self.assertIsNotNone(self.container._concurrency_semaphore) + self.assertEqual(self.container._concurrency_semaphore._value, 1) + + def test_initialize_concurrency_control_with_max_concurrency(self): + """Test concurrency control initialization with AWS_LAMBDA_MAX_CONCURRENCY""" + # Set AWS_LAMBDA_MAX_CONCURRENCY in env vars + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": "8"} + + self.container._initialize_concurrency_control() + + self.assertEqual(self.container._max_concurrency, 8) + self.assertIsNotNone(self.container._concurrency_semaphore) + self.assertEqual(self.container._concurrency_semaphore._value, 8) + + def test_initialize_concurrency_control_invalid_value(self): + """Test concurrency control initialization with invalid AWS_LAMBDA_MAX_CONCURRENCY""" + # Set invalid AWS_LAMBDA_MAX_CONCURRENCY in env vars + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": "invalid"} + + with patch("samcli.local.docker.container.LOG") as mock_log: + self.container._initialize_concurrency_control() + + # Should default to 1 and log warning + self.assertEqual(self.container._max_concurrency, 1) + self.assertIsNotNone(self.container._concurrency_semaphore) + self.assertEqual(self.container._concurrency_semaphore._value, 1) + mock_log.warning.assert_called_once() + + def test_initialize_concurrency_control_idempotent(self): + """Test that concurrency control initialization is idempotent (safe to call multiple times)""" + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": "4"} + + # Initialize once + self.container._initialize_concurrency_control() + first_semaphore = self.container._concurrency_semaphore + + # Initialize again - should not create a new semaphore + self.container._initialize_concurrency_control() + second_semaphore = self.container._concurrency_semaphore + + # Should be the same semaphore object + self.assertIs(first_semaphore, second_semaphore) + self.assertEqual(self.container._max_concurrency, 4) + self.assertEqual(self.container._concurrency_semaphore._value, 4) + + def test_initialize_concurrency_control_debug_mode_forces_concurrency_one(self): + """Test that debug mode forces concurrency to 1 regardless of AWS_LAMBDA_MAX_CONCURRENCY""" + + # Set high concurrency in env vars + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": "10"} + + # Set debug options with debug ports (indicating debug mode) + debug_options = DebugContext( + debug_ports=[5858], debugger_path="/path/to/debugger", debug_args="--debug", debug_function="test_func" + ) + self.container.debug_options = debug_options + + with patch("samcli.local.docker.container.LOG") as mock_log: + self.container._initialize_concurrency_control() + + # Should force concurrency to 1 in debug mode + self.assertEqual(self.container._max_concurrency, 1) + self.assertIsNotNone(self.container._concurrency_semaphore) + self.assertEqual(self.container._concurrency_semaphore._value, 1) + + # Should log container initialization + mock_log.debug.assert_any_call("Initialized container %s with max_concurrency=%d", "unknown", 1) + + def test_initialize_concurrency_control_no_debug_mode_uses_env_var(self): + """Test that non-debug mode uses AWS_LAMBDA_MAX_CONCURRENCY normally""" + + # Set high concurrency in env vars + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": "8"} + + # Set debug options without debug ports (not in debug mode) + debug_options = DebugContext(debug_ports=None, debugger_path=None, debug_args=None, debug_function="test_func") + self.container.debug_options = debug_options + + self.container._initialize_concurrency_control() + + # Should use the env var value since not in debug mode + self.assertEqual(self.container._max_concurrency, 8) + self.assertIsNotNone(self.container._concurrency_semaphore) + self.assertEqual(self.container._concurrency_semaphore._value, 8) + + def test_get_max_concurrency(self): + """Test get_max_concurrency method""" + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": "6"} + + # Initialize concurrency control (simulating what create() does) + self.container._initialize_concurrency_control() + + # Should return max concurrency without additional initialization + max_concurrency = self.container.get_max_concurrency() + + self.assertEqual(max_concurrency, 6) + self.assertIsNotNone(self.container._concurrency_semaphore) + + @patch("samcli.local.docker.container.requests.post") + def test_wait_for_http_response_single_thread(self, mock_post): + """Test HTTP response with single thread (traditional function)""" + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": "1"} + + # Initialize concurrency control (simulating what create() does) + self.container._initialize_concurrency_control() + + # Mock response properly + mock_response = Mock() + mock_response.text = "response" + mock_response.content = b'{"result": "success"}' + mock_response.headers = {} + mock_post.return_value = mock_response + + # Mock container properties + self.container.id = "test-container-id" + self.container.rapid_port_host = 8080 + self.container._container_host = "127.0.0.1" + + with patch("samcli.local.docker.container.LOG") as mock_log: + response, is_error = self.container.wait_for_http_response("test-function", "test-event", Mock()) + + self.assertEqual(response, '{"result": "success"}') + self.assertFalse(is_error) + + # Should log semaphore acquisition + mock_log.debug.assert_called() + + @patch("samcli.local.docker.container.requests.post") + def test_wait_for_http_response_multi_thread(self, mock_post): + """Test HTTP response with multiple threads (lmi function)""" + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": "3"} + + # Initialize concurrency control (simulating what create() does) + self.container._initialize_concurrency_control() + + # Mock response properly + mock_response = Mock() + mock_response.text = "response" + mock_response.content = b'{"result": "success"}' + mock_response.headers = {} + mock_post.return_value = mock_response + + # Mock container properties + self.container.id = "test-container-id" + self.container.rapid_port_host = 8080 + self.container._container_host = "127.0.0.1" + + results = [] + errors = [] + + def make_request(event_data): + try: + response, is_error = self.container.wait_for_http_response( + "test-function", f"event-{event_data}", Mock() + ) + results.append((response, is_error)) + except Exception as e: + errors.append(e) + + # Start multiple concurrent requests + threads = [] + for i in range(5): # More threads than max concurrency + thread = threading.Thread(target=make_request, args=(i,)) + threads.append(thread) + thread.start() + + # Wait for all threads to complete + for thread in threads: + thread.join() + + # All requests should complete successfully + self.assertEqual(len(results), 5) + self.assertEqual(len(errors), 0) + + # All should return the same response + for response, is_error in results: + self.assertEqual(response, '{"result": "success"}') + self.assertFalse(is_error) + + @patch("samcli.local.docker.container.requests.post") + def test_concurrency_semaphore_limiting(self, mock_post): + """Test that semaphore properly limits concurrent requests""" + max_concurrency = 2 + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": str(max_concurrency)} + + # Initialize concurrency control (simulating what create() does) + self.container._initialize_concurrency_control() + + # Track concurrent executions inside the semaphore + concurrent_count = 0 + max_concurrent_observed = 0 + lock = threading.Lock() + + # Mock slow response to test concurrency limiting + def slow_response(*args, **kwargs): + nonlocal concurrent_count, max_concurrent_observed + with lock: + concurrent_count += 1 + max_concurrent_observed = max(max_concurrent_observed, concurrent_count) + + time.sleep(0.1) # Simulate slow response + + with lock: + concurrent_count -= 1 + + response = Mock() + response.text = "response" + response.content = b'{"result": "success"}' + response.headers = {} + return response + + mock_post.side_effect = slow_response + + # Mock container properties + self.container.id = "test-container-id" + self.container.rapid_port_host = 8080 + self.container._container_host = "127.0.0.1" + + def make_request(): + self.container.wait_for_http_response("test-function", "test-event", Mock()) + + # Start more threads than max concurrency + threads = [] + for i in range(5): + thread = threading.Thread(target=make_request) + threads.append(thread) + thread.start() + + # Wait for all threads to complete + for thread in threads: + thread.join() + + # Should never exceed max concurrency + self.assertLessEqual(max_concurrent_observed, max_concurrency) + + @patch("samcli.local.docker.container.requests.post") + def test_semaphore_fallback_warning(self, mock_post): + """Test fallback behavior when semaphore is not available (edge case)""" + # Mock response properly + mock_response = Mock() + mock_response.text = "response" + mock_response.content = b'{"result": "success"}' + mock_response.headers = {} + mock_post.return_value = mock_response + + # Mock container properties + self.container.id = "test-container-id" + self.container.rapid_port_host = 8080 + self.container._container_host = "127.0.0.1" + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": "1"} + + with patch("samcli.local.docker.container.LOG") as mock_log: + response, is_error = self.container.wait_for_http_response("test-function", "test-event", Mock()) + + self.assertEqual(response, '{"result": "success"}') + self.assertFalse(is_error) + + # Should log warning about fallback + mock_log.warning.assert_called_with( + "Container concurrency control not initiated properly during container creation" + ) + + @parameterized.expand( + [ + ("1", 1), + ("4", 4), + ("8", 8), + ("16", 16), + ] + ) + def test_various_concurrency_levels(self, concurrency_str, expected_concurrency): + """Test various concurrency levels""" + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": concurrency_str} + + self.container._initialize_concurrency_control() + + self.assertEqual(self.container._max_concurrency, expected_concurrency) + self.assertEqual(self.container._concurrency_semaphore._value, expected_concurrency) + + def test_concurrency_control_logging(self): + """Test that concurrency control logs appropriate debug information""" + self.container._env_vars = {"AWS_LAMBDA_MAX_CONCURRENCY": "4"} + self.container.id = "test-container-id" + + with patch("samcli.local.docker.container.LOG") as mock_log: + self.container._initialize_concurrency_control() + + # Should log initialization + mock_log.debug.assert_called_with( + "Initialized container %s with max_concurrency=%d", "test-container-id", 4 + ) + + def test_concurrency_without_env_vars(self): + """Test concurrency control when no environment variables are set""" + self.container._env_vars = None + + self.container._initialize_concurrency_control() + + self.assertEqual(self.container._max_concurrency, 1) + self.assertIsNotNone(self.container._concurrency_semaphore) + self.assertEqual(self.container._concurrency_semaphore._value, 1) diff --git a/tests/unit/local/docker/test_lambda_container.py b/tests/unit/local/docker/test_lambda_container.py index a93f6de6b53..2d9777b2dd4 100644 --- a/tests/unit/local/docker/test_lambda_container.py +++ b/tests/unit/local/docker/test_lambda_container.py @@ -491,6 +491,80 @@ def test_must_fail_for_unsupported_runtime(self, mock_get_validated_client): "Unsupported Lambda runtime: foo. For a list of supported runtimes, please visit https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html", ) + @patch("samcli.local.docker.utils.get_validated_container_client") + @patch.object(LambdaContainer, "_get_image") + @patch.object(LambdaContainer, "_get_exposed_ports") + @patch.object(LambdaContainer, "_get_debug_settings") + @patch.object(LambdaContainer, "_get_additional_options") + @patch.object(LambdaContainer, "_get_additional_volumes") + def test_debug_options_attribute_is_set( + self, + get_additional_volumes_mock, + get_additional_options_mock, + get_debug_settings_mock, + get_exposed_ports_mock, + get_image_mock, + mock_get_validated_client, + ): + """Test that the debug_options attribute is properly set on the container""" + image = "test-image" + ports = {"5858": "5858"} + addtl_options = {} + addtl_volumes = {} + debug_settings = (["/var/rapid/aws-lambda-rie"], {}) + + get_image_mock.return_value = image + get_exposed_ports_mock.return_value = ports + get_debug_settings_mock.return_value = debug_settings + get_additional_options_mock.return_value = addtl_options + get_additional_volumes_mock.return_value = addtl_volumes + + # Mock the docker client + docker_client_mock = Mock() + mock_get_validated_client.return_value = docker_client_mock + + image_builder_mock = Mock() + + # Test with debug options + debug_options = DebugContext( + debug_ports=[5858], debugger_path="/path/to/debugger", debug_args="--debug", debug_function="test_func" + ) + + container = LambdaContainer( + runtime=self.runtime, + imageuri=self.imageuri, + handler=self.handler, + packagetype=self.packagetype, + image_config=self.image_config, + code_dir=self.code_dir, + layers=[], + lambda_image=image_builder_mock, + architecture="x86_64", + debug_options=debug_options, + function_full_path=self.function_name, + ) + + # Verify that debug_options attribute is set correctly + self.assertEqual(container.debug_options, debug_options) + + # Test with None debug options + container_no_debug = LambdaContainer( + runtime=self.runtime, + imageuri=self.imageuri, + handler=self.handler, + packagetype=self.packagetype, + image_config=self.image_config, + code_dir=self.code_dir, + layers=[], + lambda_image=image_builder_mock, + architecture="x86_64", + debug_options=None, + function_full_path=self.function_name, + ) + + # Verify that debug_options attribute is None when no debug options provided + self.assertIsNone(container_no_debug.debug_options) + @parameterized.expand( [ ( diff --git a/tests/unit/local/lambda_service/test_lambda_error_responses.py b/tests/unit/local/lambda_service/test_lambda_error_responses.py index fa70deb0f0a..47190f0ea41 100644 --- a/tests/unit/local/lambda_service/test_lambda_error_responses.py +++ b/tests/unit/local/lambda_service/test_lambda_error_responses.py @@ -26,18 +26,9 @@ def test_invalid_request_content(self, service_response_mock): response = LambdaErrorResponses.invalid_request_content("InvalidRequestContent") self.assertEqual(response, "InvalidRequestContent") - - @patch("samcli.local.services.base_local_service.BaseLocalService.service_response") - def test_validation_exception(self, service_response_mock): - service_response_mock.return_value = "ValidationException" - - response = LambdaErrorResponses.validation_exception("ValidationException") - - self.assertEqual(response, "ValidationException") - service_response_mock.assert_called_once_with( - '{"Type": "User", "Message": "ValidationException"}', - {"x-amzn-errortype": "ValidationException", "Content-Type": "application/json"}, + '{"Type": "User", "Message": "InvalidRequestContent"}', + {"x-amzn-errortype": "InvalidRequestContent", "Content-Type": "application/json"}, 400, ) diff --git a/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py b/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py index 4bca1620d43..d9feacaabad 100644 --- a/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py +++ b/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py @@ -6,7 +6,6 @@ from samcli.local.lambda_service.local_lambda_invoke_service import LocalLambdaInvokeService, FunctionNamePathConverter from samcli.local.lambdafn.exceptions import FunctionNotFound from samcli.commands.local.lib.exceptions import UnsupportedInlineCodeError -from samcli.lib.utils.name_utils import InvalidFunctionNameException class TestLocalLambdaService(TestCase): diff --git a/tests/unit/local/lambdafn/test_env_vars.py b/tests/unit/local/lambdafn/test_env_vars.py index 4259028de9c..693da7705d8 100644 --- a/tests/unit/local/lambdafn/test_env_vars.py +++ b/tests/unit/local/lambdafn/test_env_vars.py @@ -6,6 +6,8 @@ from unittest import TestCase from unittest.mock import patch from samcli.local.lambdafn.env_vars import EnvironmentVariables +from samcli.lib.providers.provider import CapacityProviderConfig +from samcli.lib.providers.provider import CapacityProviderConfig class TestEnvironmentVariables_init(TestCase): @@ -67,6 +69,38 @@ def test_must_initialize_with_optional_values(self): self.assertEqual(environ.override_values, {"e": "f"}) self.assertEqual(environ.aws_creds, {"g": "h"}) + def test_must_initialize_with_capacity_provider_config(self): + name = "name" + memory = 1024 + timeout = 123 + handler = "handler" + variables = {"a": "b"} + shell_values = {"c": "d"} + overrides = {"e": "f"} + aws_creds = {"g": "h"} + capacity_provider_configuration = CapacityProviderConfig( + arn="arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name", + execution_environment_max_concurrency=8, + ) + + environ = EnvironmentVariables( + name, + memory, + timeout, + handler, + variables=variables, + shell_env_values=shell_values, + override_values=overrides, + aws_creds=aws_creds, + capacity_provider_configuration=capacity_provider_configuration, + ) + + self.assertEqual(environ.variables, {"a": "b"}) + self.assertEqual(environ.shell_env_values, {"c": "d"}) + self.assertEqual(environ.override_values, {"e": "f"}) + self.assertEqual(environ.aws_creds, {"g": "h"}) + self.assertEqual(environ.capacity_provider_configuration, capacity_provider_configuration) + class TestEnvironmentVariables_resolve(TestCase): def setUp(self): @@ -256,6 +290,104 @@ def test_with_overrides_value(self): self.assertEqual(environ.resolve(), expected) + def test_with_capacity_provider_config(self): + """ + Given capacity provider configuration with PerExecutionEnvironmentMaxConcurrency + """ + capacity_provider_config = CapacityProviderConfig( + arn="arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name", + execution_environment_max_concurrency=8, + ) + + expected = { + "AWS_SAM_LOCAL": "true", + "AWS_LAMBDA_MAX_CONCURRENCY": "8", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_TIMEOUT": "123", + "AWS_LAMBDA_FUNCTION_HANDLER": "handler", + "AWS_LAMBDA_FUNCTION_NAME": self.name, + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_LOG_GROUP_NAME": f"aws/lambda/{self.name}", + "AWS_LAMBDA_LOG_STREAM_NAME": "$LATEST", + "AWS_ACCOUNT_ID": "123456789012", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_REGION": "us-east-1", + "AWS_ACCESS_KEY_ID": "defaultkey", + "AWS_SECRET_ACCESS_KEY": "defaultsecret", + # This value is coming from user passed environment variable + "AWS_DEFAULT_REGION": "user-specified-region", + "variable2": "mystring", + # Value coming from the overrides + "variable1": "variable1 value from overrides", + "list_var": "list value coming from overrides", + "dict_var": "", + "none_var": "", + "true_var": "true", + "false_var": "false", + } + + environ = EnvironmentVariables( + self.name, + self.memory, + self.timeout, + self.handler, + variables=self.variables, + shell_env_values=self.shell_env, + override_values=self.override, + capacity_provider_configuration=capacity_provider_config, + ) + + self.assertEqual(environ.resolve(), expected) + + def test_with_capacity_provider_config_default_concurrency(self): + """ + Given capacity provider configuration without PerExecutionEnvironmentMaxConcurrency (should default to 4) + """ + capacity_provider_config = CapacityProviderConfig( + arn="arn:aws:lambda:us-east-1:123456789012:capacity-provider:my-capacity-provider-name" + # execution_environment_max_concurrency will default to 4 + ) + + expected = { + "AWS_SAM_LOCAL": "true", + "AWS_LAMBDA_MAX_CONCURRENCY": "4", # Default value + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_TIMEOUT": "123", + "AWS_LAMBDA_FUNCTION_HANDLER": "handler", + "AWS_LAMBDA_FUNCTION_NAME": self.name, + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_LOG_GROUP_NAME": f"aws/lambda/{self.name}", + "AWS_LAMBDA_LOG_STREAM_NAME": "$LATEST", + "AWS_ACCOUNT_ID": "123456789012", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_REGION": "us-east-1", + "AWS_ACCESS_KEY_ID": "defaultkey", + "AWS_SECRET_ACCESS_KEY": "defaultsecret", + # This value is coming from user passed environment variable + "AWS_DEFAULT_REGION": "user-specified-region", + "variable2": "mystring", + # Value coming from the overrides + "variable1": "variable1 value from overrides", + "list_var": "list value coming from overrides", + "dict_var": "", + "none_var": "", + "true_var": "true", + "false_var": "false", + } + + environ = EnvironmentVariables( + self.name, + self.memory, + self.timeout, + self.handler, + variables=self.variables, + shell_env_values=self.shell_env, + override_values=self.override, + capacity_provider_configuration=capacity_provider_config, + ) + + self.assertEqual(environ.resolve(), expected) + class TestEnvironmentVariables_get_aws_variables(TestCase): def setUp(self): diff --git a/tests/unit/local/lambdafn/test_runtime.py b/tests/unit/local/lambdafn/test_runtime.py index 0296eef3509..1b941fc5a0b 100644 --- a/tests/unit/local/lambdafn/test_runtime.py +++ b/tests/unit/local/lambdafn/test_runtime.py @@ -3,15 +3,22 @@ """ from unittest import TestCase -from unittest.mock import Mock, patch, MagicMock, ANY, call +from unittest.mock import Mock, patch, MagicMock, ANY, call, PropertyMock from parameterized import parameterized from samcli.lib.utils.packagetype import ZIP, IMAGE from samcli.lib.providers.provider import LayerVersion from samcli.local.lambdafn.env_vars import EnvironmentVariables -from samcli.local.lambdafn.runtime import LambdaRuntime, _unzip_file, WarmLambdaRuntime, _require_container_reloading +from samcli.local.lambdafn.runtime import ( + LambdaRuntime, + _unzip_file, + WarmLambdaRuntime, + _require_container_reloading, + _should_reload_container, +) from samcli.local.lambdafn.config import FunctionConfig from samcli.local.docker.container import ContainerContext +from samcli.commands.local.lib.debug_context import DebugContext class LambdaRuntime_create(TestCase): @@ -963,7 +970,15 @@ def test_must_return_cached_container(self, LambdaContainerMock, LambdaFunctionO self.runtime._get_code_dir.return_value = code_dir LambdaContainerMock.return_value = container - self.runtime.create(self.func_config, debug_context=debug_options) + + # Mock the container's is_created method and debug_options property + container.is_created.return_value = True + container.debug_options = debug_options + + # First call - creates container + first_result = self.runtime.create(self.func_config, debug_context=debug_options) + + # Second call - should reuse existing container since debug_context matches result = self.runtime.create(self.func_config, debug_context=debug_options) # validate that the manager.create method got called only one time @@ -1979,3 +1994,132 @@ def test_on_code_change_with_image_package_type_logs_image_resource(self): # The log format is: "Lambda Function '%s' %s has been changed..." # where %s is function_full_path and %s is resource (imageuri + " image") self.assertIn("my-image:latest image", log_call_args[2]) + + +class TestShouldReloadContainer(TestCase): + """Test _should_reload_container function""" + + def setUp(self): + """Set up test fixtures""" + self.base_config = FunctionConfig( + name="test_func", + full_path="stack/test_func", + runtime="python3.9", + handler="app.handler", + imageuri=None, + imageconfig=None, + packagetype=ZIP, + code_abs_path="/tmp/code", + layers=[], + architecture="x86_64", + memory=128, + timeout=30, + env_vars=EnvironmentVariables(), + ) + + self.different_config = FunctionConfig( + name="test_func", + full_path="stack/test_func", + runtime="python3.11", # Different runtime + handler="app.handler", + imageuri=None, + imageconfig=None, + packagetype=ZIP, + code_abs_path="/tmp/code", + layers=[], + architecture="x86_64", + memory=128, + timeout=30, + env_vars=EnvironmentVariables(), + ) + + def test_should_not_reload_when_no_existing_config_and_no_container(self): + """Test that reload is not needed when there's no existing config and no container""" + result = _should_reload_container(None, self.base_config, None, None) + self.assertFalse(result) + + def test_should_not_reload_when_same_config_and_same_debug_context(self): + """Test that reload is not needed when config and debug context are the same""" + + debug_context = DebugContext( + debug_ports=[5858], debugger_path="/path/to/debugger", debug_args="--debug-args", debug_function="test_func" + ) + + container = Mock() + container.debug_options = debug_context + + result = _should_reload_container(self.base_config, self.base_config, container, debug_context) + self.assertFalse(result) + + def test_should_reload_when_function_config_changed(self): + """Test that reload is needed when function configuration changes""" + container = Mock() + container.debug_options = None + + result = _should_reload_container(self.base_config, self.different_config, container, None) + self.assertTrue(result) + + def test_should_reload_when_debug_context_changed(self): + """Test that reload is needed when debug context changes""" + + debug_context1 = DebugContext( + debug_ports=[5858], debugger_path="/path/to/debugger", debug_args="--debug-args", debug_function="test_func" + ) + + debug_context2 = DebugContext( + debug_ports=[9229], debugger_path="/new/path", debug_args="--new-args", debug_function="test_func" + ) + + container = Mock() + container.debug_options = debug_context1 + + result = _should_reload_container(self.base_config, self.base_config, container, debug_context2) + self.assertTrue(result) + + def test_should_reload_when_debug_context_changes_to_none(self): + """Test that reload is needed when debug context changes from something to None""" + + debug_context = DebugContext( + debug_ports=[5858], debugger_path="/path/to/debugger", debug_args="--debug-args", debug_function="test_func" + ) + + container = Mock() + container.debug_options = debug_context + + result = _should_reload_container(self.base_config, self.base_config, container, None) + self.assertTrue(result) + + def test_should_reload_when_debug_context_changes_from_none(self): + """Test that reload is needed when debug context changes from None to something""" + + debug_context = DebugContext( + debug_ports=[5858], debugger_path="/path/to/debugger", debug_args="--debug-args", debug_function="test_func" + ) + + container = Mock() + container.debug_options = None + + result = _should_reload_container(self.base_config, self.base_config, container, debug_context) + self.assertTrue(result) + + def test_should_not_reload_when_no_container(self): + """Test that reload is not needed when there's no container and no config changes""" + result = _should_reload_container(self.base_config, self.base_config, None, None) + self.assertFalse(result) + + def test_should_reload_when_both_config_and_debug_context_changed(self): + """Test that reload is needed when both config and debug context change""" + + debug_context1 = DebugContext( + debug_ports=[5858], debugger_path="/path/to/debugger", debug_args="--debug-args", debug_function="test_func" + ) + + debug_context2 = DebugContext( + debug_ports=[9229], debugger_path="/new/path", debug_args="--new-args", debug_function="test_func" + ) + + container = Mock() + container.debug_options = debug_context1 + + result = _should_reload_container(self.base_config, self.different_config, container, debug_context2) + self.assertTrue(result)