diff --git a/Libraries/src/Amazon.Lambda.PowerShellHost/PowerShellFunctionHost.cs b/Libraries/src/Amazon.Lambda.PowerShellHost/PowerShellFunctionHost.cs index 52012c1ca..74d62ee57 100644 --- a/Libraries/src/Amazon.Lambda.PowerShellHost/PowerShellFunctionHost.cs +++ b/Libraries/src/Amazon.Lambda.PowerShellHost/PowerShellFunctionHost.cs @@ -33,6 +33,13 @@ public abstract class PowerShellFunctionHost private readonly string _powerShellScriptFileName; private string _powerShellScriptFileContent; + // Cached result of detecting whether a subclass has overridden LoadScript(). + // When overridden, the host must execute the override's returned text instead of + // invoking the file directly, to preserve the pre-5.x contract that override return + // values are always executed. Lazy + short-circuit: only evaluated when a script + // file is present, then cached. + private bool? _isLoadScriptOverriddenCache; + // The PowerShell Object for executing PowerShell code private readonly PowerShell _ps; @@ -155,28 +162,9 @@ private IAsyncResult BeginInvoke(string input, ILambdaContext context) _ps.Runspace?.ResetRunspaceState(); _output.Clear(); - var providedScript = LoadScript(input, context); - - - string executingScript = -@" -Param( - [string]$LambdaInputString, - [Amazon.Lambda.Core.ILambdaContext]$LambdaContext -) - -$LambdaInput = ConvertFrom-Json -InputObject $LambdaInputString - -"; - var isLambda = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("LAMBDA_TASK_ROOT")); - var tempFolder = isLambda ? "/tmp" : Path.GetTempPath(); - executingScript += $"{Environment.NewLine}$env:TEMP=\"{tempFolder}\""; - executingScript += $"{Environment.NewLine}$env:TMP=\"{tempFolder}\""; - executingScript += $"{Environment.NewLine}$env:TMPDIR=\"{tempFolder}\"{Environment.NewLine}"; - if(isLambda && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HOME"))) { // Make sure to set HOME directory to avoid issue with using the -Parallel PowerShell feature. This works around @@ -185,18 +173,64 @@ private IAsyncResult BeginInvoke(string input, ILambdaContext context) Environment.SetEnvironmentVariable("HOME", $"{tempFolder}/home"); } - executingScript += providedScript; + // Set environment variables and Lambda input/context as global variables. + // Using $global: makes them visible in all scopes including the user's script. + string setupScript = $@" +$env:TEMP = '{tempFolder}' +$env:TMP = '{tempFolder}' +$env:TMPDIR = '{tempFolder}' +$global:LambdaInputString = $args[0] +$global:LambdaInput = ConvertFrom-Json -InputObject $args[0] +$global:LambdaContext = $args[1] +"; - if (!string.IsNullOrEmpty(PowerShellFunctionName)) + _ps.AddScript(setupScript, useLocalScope: false); + _ps.AddArgument(input); + _ps.AddArgument(context); + var setupResult = _ps.BeginInvoke(); + WaitPowerShellExecution(setupResult); + _ps.Commands.Clear(); + + // Execute the user's script. Use the new AddCommand path only when a file exists + // AND no subclass has overridden LoadScript(). Reflection is short-circuited away + // entirely when no file path was provided (the common S3-fetched-script case). + if (!string.IsNullOrEmpty(_powerShellScriptFileName) + && File.Exists(_powerShellScriptFileName) + && !IsLoadScriptOverridden()) { - executingScript += $"{Environment.NewLine}{PowerShellFunctionName} $LambdaInput $LambdaContext{Environment.NewLine}"; - } + var scriptFullPath = Path.GetFullPath(_powerShellScriptFileName); + if (!string.IsNullOrEmpty(PowerShellFunctionName)) + { + // Dot-source the script so functions it defines are visible in the current scope, + // then call the named function. + _ps.AddScript( + $". '{scriptFullPath}'{Environment.NewLine}{PowerShellFunctionName} $global:LambdaInput $global:LambdaContext", + useLocalScope: false); + } + else + { + // Invoke the script file directly. PowerShell resolves it as ExternalScript, + // populating $PSScriptRoot, $PSCommandPath, $MyInvocation, etc. + var command = new Command(scriptFullPath, isScript: true, useLocalScope: false); + _ps.Commands.AddCommand(command); + } + } + else + { + // Either no file on disk, or a subclass has overridden LoadScript() and we + // must execute its returned text rather than invoking the file directly. + // Automatic variables will be empty in both cases since there is no backing + // file from PowerShell's perspective. + var providedScript = LoadScript(input, context); - _ps.AddScript(executingScript); - _ps.AddParameter("LambdaInputString", input); - _ps.AddParameter("LambdaContext", context); + if (!string.IsNullOrEmpty(PowerShellFunctionName)) + { + providedScript += $"{Environment.NewLine}{PowerShellFunctionName} $global:LambdaInput $global:LambdaContext{Environment.NewLine}"; + } + _ps.AddScript(providedScript, useLocalScope: false); + } return _ps.BeginInvoke(null, _output); } @@ -229,6 +263,29 @@ protected virtual string LoadScript(string input, ILambdaContext context) return _powerShellScriptFileContent; } + // Detects (and caches) whether a subclass has overridden LoadScript(). The override + // is an officially advertised extension point; preserving its pre-5.x semantics is + // required for backward compatibility with subclassers who transform script content. + // Parameter types are specified explicitly so a future overload of LoadScript would + // not throw AmbiguousMatchException, and a null result is handled explicitly so any + // future signature/visibility change does not silently regress every caller onto the + // legacy path. + private bool IsLoadScriptOverridden() + { + if (!_isLoadScriptOverriddenCache.HasValue) + { + var method = GetType().GetMethod( + nameof(LoadScript), + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + types: new[] { typeof(string), typeof(ILambdaContext) }, + modifiers: null); + _isLoadScriptOverriddenCache = method != null + && method.DeclaringType != typeof(PowerShellFunctionHost); + } + return _isLoadScriptOverriddenCache.Value; + } + /// /// Waits for the PowerShell execution to be completed /// diff --git a/PowerShell/Tests/LambdaTestHelpers.ps1 b/PowerShell/Tests/LambdaTestHelpers.ps1 new file mode 100644 index 000000000..12e62fb8d --- /dev/null +++ b/PowerShell/Tests/LambdaTestHelpers.ps1 @@ -0,0 +1,334 @@ +# Helper functions for Lambda E2E tests. +# Dot-source this file in Pester BeforeAll blocks. + +function Invoke-AwsCli { + param([string[]]$Arguments) + $allArgs = $Arguments + @('--profile', $script:ProfileName, '--region', $script:Region, '--output', 'json') + $result = & aws @allArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "AWS CLI failed: $result" + } + if ($result) { $result | ConvertFrom-Json } +} + +function New-TestRole { + $trustPolicyFile = Join-Path ([System.IO.Path]::GetTempPath()) "trust-policy-$script:RunId.json" + Set-Content -Path $trustPolicyFile -Value $script:TrustPolicy + + try { + $null = Invoke-AwsCli @('iam', 'create-role', + '--role-name', $script:RoleName, + '--assume-role-policy-document', "file://$trustPolicyFile") + } catch { + if ($_ -match 'EntityAlreadyExists') { } + else { throw } + } + + try { + $null = Invoke-AwsCli @('iam', 'attach-role-policy', + '--role-name', $script:RoleName, + '--policy-arn', $script:PolicyArn) + } catch { + if ($_ -match 'already been attached') { } + else { throw } + } + + Remove-Item $trustPolicyFile -ErrorAction SilentlyContinue + + $roleInfo = Invoke-AwsCli @('iam', 'get-role', '--role-name', $script:RoleName) + return $roleInfo.Role.Arn +} + +function Remove-TestResources { + foreach ($fn in $script:DeployedFunctions) { + try { $null = Invoke-AwsCli @('lambda', 'delete-function', '--function-name', $fn) } catch {} + try { $null = Invoke-AwsCli @('logs', 'delete-log-group', '--log-group-name', "/aws/lambda/$fn") } catch {} + } + + try { $null = Invoke-AwsCli @('iam', 'detach-role-policy', '--role-name', $script:RoleName, '--policy-arn', $script:PolicyArn) } catch {} + try { $null = Invoke-AwsCli @('iam', 'delete-role', '--role-name', $script:RoleName) } catch {} +} + +function New-LambdaPackage { + param( + [string]$HandlerScript, + [string]$StagingDir + ) + + $projectDir = Join-Path $StagingDir 'project' + $null = New-Item -ItemType Directory -Path $projectDir -Force + + Set-Content -Path (Join-Path $projectDir 'handler.ps1') -Value $HandlerScript + + $bootstrapCs = @' +using Amazon.Lambda.PowerShellHost; + +namespace handler +{ + public class Bootstrap : PowerShellFunctionHost + { + public Bootstrap() : base("handler.ps1") + { + } + } +} +'@ + Set-Content -Path (Join-Path $projectDir 'Bootstrap.cs') -Value $bootstrapCs + + $csproj = @" + + + net10.0 + true + + + + PreserveNewest + + + + + + + + +"@ + Set-Content -Path (Join-Path $projectDir 'handler.csproj') -Value $csproj + + $publishDir = Join-Path $StagingDir 'publish' + $publishOutput = & dotnet publish $projectDir -c Release -o $publishDir --framework net10.0 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed: $publishOutput" + } + + $outputZip = Join-Path $StagingDir 'package.zip' + Compress-Archive -Path "$publishDir/*" -DestinationPath $outputZip -Force + return $outputZip +} + +function New-DynamicScriptPackage { + param([string]$StagingDir) + + $projectDir = Join-Path $StagingDir 'project' + $null = New-Item -ItemType Directory -Path $projectDir -Force + + $bootstrapCs = @' +using Amazon.Lambda.Core; +using Amazon.Lambda.PowerShellHost; + +namespace DynamicScriptTest +{ + public class Bootstrap : PowerShellFunctionHost + { + public Bootstrap() : base() + { + } + + protected override string LoadScript(string input, ILambdaContext context) + { + return @" +$result = [ordered]@{ + PSScriptRoot = $PSScriptRoot + PSCommandPath = $PSCommandPath + MyInvocation_Path = $MyInvocation.MyCommand.Path + MyInvocation_CommandType = [string]$MyInvocation.MyCommand.CommandType + LambdaInput_Exists = $null -ne $LambdaInput + LambdaInput_TestKey = $LambdaInput.testKey + LambdaContext_Exists = $null -ne $LambdaContext + LambdaInputString_Exists = $null -ne $LambdaInputString + TEMP = $env:TEMP + HOME = $env:HOME + LAMBDA_TASK_ROOT = $env:LAMBDA_TASK_ROOT + DynamicMode = $true +} + +[pscustomobject]$result +"; + } + } +} +'@ + + $csproj = @" + + + net10.0 + true + + + + + + + +"@ + + Set-Content -Path (Join-Path $projectDir 'DynamicScriptTest.csproj') -Value $csproj + Set-Content -Path (Join-Path $projectDir 'Bootstrap.cs') -Value $bootstrapCs + + $publishDir = Join-Path $StagingDir 'publish' + $publishOutput = & dotnet publish $projectDir -c Release -o $publishDir --framework net10.0 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed: $publishOutput" + } + + $outputZip = Join-Path $StagingDir 'package.zip' + Compress-Archive -Path "$publishDir/*" -DestinationPath $outputZip -Force + return $outputZip +} + +function New-LoadScriptOverrideWithFilePackage { + # Issue A regression test: subclass overrides LoadScript() AND a file exists on disk. + # Pre-PR: override always ran. Post-PR without mitigation: file path silently ignores + # the override. Post-PR with mitigation: override runs (text from override is executed). + param([string]$StagingDir) + + $projectDir = Join-Path $StagingDir 'project' + $null = New-Item -ItemType Directory -Path $projectDir -Force + + # The on-disk file says "FromFile" and would expose $PSScriptRoot if it ran. The override + # returns "FromOverride" with empty $PSScriptRoot. The result tells us which one ran. + $originalScript = @' +[pscustomobject]@{ Source = 'FromFile'; PSScriptRoot = $PSScriptRoot } +'@ + Set-Content -Path (Join-Path $projectDir 'handler.ps1') -Value $originalScript + + $bootstrapCs = @' +using Amazon.Lambda.Core; +using Amazon.Lambda.PowerShellHost; + +namespace handler +{ + public class Bootstrap : PowerShellFunctionHost + { + public Bootstrap() : base("handler.ps1") + { + } + + protected override string LoadScript(string input, ILambdaContext context) + { + return @"[pscustomobject]@{ Source = 'FromOverride'; PSScriptRoot = $PSScriptRoot }"; + } + } +} +'@ + Set-Content -Path (Join-Path $projectDir 'Bootstrap.cs') -Value $bootstrapCs + + $csproj = @" + + + net10.0 + true + + + PreserveNewest + + + + + + + +"@ + Set-Content -Path (Join-Path $projectDir 'handler.csproj') -Value $csproj + + $publishDir = Join-Path $StagingDir 'publish' + $publishOutput = & dotnet publish $projectDir -c Release -o $publishDir --framework net10.0 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed: $publishOutput" + } + + $outputZip = Join-Path $StagingDir 'package.zip' + Compress-Archive -Path "$publishDir/*" -DestinationPath $outputZip -Force + return $outputZip +} + +function Deploy-TestFunction { + param( + [string]$FunctionName, + [string]$ZipPath, + [string]$RoleArn, + [string]$HandlerOverride, + [hashtable]$EnvironmentVariables + ) + + try { + $null = Invoke-AwsCli @('lambda', 'delete-function', '--function-name', $FunctionName) + } catch { + if ($_ -notmatch 'ResourceNotFoundException') { throw } + } + + $handler = if ($HandlerOverride) { $HandlerOverride } else { 'handler::handler.Bootstrap::ExecuteFunction' } + + $createArgs = @('lambda', 'create-function', + '--function-name', $FunctionName, + '--runtime', $script:Runtime, + '--role', $RoleArn, + '--handler', $handler, + '--zip-file', "fileb://$ZipPath", + '--timeout', '120', + '--memory-size', '512') + + if ($EnvironmentVariables -and $EnvironmentVariables.Count -gt 0) { + $envJson = ($EnvironmentVariables | ConvertTo-Json -Compress) + $createArgs += @('--environment', "{`"Variables`":$envJson}") + } + + $maxRetries = 3 + for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { + try { + $null = Invoke-AwsCli $createArgs + break + } catch { + if ($_ -match 'AccessDeniedException|cannot be assumed' -and $attempt -lt $maxRetries) { + Start-Sleep -Seconds 10 + } else { + throw + } + } + } + + $null = & aws lambda wait function-active-v2 --function-name $FunctionName --profile $script:ProfileName --region $script:Region 2>&1 + if ($LASTEXITCODE -ne 0) { + $null = & aws lambda wait function-active --function-name $FunctionName --profile $script:ProfileName --region $script:Region 2>&1 + } + + $script:DeployedFunctions.Add($FunctionName) +} + +function Invoke-TestFunction { + param( + [string]$FunctionName, + [string]$Payload = '{}' + ) + + $outputFile = Join-Path ([System.IO.Path]::GetTempPath()) "$FunctionName-$(Get-Random).json" + $payloadFile = Join-Path ([System.IO.Path]::GetTempPath()) "$FunctionName-payload-$(Get-Random).json" + Set-Content -Path $payloadFile -Value $Payload + + $invokeResult = & aws lambda invoke ` + --function-name $FunctionName ` + --profile $script:ProfileName ` + --region $script:Region ` + --cli-binary-format raw-in-base64-out ` + --payload "file://$payloadFile" ` + --log-type Tail ` + --output json ` + $outputFile 2>&1 + + Remove-Item $payloadFile -ErrorAction SilentlyContinue + + if ($LASTEXITCODE -ne 0) { + throw "Lambda invoke failed: $invokeResult" + } + + $meta = $invokeResult | ConvertFrom-Json + $response = Get-Content $outputFile -Raw | ConvertFrom-Json + Remove-Item $outputFile -ErrorAction SilentlyContinue + + if ($meta.FunctionError) { + throw "Lambda function error: $($response | ConvertTo-Json -Compress -Depth 3)" + } + + return $response +} diff --git a/PowerShell/Tests/Test-LambdaPSScriptRoot.Tests.ps1 b/PowerShell/Tests/Test-LambdaPSScriptRoot.Tests.ps1 new file mode 100644 index 000000000..933793650 --- /dev/null +++ b/PowerShell/Tests/Test-LambdaPSScriptRoot.Tests.ps1 @@ -0,0 +1,399 @@ +#Requires -Modules Pester + +<# +.SYNOPSIS + Pester 5 E2E tests for PowerShell Lambda runtime behavior. + +.EXAMPLE + $container = New-PesterContainer -Path ./Test-LambdaPSScriptRoot.Tests.ps1 -Data @{ + ProfileName = 'myprofile'; Region = 'us-west-2'; Runtime = 'dotnet10' + } + Invoke-Pester -Container $container -Output Detailed +#> + +param( + [Parameter(Mandatory)] + [string]$ProfileName, + + [string]$Region = 'us-west-2', + + [ValidateSet('dotnet8', 'dotnet10')] + [string]$Runtime = 'dotnet10' +) + +BeforeAll { + $script:ProfileName = $ProfileName + $script:Region = $Region + $script:Runtime = $Runtime + $script:RepoRoot = (Resolve-Path "$PSScriptRoot/../../").Path + $script:RunId = [System.Guid]::NewGuid().ToString('N').Substring(0, 8) + $script:RoleName = "ps-lambda-test-$script:RunId" + $script:PolicyArn = 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + $script:DeployedFunctions = [System.Collections.Concurrent.ConcurrentBag[string]]::new() + $script:TrustPolicy = @' +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { "Service": "lambda.amazonaws.com" }, + "Action": "sts:AssumeRole" + }] +} +'@ + + . "$PSScriptRoot/LambdaTestHelpers.ps1" + + $script:RoleArn = New-TestRole + Start-Sleep -Seconds 10 +} + +AfterAll { + . "$PSScriptRoot/LambdaTestHelpers.ps1" + Remove-TestResources +} + +Describe 'Automatic Variables' { + It 'populates $PSScriptRoot, $PSCommandPath, and $MyInvocation correctly' { + $fnName = "ps-test-auto-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$result = [ordered]@{ + PSScriptRoot = $PSScriptRoot + PSCommandPath = $PSCommandPath + MyInvocation_Path = $MyInvocation.MyCommand.Path + MyInvocation_Name = $MyInvocation.MyCommand.Name + MyInvocation_CommandType = [string]$MyInvocation.MyCommand.CommandType + MyInvocation_Source = $MyInvocation.MyCommand.Source + LambdaTaskRoot = $env:LAMBDA_TASK_ROOT +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName + + $r.PSScriptRoot | Should -Be '/var/task' + $r.PSCommandPath | Should -Be '/var/task/handler.ps1' + $r.MyInvocation_Path | Should -Be '/var/task/handler.ps1' + $r.MyInvocation_Name | Should -Be 'handler.ps1' + $r.MyInvocation_CommandType | Should -Be 'ExternalScript' + $r.MyInvocation_Source | Should -Be '/var/task/handler.ps1' + $r.LambdaTaskRoot | Should -Be '/var/task' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'Injected Variables' { + It '$LambdaInput, $LambdaContext, and $LambdaInputString are accessible' { + $fnName = "ps-test-inj-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$result = [ordered]@{ + LambdaInput_Exists = $null -ne $LambdaInput + LambdaInput_TestKey = $LambdaInput.testKey + LambdaContext_Exists = $null -ne $LambdaContext + LambdaContext_FunctionName = if ($null -ne $LambdaContext) { $LambdaContext.FunctionName } else { 'null' } + LambdaInputString_Exists = $null -ne $LambdaInputString + LambdaInputString_Type = if ($null -ne $LambdaInputString) { $LambdaInputString.GetType().Name } else { 'null' } +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r.LambdaInput_Exists | Should -BeTrue + $r.LambdaInput_TestKey | Should -Be 'hello-lambda' + $r.LambdaContext_Exists | Should -BeTrue + $r.LambdaContext_FunctionName | Should -Be $fnName + $r.LambdaInputString_Exists | Should -BeTrue + $r.LambdaInputString_Type | Should -Be 'String' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'Environment Normalization' { + It '$env:TEMP, TMP, TMPDIR, HOME are set and writable' { + $fnName = "ps-test-env-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$testFile = Join-Path $env:TEMP "write-test-$(Get-Random).txt" +$writable = $false +try { + Set-Content -Path $testFile -Value "test" + if (Test-Path $testFile) { $writable = $true; Remove-Item $testFile -ErrorAction SilentlyContinue } +} catch {} + +$result = [ordered]@{ + TEMP = $env:TEMP + TMP = $env:TMP + TMPDIR = $env:TMPDIR + HOME = $env:HOME + LAMBDA_TASK_ROOT = $env:LAMBDA_TASK_ROOT + TempWritable = $writable +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName + + $r.TEMP | Should -Be '/tmp' + $r.TMP | Should -Be '/tmp' + $r.TMPDIR | Should -Be '/tmp' + $r.HOME | Should -Be '/tmp/home' + $r.LAMBDA_TASK_ROOT | Should -Be '/var/task' + $r.TempWritable | Should -BeTrue + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'PowerShellFunctionName' { + It 'calls the named function with $LambdaInput and $LambdaContext' { + $fnName = "ps-test-fn-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +function Invoke-LambdaHandler { + param($LambdaInput, $LambdaContext) + $result = [ordered]@{ + FunctionCalled = $true + ReceivedInput = $null -ne $LambdaInput + ReceivedContext = $null -ne $LambdaContext + InputTestKey = $LambdaInput.testKey + ContextFunctionName = if ($null -ne $LambdaContext) { $LambdaContext.FunctionName } else { 'null' } + } + [pscustomobject]$result +} +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn ` + -EnvironmentVariables @{ AWS_POWERSHELL_FUNCTION_HANDLER = 'Invoke-LambdaHandler' } + $r = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r.FunctionCalled | Should -BeTrue + $r.ReceivedInput | Should -BeTrue + $r.ReceivedContext | Should -BeTrue + $r.InputTestKey | Should -Be 'hello-lambda' + $r.ContextFunctionName | Should -Be $fnName + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'Dynamic Script Block (LoadScript override)' { + It 'executes with empty automatic variables and working injected variables' { + $fnName = "ps-test-dyn-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $zip = New-DynamicScriptPackage -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn ` + -HandlerOverride 'DynamicScriptTest::DynamicScriptTest.Bootstrap::ExecuteFunction' + $r = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r.DynamicMode | Should -BeTrue + $r.PSScriptRoot | Should -BeNullOrEmpty + $r.PSCommandPath | Should -BeNullOrEmpty + $r.MyInvocation_CommandType | Should -Be 'Script' + $r.LambdaInput_Exists | Should -BeTrue + $r.LambdaInput_TestKey | Should -Be 'hello-lambda' + $r.LambdaContext_Exists | Should -BeTrue + $r.LambdaInputString_Exists | Should -BeTrue + $r.TEMP | Should -Be '/tmp' + $r.HOME | Should -Be '/tmp/home' + $r.LAMBDA_TASK_ROOT | Should -Be '/var/task' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'LoadScript Override With File On Disk (Issue A regression)' { + It 'runs the override (not the file) when LoadScript is overridden and a file exists' { + $fnName = "ps-test-ovr-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $zip = New-LoadScriptOverrideWithFilePackage -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName + + $r.Source | Should -Be 'FromOverride' + $r.PSScriptRoot | Should -BeNullOrEmpty + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'ForEach-Object -Parallel' { + It 'propagates $PSScriptRoot and $LambdaInput via $using:' { + $fnName = "ps-test-par-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$mainPSScriptRoot = $PSScriptRoot +$mainLambdaInput = $LambdaInput + +$parallelResults = 1..3 | ForEach-Object -Parallel { + [pscustomobject]@{ + Using_PSScriptRoot = $using:mainPSScriptRoot + LambdaInput_Exists = $null -ne $using:mainLambdaInput + LambdaInput_TestKey = $using:mainLambdaInput.testKey + } +} + +$result = [ordered]@{ + MainPSScriptRoot = $mainPSScriptRoot + ParallelCount = $parallelResults.Count + Parallel_Using_PSScriptRoot = ($parallelResults | ForEach-Object { $_.Using_PSScriptRoot }) -join ',' + Parallel_LambdaInput = ($parallelResults | ForEach-Object { $_.LambdaInput_Exists }) -join ',' + Parallel_TestKey = ($parallelResults | ForEach-Object { $_.LambdaInput_TestKey }) -join ',' +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r.ParallelCount | Should -Be 3 + $r.MainPSScriptRoot | Should -Be '/var/task' + ($r.Parallel_Using_PSScriptRoot -split ',')[0] | Should -Be '/var/task' + ($r.Parallel_LambdaInput -split ',')[0] | Should -Be 'True' + ($r.Parallel_TestKey -split ',')[0] | Should -Be 'hello-lambda' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'Warm Invocation' { + It 'maintains consistent behavior across two calls on same container' { + $fnName = "ps-test-warm-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$result = [ordered]@{ + PSScriptRoot = $PSScriptRoot + PSCommandPath = $PSCommandPath + LambdaInput_Exists = $null -ne $LambdaInput + LambdaInput_TestKey = $LambdaInput.testKey + LambdaContext_FunctionName = if ($null -ne $LambdaContext) { $LambdaContext.FunctionName } else { 'null' } + TEMP = $env:TEMP +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + + $r1 = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + Start-Sleep -Seconds 2 + $r2 = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r1.LambdaInput_Exists | Should -BeTrue + $r2.LambdaInput_Exists | Should -BeTrue + $r2.PSScriptRoot | Should -Be $r1.PSScriptRoot + $r2.PSCommandPath | Should -Be $r1.PSCommandPath + $r2.LambdaInput_TestKey | Should -Be 'hello-lambda' + $r2.LambdaContext_FunctionName | Should -Be $fnName + $r2.TEMP | Should -Be '/tmp' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'Module Resolution via $PSScriptRoot' { + It 'uses $PSScriptRoot for path resolution without fallback' { + $fnName = "ps-test-mod-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$basePath = if ($PSScriptRoot) { $PSScriptRoot } else { $env:LAMBDA_TASK_ROOT } + +$result = [ordered]@{ + BasePath = $basePath + PSScriptRoot = $PSScriptRoot + UsedPSScriptRoot = [bool]$PSScriptRoot + UsedFallback = -not [bool]$PSScriptRoot +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName + + $r.BasePath | Should -Be '/var/task' + $r.PSScriptRoot | Should -Be '/var/task' + $r.UsedPSScriptRoot | Should -BeTrue + $r.UsedFallback | Should -BeFalse + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'User Param() Block' { + It 'allows scripts with their own Param() without conflict' { + $fnName = "ps-test-prm-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +Param( + [string]$CustomParam = 'default-value' +) + +$result = [ordered]@{ + CustomParam_Value = $CustomParam + LambdaInput_Exists = $null -ne $LambdaInput + LambdaInput_TestKey = $LambdaInput.testKey + LambdaContext_Exists = $null -ne $LambdaContext + LambdaInputString_Exists = $null -ne $LambdaInputString + PSScriptRoot = $PSScriptRoot +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r.CustomParam_Value | Should -Be 'default-value' + $r.LambdaInput_Exists | Should -BeTrue + $r.LambdaInput_TestKey | Should -Be 'hello-lambda' + $r.LambdaContext_Exists | Should -BeTrue + $r.LambdaInputString_Exists | Should -BeTrue + $r.PSScriptRoot | Should -Be '/var/task' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +}