diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ea417e5..5129f728d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Release 2026-05-07 + +### AWSLambdaPSCore PowerShell Module (5.1.0) +* Reduce Lambda cold start INIT times by stripping files that are not used at runtime (PowerShell help XML and .pdb debug symbols) from AWS-authored modules (AWSPowerShell.NetCore and AWS.Tools.*) during packaging + ## Release 2026-05-06 ### Amazon.Lambda.RuntimeSupport (2.0.0) diff --git a/PowerShell/Module/AWSLambdaPSCore.psd1 b/PowerShell/Module/AWSLambdaPSCore.psd1 index 64d14463e..37559e689 100644 --- a/PowerShell/Module/AWSLambdaPSCore.psd1 +++ b/PowerShell/Module/AWSLambdaPSCore.psd1 @@ -12,7 +12,7 @@ RootModule = 'AWSLambdaPSCore.psm1' # Version number of this module. -ModuleVersion = '5.0.0.0' +ModuleVersion = '5.1.0.0' # Supported PSEditions CompatiblePSEditions = 'Core' diff --git a/PowerShell/Module/Private/_Constants.ps1 b/PowerShell/Module/Private/_Constants.ps1 index a97e1e383..95b0d070c 100644 --- a/PowerShell/Module/Private/_Constants.ps1 +++ b/PowerShell/Module/Private/_Constants.ps1 @@ -31,4 +31,33 @@ if (!($AwsPowerShellTargetFramework)) if (!($AwsPowerShellLambdaRuntime)) { New-Variable -Name AwsPowerShellLambdaRuntime -Value 'dotnet10' -Option Constant +} + +if (!($AwsModuleStripFilters)) +{ + # File patterns inside AWS-authored PowerShell modules that have no purpose at + # Lambda runtime (no interactive shell, no Get-Help, no debugger). Stripping + # them reduces package size and INIT (cold-start) duration. LICENSE / NOTICE + # files are intentionally retained. + # + # The '*.xml' pattern matches case-insensitively, covering: + # - .dll-Help.xml — PowerShell MAML help + # - .XML — .NET XMLDoc compiler output (IntelliSense data) + # - PSGetModuleInfo.xml — PowerShellGet install metadata + # Format.ps1xml / Types.ps1xml are NOT matched because their extension is + # .ps1xml (not .xml) and the wildcard requires a literal '.xml' suffix. + New-Variable -Name AwsModuleStripFilters -Value @( + '*.xml', + '*.pdb' + ) -Option Constant +} + +if (!($AwsAuthoredModuleNamePatterns)) +{ + # Only AWS-authored modules under Modules/ are stripped; third-party / community + # modules are left untouched. + New-Variable -Name AwsAuthoredModuleNamePatterns -Value @( + 'AWSPowerShell.NetCore', + 'AWS.Tools.*' + ) -Option Constant } \ No newline at end of file diff --git a/PowerShell/Module/Private/_DeploymentFunctions.ps1 b/PowerShell/Module/Private/_DeploymentFunctions.ps1 index 96c81bc7b..3ac8c2f2c 100644 --- a/PowerShell/Module/Private/_DeploymentFunctions.ps1 +++ b/PowerShell/Module/Private/_DeploymentFunctions.ps1 @@ -479,6 +479,57 @@ function _formatArray return $sb.ToString() } +function _stripAwsModuleFiles +{ + param + ( + [Parameter(Mandatory = $true)] + [string]$ModulesRoot, + + [Parameter(Mandatory = $true)] + [string[]]$Filters, + + [Parameter(Mandatory = $true)] + [string[]]$ModuleNamePatterns + ) + + if (!(Test-Path -Path $ModulesRoot)) + { + return + } + + foreach ($moduleDir in (Get-ChildItem -Path $ModulesRoot -Directory)) + { + $matchesAws = $false + foreach ($pattern in $ModuleNamePatterns) + { + if ($moduleDir.Name -like $pattern) + { + $matchesAws = $true + break + } + } + + if (-not $matchesAws) + { + continue + } + + $removed = Get-ChildItem -Path $moduleDir.FullName -Recurse -File -Include $Filters -ErrorAction SilentlyContinue + foreach ($file in $removed) + { + Write-Verbose ('Removing AWS module file: {0}' -f $file.FullName) + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue + } + + $count = ($removed | Measure-Object).Count + if ($count -gt 0) + { + Write-Verbose ('Stripped {0} unwanted file(s) from AWS module {1}' -f $count, $moduleDir.Name) + } + } +} + function _prepareDependentPowerShellModules { param @@ -567,6 +618,11 @@ function _prepareDependentPowerShellModules } ## Add verbosity that no RequiredModules found else {Write-Verbose "No RequiredModules found for script '$Script'"} + + _stripAwsModuleFiles ` + -ModulesRoot $SavedModulesDirectory ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns } function _findLocalModule diff --git a/PowerShell/Tests/Get-AWSPowerShelLambdaTemplate.Tests.ps1 b/PowerShell/Tests/Get-AWSPowerShelLambdaTemplate.Tests.ps1 index dd5fffe83..54169af12 100644 --- a/PowerShell/Tests/Get-AWSPowerShelLambdaTemplate.Tests.ps1 +++ b/PowerShell/Tests/Get-AWSPowerShelLambdaTemplate.Tests.ps1 @@ -7,31 +7,33 @@ Import-Module $moduleManifestPath InModuleScope -ModuleName $module -ScriptBlock { Describe -Name 'Get-AWSPowerShellLambdaTemplate' -Fixture { - function LoadFakeData - { - ConvertTo-Json -InputObject @{ - manifestVersion = 1 - blueprints = @( - @{ - name = 'Basic' - description = 'Bare bones script' - content = @( - @{ - source = 'basic.ps1.txt' - output = '{basename}.ps1' - filetype = 'lambdaFunction' - }, - @{ - source = 'readme.txt' - output = 'readme.txt' - } - ) - } - ) + BeforeAll { + function LoadFakeData + { + ConvertTo-Json -InputObject @{ + manifestVersion = 1 + blueprints = @( + @{ + name = 'Basic' + description = 'Bare bones script' + content = @( + @{ + source = 'basic.ps1.txt' + output = '{basename}.ps1' + filetype = 'lambdaFunction' + }, + @{ + source = 'readme.txt' + output = 'readme.txt' + } + ) + } + ) + } } + Mock -CommandName '_getHostedBlueprintsContent' -MockWith {LoadFakeData} + Mock -CommandName '_getLocalBlueprintsContent' -MockWith {LoadFakeData} } - Mock -CommandName '_getHostedBlueprintsContent' -MockWith {LoadFakeData} - Mock -CommandName '_getLocalBlueprintsContent' -MockWith {LoadFakeData} Context -Name 'Online Templates' -Fixture { It -Name 'Retrieves Blueprints from online sources by default' -Test { diff --git a/PowerShell/Tests/_stripAwsModuleFiles.Tests.ps1 b/PowerShell/Tests/_stripAwsModuleFiles.Tests.ps1 new file mode 100644 index 000000000..25de6131c --- /dev/null +++ b/PowerShell/Tests/_stripAwsModuleFiles.Tests.ps1 @@ -0,0 +1,251 @@ +$module = 'AWSLambdaPSCore' +$moduleManifestPath = [System.IO.Path]::Combine('..', 'Module', "$module.psd1") + +if (Get-Module -Name $module) {Remove-Module -Name $module} +Import-Module $moduleManifestPath + + InModuleScope -ModuleName $module -ScriptBlock { + Describe -Name '_stripAwsModuleFiles' -Fixture { + + BeforeAll { + function New-FakeModuleDir + { + param + ( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter()][string[]]$ExtraFiles = @() + ) + + $dir = Join-Path -Path $Root -ChildPath $Name + New-Item -ItemType Directory -Path $dir -Force | Out-Null + + $defaults = @( + 'AWS.Tools.S3.dll-Help.xml', + 'AWS.Tools.S3-Help.xml', + 'LICENSE', + 'LICENSE.txt', + 'NOTICE', + 'NOTICE.txt', + 'AWS.Tools.S3.pdb', + 'AWS.Tools.S3.psm1', + 'AWS.Tools.S3.psd1' + ) + foreach ($f in ($defaults + $ExtraFiles)) + { + Set-Content -Path (Join-Path -Path $dir -ChildPath $f) -Value 'x' -Force + } + return $dir + } + } + + Context -Name 'AWS-authored module directories' -Fixture { + + It -Name 'Strips unwanted files from AWS.Tools.* modules' -Test { + $root = Join-Path -Path $TestDrive -ChildPath 'aws-tools' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $moduleDir = New-FakeModuleDir -Root $root -Name 'AWS.Tools.S3' + + _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns + + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.dll-Help.xml') | Should -BeFalse + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3-Help.xml') | Should -BeFalse + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.pdb') | Should -BeFalse + Test-Path -Path (Join-Path $moduleDir 'LICENSE') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'LICENSE.txt') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'NOTICE') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'NOTICE.txt') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.psm1') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.psd1') | Should -BeTrue + } + + It -Name 'Strips unwanted files from AWSPowerShell.NetCore (exact-name match)' -Test { + $root = Join-Path -Path $TestDrive -ChildPath 'monolithic' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $moduleDir = New-FakeModuleDir -Root $root -Name 'AWSPowerShell.NetCore' + + _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns + + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.pdb') | Should -BeFalse + Test-Path -Path (Join-Path $moduleDir 'LICENSE') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.psm1') | Should -BeTrue + } + + It -Name 'Recurses into nested version subdirectories' -Test { + $root = Join-Path -Path $TestDrive -ChildPath 'versioned' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $versionDir = Join-Path -Path $root -ChildPath 'AWS.Tools.EC2\1.2.3' + New-Item -ItemType Directory -Path $versionDir -Force | Out-Null + Set-Content -Path (Join-Path $versionDir 'AWS.Tools.EC2.dll-Help.xml') -Value 'x' + Set-Content -Path (Join-Path $versionDir 'AWS.Tools.EC2.pdb') -Value 'x' + Set-Content -Path (Join-Path $versionDir 'AWS.Tools.EC2.psm1') -Value 'x' + + _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns + + Test-Path -Path (Join-Path $versionDir 'AWS.Tools.EC2.dll-Help.xml') | Should -BeFalse + Test-Path -Path (Join-Path $versionDir 'AWS.Tools.EC2.pdb') | Should -BeFalse + Test-Path -Path (Join-Path $versionDir 'AWS.Tools.EC2.psm1') | Should -BeTrue + } + } + + Context -Name 'Non-AWS module directories' -Fixture { + + It -Name 'Leaves third-party modules untouched' -Test { + $root = Join-Path -Path $TestDrive -ChildPath 'third-party' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $moduleDir = New-FakeModuleDir -Root $root -Name 'Pester' + + _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns + + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.dll-Help.xml') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'LICENSE') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'NOTICE') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.pdb') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.psm1') | Should -BeTrue + } + + It -Name 'Strips AWS module while leaving co-resident third-party module untouched' -Test { + $root = Join-Path -Path $TestDrive -ChildPath 'mixed' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $awsDir = New-FakeModuleDir -Root $root -Name 'AWS.Tools.Lambda' + $otherDir = New-FakeModuleDir -Root $root -Name 'PSReadLine' + + _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns + + # AWS module: .pdb stripped, LICENSE retained + Test-Path -Path (Join-Path $awsDir 'AWS.Tools.S3.pdb') | Should -BeFalse + Test-Path -Path (Join-Path $awsDir 'LICENSE') | Should -BeTrue + + # Third-party module: nothing touched + Test-Path -Path (Join-Path $otherDir 'AWS.Tools.S3.pdb') | Should -BeTrue + Test-Path -Path (Join-Path $otherDir 'LICENSE') | Should -BeTrue + } + } + + Context -Name 'Files intentionally retained' -Fixture { + + It -Name 'Does not remove LICENSE or NOTICE files (Apache 2.0 retention)' -Test { + $root = Join-Path -Path $TestDrive -ChildPath 'license-retained' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $moduleDir = New-FakeModuleDir -Root $root -Name 'AWS.Tools.S3' + + _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns + + Test-Path -Path (Join-Path $moduleDir 'LICENSE') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'LICENSE.txt') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'NOTICE') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'NOTICE.txt') | Should -BeTrue + } + + It -Name 'Does not remove Format.ps1xml or Types.ps1xml files' -Test { + $root = Join-Path -Path $TestDrive -ChildPath 'narrow-xml' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $moduleDir = New-FakeModuleDir -Root $root -Name 'AWS.Tools.S3' -ExtraFiles @( + 'AWS.Tools.S3.Format.ps1xml', + 'AWS.Tools.S3.Types.ps1xml' + ) + + _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns + + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.dll-Help.xml') | Should -BeFalse + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.Format.ps1xml') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.Types.ps1xml') | Should -BeTrue + } + + It -Name 'Strips XMLDoc (.XML) and PSGetModuleInfo.xml' -Test { + $root = Join-Path -Path $TestDrive -ChildPath 'xmldoc' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $moduleDir = New-FakeModuleDir -Root $root -Name 'AWS.Tools.S3' -ExtraFiles @( + 'AWS.Tools.S3.XML', + 'PSGetModuleInfo.xml' + ) + + _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns + + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.XML') | Should -BeFalse + Test-Path -Path (Join-Path $moduleDir 'PSGetModuleInfo.xml') | Should -BeFalse + } + + It -Name 'Does not remove Aliases.psm1 or Completers.psm1' -Test { + $root = Join-Path -Path $TestDrive -ChildPath 'preserve-nested' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $moduleDir = New-FakeModuleDir -Root $root -Name 'AWS.Tools.S3' -ExtraFiles @( + 'AWS.Tools.S3.Aliases.psm1', + 'AWS.Tools.S3.Completers.psm1' + ) + + _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns + + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.Aliases.psm1') | Should -BeTrue + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.Completers.psm1') | Should -BeTrue + } + } + + Context -Name 'Robustness' -Fixture { + + It -Name 'Is idempotent (second run is a clean no-op)' -Test { + $root = Join-Path -Path $TestDrive -ChildPath 'idempotent' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $moduleDir = New-FakeModuleDir -Root $root -Name 'AWS.Tools.S3' + + _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns + + { _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns } | Should -Not -Throw + + Test-Path -Path (Join-Path $moduleDir 'AWS.Tools.S3.psm1') | Should -BeTrue + } + + It -Name 'No-ops gracefully when ModulesRoot does not exist' -Test { + $missing = Join-Path -Path $TestDrive -ChildPath 'does-not-exist' + + { _stripAwsModuleFiles ` + -ModulesRoot $missing ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns } | Should -Not -Throw + } + + It -Name 'No-ops on empty ModulesRoot' -Test { + $root = Join-Path -Path $TestDrive -ChildPath 'empty-root' + New-Item -ItemType Directory -Path $root -Force | Out-Null + + { _stripAwsModuleFiles ` + -ModulesRoot $root ` + -Filters $AwsModuleStripFilters ` + -ModuleNamePatterns $AwsAuthoredModuleNamePatterns } | Should -Not -Throw + } + } + } # End Describe +} # End InModuleScope