diff --git a/build.ps1 b/build.ps1 index 73a98d06..59b4a5b0 100644 --- a/build.ps1 +++ b/build.ps1 @@ -35,10 +35,6 @@ if ($Bootstrap.IsPresent) { Write-Log -Warning "Module 'PSDepend' is missing. Installing 'PSDepend' ..." Install-Module -Name PSDepend -Scope CurrentUser -Force } - if (-not (Get-Module -Name Pester -ListAvailable)) { - Write-Log -Warning "Module 'Pester' is missing. Installing 'Pester' ..." - Install-Module -Name Pester -Scope CurrentUser -Force - } if (-not (Get-Module -Name platyPS -ListAvailable)) { Write-Log -Warning "Module 'platyPS' is missing. Installing 'platyPS' ..." Install-Module -Name platyPS -Scope CurrentUser -Force @@ -93,36 +89,6 @@ if(!$NoBuild.IsPresent) { # Test step if($Test.IsPresent) { - if (-not (Get-Module -Name Pester -ListAvailable)) { - throw "Cannot find the 'Pester' module. Please specify '-Bootstrap' to install build dependencies." - } - dotnet test "$PSScriptRoot/test/Unit" if ($LASTEXITCODE -ne 0) { throw "xunit tests failed." } - - Invoke-Tests -Path "$PSScriptRoot/test/Unit/Modules" -OutputFile UnitTestsResults.xml - - if (-not (Get-Module -Name platyPS -ListAvailable)) { - throw "Cannot find the 'platyPS' module. Please specify '-Bootstrap' to install build dependencies." - } - elseif (-not (Get-Command -Name git -CommandType Application)) { - throw "Cannot find 'git'. Please make sure it's in the 'PATH'." - } - - # Cmdlet help docs should be up-to-date. - # PlatyPS needs the module to be imported. - Import-Module -Force (Join-Path $PSScriptRoot src Modules Microsoft.Azure.Functions.PowerShellWorker) - try { - # Update the help and diff the result. - $docsPath = Join-Path $PSScriptRoot docs cmdlets - $null = Update-MarkdownHelp -Path $docsPath - $diff = git diff $docsPath - if ($diff) { - throw "Cmdlet help docs are not up-to-date, run Update-MarkdownHelp.`n$diff`n" - } - Write-Host "Help is up-to-date." - } finally { - # Clean up. - Remove-Module Microsoft.Azure.Functions.PowerShellWorker -Force - } } diff --git a/docs/cmdlets/Get-OutputBinding.md b/docs/cmdlets/Get-OutputBinding.md index 074a9eeb..d7b0c9e5 100644 --- a/docs/cmdlets/Get-OutputBinding.md +++ b/docs/cmdlets/Get-OutputBinding.md @@ -1,5 +1,5 @@ --- -external help file: Microsoft.Azure.Functions.PowerShellWorker-help.xml +external help file: Microsoft.Azure.Functions.PowerShellWorker.dll-Help.xml Module Name: Microsoft.Azure.Functions.PowerShellWorker online version: schema: 2.0.0 @@ -13,7 +13,7 @@ Gets the hashtable of the output bindings set so far. ## SYNTAX ``` -Get-OutputBinding [[-Name] ] [-Purge] [] +Get-OutputBinding [[-Name] ] [-Purge] [] ``` ## DESCRIPTION @@ -49,12 +49,12 @@ The name of the output binding you want to get. Supports wildcards. ```yaml -Type: String[] +Type: String Parameter Sets: (All) Aliases: Required: False -Position: 1 +Position: 0 Default value: * Accept pipeline input: True (ByPropertyName, ByValue) Accept wildcard characters: True diff --git a/docs/cmdlets/Push-OutputBinding.md b/docs/cmdlets/Push-OutputBinding.md index a8ef9976..d1fd457e 100644 --- a/docs/cmdlets/Push-OutputBinding.md +++ b/docs/cmdlets/Push-OutputBinding.md @@ -1,5 +1,5 @@ --- -external help file: Microsoft.Azure.Functions.PowerShellWorker-help.xml +external help file: Microsoft.Azure.Functions.PowerShellWorker.dll-Help.xml Module Name: Microsoft.Azure.Functions.PowerShellWorker online version: schema: 2.0.0 @@ -92,6 +92,21 @@ The output binding of "outQueue" will now have a list with 4 items: ## PARAMETERS +### -Clobber +(Optional) If specified, will force the value to be set for a specified output binding. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Name The name of the output binding you want to set. @@ -101,7 +116,7 @@ Parameter Sets: (All) Aliases: Required: True -Position: 1 +Position: 0 Default value: None Accept pipeline input: False Accept wildcard characters: False @@ -116,27 +131,12 @@ Parameter Sets: (All) Aliases: Required: True -Position: 2 +Position: 1 Default value: None Accept pipeline input: True (ByValue) Accept wildcard characters: False ``` -### -Clobber -(Optional) If specified, will force the value to be set for a specified output binding. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: False -Accept pipeline input: False -Accept wildcard characters: False -``` - ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/docs/cmdlets/Trace-PipelineObject.md b/docs/cmdlets/Trace-PipelineObject.md index 220f22b7..1c503305 100644 --- a/docs/cmdlets/Trace-PipelineObject.md +++ b/docs/cmdlets/Trace-PipelineObject.md @@ -1,5 +1,5 @@ --- -external help file: Microsoft.Azure.Functions.PowerShellWorker-help.xml +external help file: Microsoft.Azure.Functions.PowerShellWorker.dll-Help.xml Module Name: Microsoft.Azure.Functions.PowerShellWorker online version: schema: 2.0.0 @@ -40,7 +40,7 @@ Parameter Sets: (All) Aliases: Required: True -Position: 1 +Position: 0 Default value: None Accept pipeline input: True (ByValue) Accept wildcard characters: False diff --git a/src/DependencyManagement/DependencyManager.cs b/src/DependencyManagement/DependencyManager.cs index 8210e89d..1b424c10 100644 --- a/src/DependencyManagement/DependencyManager.cs +++ b/src/DependencyManagement/DependencyManager.cs @@ -215,7 +215,7 @@ internal void InstallFunctionAppDependencies(PowerShell pwsh, ILogger logger) .AddParameter("Name", moduleName) .AddParameter("RequiredVersion", latestVersion) .AddParameter("Path", DependenciesPath) - .AddParameter("Force", true) + .AddParameter("Force", Utils.BoxedTrue) .AddParameter("ErrorAction", "Stop") .InvokeAndClearCommands(); @@ -228,7 +228,7 @@ internal void InstallFunctionAppDependencies(PowerShell pwsh, ILogger logger) // Clean up pwsh.AddCommand(Utils.RemoveModuleCmdletInfo) .AddParameter("Name", "PackageManagement, PowerShellGet") - .AddParameter("Force", true) + .AddParameter("Force", Utils.BoxedTrue) .AddParameter("ErrorAction", "SilentlyContinue") .InvokeAndClearCommands(); } diff --git a/src/FunctionInfo.cs b/src/FunctionInfo.cs index 1da5448f..e07540f3 100644 --- a/src/FunctionInfo.cs +++ b/src/FunctionInfo.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Management.Automation; using System.Management.Automation.Language; using System.Text; @@ -30,7 +31,9 @@ internal class AzFunctionInfo internal readonly string FuncName; internal readonly string EntryPoint; internal readonly string ScriptPath; + internal readonly string DeployedPSFuncName; internal readonly AzFunctionType Type; + internal readonly ScriptBlock FuncScriptBlock; internal readonly ReadOnlyDictionary FuncParameters; internal readonly ReadOnlyDictionary AllBindings; internal readonly ReadOnlyDictionary InputBindings; @@ -50,7 +53,8 @@ internal AzFunctionInfo(RpcFunctionMetadata metadata) // Support 'entryPoint' only if 'scriptFile' is a .psm1 file; // Support .psm1 'scriptFile' only if 'entryPoint' is specified. bool isScriptFilePsm1 = ScriptPath.EndsWith(".psm1", StringComparison.OrdinalIgnoreCase); - if (string.IsNullOrEmpty(EntryPoint)) + bool entryPointNotDefined = string.IsNullOrEmpty(EntryPoint); + if (entryPointNotDefined) { if (isScriptFilePsm1) { @@ -63,7 +67,7 @@ internal AzFunctionInfo(RpcFunctionMetadata metadata) } // Get the parameter names of the script or function. - var psScriptParams = GetParameters(ScriptPath, EntryPoint); + var psScriptParams = GetParameters(ScriptPath, EntryPoint, out ScriptBlockAst scriptAst); FuncParameters = new ReadOnlyDictionary(psScriptParams); var parametersCopy = new Dictionary(psScriptParams, StringComparer.OrdinalIgnoreCase); @@ -121,6 +125,15 @@ internal AzFunctionInfo(RpcFunctionMetadata metadata) throw new InvalidOperationException(errorMsg); } + if (entryPointNotDefined && scriptAst.ScriptRequirements == null) + { + // If the function script is a '.ps1' file that doesn't have '#requires' defined, + // then we get the script block and will deploy it as a PowerShell function in the + // global scope of each Runspace, so as to avoid hitting the disk every invocation. + FuncScriptBlock = scriptAst.GetScriptBlock(); + DeployedPSFuncName = $"_{FuncName}_"; + } + AllBindings = new ReadOnlyDictionary(allBindings); InputBindings = new ReadOnlyDictionary(inputBindings); OutputBindings = new ReadOnlyDictionary(outputBindings); @@ -140,9 +153,9 @@ private AzFunctionType GetAzFunctionType(ReadOnlyBindingInfo bindingInfo) } } - private Dictionary GetParameters(string scriptFile, string entryPoint) + private Dictionary GetParameters(string scriptFile, string entryPoint, out ScriptBlockAst scriptAst) { - ScriptBlockAst sbAst = Parser.ParseFile(scriptFile, out _, out ParseError[] errors); + scriptAst = Parser.ParseFile(scriptFile, out _, out ParseError[] errors); if (errors != null && errors.Length > 0) { var stringBuilder = new StringBuilder(15); @@ -158,11 +171,11 @@ private Dictionary GetParameters(string scriptFile, s ReadOnlyCollection paramAsts = null; if (string.IsNullOrEmpty(entryPoint)) { - paramAsts = sbAst.ParamBlock?.Parameters; + paramAsts = scriptAst.ParamBlock?.Parameters; } else { - var asts = sbAst.FindAll( + var asts = scriptAst.FindAll( ast => ast is FunctionDefinitionAst func && entryPoint.Equals(func.Name, StringComparison.OrdinalIgnoreCase), searchNestedScriptBlocks: false).ToList(); diff --git a/src/FunctionLoader.cs b/src/FunctionLoader.cs index 1758cca3..7400bdbf 100644 --- a/src/FunctionLoader.cs +++ b/src/FunctionLoader.cs @@ -15,9 +15,9 @@ namespace Microsoft.Azure.Functions.PowerShellWorker /// /// FunctionLoader holds metadata of functions. /// - internal class FunctionLoader + internal static class FunctionLoader { - private readonly Dictionary _loadedFunctions = new Dictionary(); + private static readonly Dictionary LoadedFunctions = new Dictionary(); internal static string FunctionAppRootPath { get; private set; } internal static string FunctionAppProfilePath { get; private set; } @@ -26,9 +26,9 @@ internal class FunctionLoader /// /// Query for function metadata can happen in parallel. /// - internal AzFunctionInfo GetFunctionInfo(string functionId) + internal static AzFunctionInfo GetFunctionInfo(string functionId) { - if (_loadedFunctions.TryGetValue(functionId, out AzFunctionInfo funcInfo)) + if (LoadedFunctions.TryGetValue(functionId, out AzFunctionInfo funcInfo)) { return funcInfo; } @@ -40,9 +40,25 @@ internal AzFunctionInfo GetFunctionInfo(string functionId) /// This method runs once per 'FunctionLoadRequest' during the code start of the worker. /// It will always run synchronously because we process 'FunctionLoadRequest' synchronously. /// - internal void LoadFunction(FunctionLoadRequest request) + internal static void LoadFunction(FunctionLoadRequest request) { - _loadedFunctions.Add(request.FunctionId, new AzFunctionInfo(request.Metadata)); + LoadedFunctions.Add(request.FunctionId, new AzFunctionInfo(request.Metadata)); + } + + /// + /// Get all loaded functions. + /// + internal static IEnumerable GetLoadedFunctions() + { + return LoadedFunctions.Values; + } + + /// + /// Clear all loaded functions. + /// + internal static void ClearLoadedFunctions() + { + LoadedFunctions.Clear(); } /// diff --git a/src/Microsoft.Azure.Functions.PowerShellWorker.csproj b/src/Microsoft.Azure.Functions.PowerShellWorker.csproj index 1e308d16..12621bcb 100644 --- a/src/Microsoft.Azure.Functions.PowerShellWorker.csproj +++ b/src/Microsoft.Azure.Functions.PowerShellWorker.csproj @@ -22,7 +22,6 @@ Licensed under the MIT license. See LICENSE file in the project root for full li - diff --git a/src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psd1 b/src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psd1 index bc1599eb..4558b02b 100644 --- a/src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psd1 +++ b/src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psd1 @@ -6,13 +6,13 @@ @{ # Script module or binary module file associated with this manifest. -RootModule = 'Microsoft.Azure.Functions.PowerShellWorker.psm1' +RootModule = 'Microsoft.Azure.Functions.PowerShellWorker.dll' # Version number of this module. ModuleVersion = '0.1.0' # Supported PSEditions -CompatiblePSEditions = @('Desktop', 'Core') +CompatiblePSEditions = @('Core') # ID used to uniquely identify this module GUID = 'f0149ba6-bd6f-4dbd-afe5-2a95bd755d6c' @@ -30,7 +30,7 @@ Copyright = '(c) Microsoft Corporation. All rights reserved.' Description = 'The module used in an Azure Functions environment for setting and retrieving Output Bindings.' # Minimum version of the PowerShell engine required by this module -PowerShellVersion = '5.1' +PowerShellVersion = '6.2' # Modules that must be imported into the global environment prior to importing this module RequiredModules = @() @@ -51,10 +51,10 @@ FormatsToProcess = @() NestedModules = @() # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. -FunctionsToExport = @('Push-OutputBinding', 'Get-OutputBinding', 'Trace-PipelineObject') +FunctionsToExport = @() # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. -CmdletsToExport = @() +CmdletsToExport = @('Push-OutputBinding', 'Get-OutputBinding', 'Trace-PipelineObject') # Variables to export from this module VariablesToExport = @() diff --git a/src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psm1 b/src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psm1 deleted file mode 100644 index 87368f1f..00000000 --- a/src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psm1 +++ /dev/null @@ -1,391 +0,0 @@ -# -# Copyright (c) Microsoft. All rights reserved. -# Licensed under the MIT license. See LICENSE file in the project root for full license information. -# - -using namespace System.Management.Automation -using namespace System.Management.Automation.Runspaces -using namespace Microsoft.Azure.Functions.PowerShellWorker - -# This holds the current state of the output bindings. -$script:_OutputBindings = @{} -$script:_FuncMetadataType = "FunctionMetadata" -as [type] -$script:_RunningInPSWorker = $null -ne $script:_FuncMetadataType -# These variables hold the ScriptBlock and CmdletInfo objects for constructing a SteppablePipeline of 'Out-String | Write-Information'. -$script:outStringCmd = $ExecutionContext.InvokeCommand.GetCommand("Microsoft.PowerShell.Utility\Out-String", [CommandTypes]::Cmdlet) -$script:writeInfoCmd = $ExecutionContext.InvokeCommand.GetCommand("Microsoft.PowerShell.Utility\Write-Information", [CommandTypes]::Cmdlet) -$script:tracingSb = { & $script:outStringCmd -Stream | & $script:writeInfoCmd -Tags "__PipelineObject__" } -# This loads the resource strings. -Import-LocalizedData LocalizedData -FileName PowerShellWorker.Resource.psd1 - -# Enum that defines different behaviors when collecting output data -enum DataCollectingBehavior { - Singleton - Collection -} - -<# -.SYNOPSIS - Gets the hashtable of the output bindings set so far. -.DESCRIPTION - Gets the hashtable of the output bindings set so far. -.EXAMPLE - PS > Get-OutputBinding - Gets the hashtable of all the output bindings set so far. -.EXAMPLE - PS > Get-OutputBinding -Name res - Gets the hashtable of specific output binding. -.EXAMPLE - PS > Get-OutputBinding -Name r* - Gets the hashtable of output bindings that match the wildcard. -.PARAMETER Name - The name of the output binding you want to get. Supports wildcards. -.PARAMETER Purge - Clear all stored output binding values. -.OUTPUTS - The hashtable of binding names to their respective value. -#> -function Get-OutputBinding { - [CmdletBinding()] - param( - [Parameter(ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)] - [SupportsWildcards()] - [string[]] - $Name = '*', - - [switch] - $Purge - ) - - begin { - $bindings = @{} - } - - process { - foreach ($entry in $script:_OutputBindings.GetEnumerator()) { - $bindingName = $entry.Key - $bindingValue = $entry.Value - - if ($bindingName -like $Name -and !$bindings.ContainsKey($bindingName)) { - $bindings.Add($bindingName, $bindingValue) - } - } - } - - end { - if($Purge.IsPresent) { - $script:_OutputBindings.Clear() - } - return $bindings - } -} - -# Helper private function that resolve the name to the corresponding binding information. -function Get-BindingInfo -{ - [CmdletBinding()] - param( - [Parameter(Mandatory = $true)] - [string] $Name - ) - - if ($script:_RunningInPSWorker) - { - $instanceId = [Runspace]::DefaultRunspace.InstanceId - $bindingMap = $script:_FuncMetadataType::GetOutputBindingInfo($instanceId) - - # If the instance id doesn't get us back a binding map, then we are not running in one of the PS worker's default Runspace(s). - # This could happen when a custom Runspace is created in the function script, and 'Push-OutputBinding' is called in that Runspace. - if ($null -eq $bindingMap) - { - throw $LocalizedData.DontPushOutputOutsideWorkerRunspace - } - - $bindingInfo = $bindingMap[$Name] - if ($null -eq $bindingInfo) - { - $errorMsg = $LocalizedData.BindingNameNotExist -f $Name - throw $errorMsg - } - - return $bindingInfo - } -} - -# Helper private function that maps an output binding to a data collecting behavior. -function Get-DataCollectingBehavior -{ - param($BindingInfo) - - # binding info not available - if ($null -eq $BindingInfo) - { - return [DataCollectingBehavior]::Singleton - } - - switch ($BindingInfo.Type) - { - "http" { return [DataCollectingBehavior]::Singleton } - "blob" { return [DataCollectingBehavior]::Singleton } - - "sendGrid" { return [DataCollectingBehavior]::Singleton } - "onedrive" { return [DataCollectingBehavior]::Singleton } - "outlook" { return [DataCollectingBehavior]::Singleton } - "notificationHub" { return [DataCollectingBehavior]::Singleton } - - "excel" { return [DataCollectingBehavior]::Collection } - "table" { return [DataCollectingBehavior]::Collection } - "queue" { return [DataCollectingBehavior]::Collection } - "eventHub" { return [DataCollectingBehavior]::Collection } - "documentDB" { return [DataCollectingBehavior]::Collection } - "mobileTable" { return [DataCollectingBehavior]::Collection } - "serviceBus" { return [DataCollectingBehavior]::Collection } - "signalR" { return [DataCollectingBehavior]::Collection } - "twilioSms" { return [DataCollectingBehavior]::Collection } - "graphWebhookSubscription" { return [DataCollectingBehavior]::Collection } - - # Be conservative on new output bindings - default { return [DataCollectingBehavior]::Singleton } - } -} - -<# -.SYNOPSIS - Combine the new data with the existing data for a output binding with 'Collection' behavior. - Here is what this command do: - - when there is no existing data - - if the new data is considered enumerable by PowerShell, - then all its elements get added to a List, and that list is returned. - - otherwise, the new data is returned intact. - - - when there is existing data - - if the existing data is a singleton, then a List is created and the existing data - is added to the list. - - otherwise, the existing data is already a List - - Then, depending on whether the new data is enumerable or not, its elements or itself will also be added to the list. - - That list is returned. -#> -function Merge-Collection -{ - param($OldData, $NewData) - - $isNewDataEnumerable = [LanguagePrimitives]::IsObjectEnumerable($NewData) - - if ($null -eq $OldData -and -not $isNewDataEnumerable) - { - return $NewData - } - - $list = $OldData -as [System.Collections.Generic.List[object]] - if ($null -eq $list) - { - $list = [System.Collections.Generic.List[object]]::new() - if ($null -ne $OldData) - { - $list.Add($OldData) - } - } - - if ($isNewDataEnumerable) - { - foreach ($item in $NewData) - { - $list.Add($item) - } - } - else - { - $list.Add($NewData) - } - - return ,$list -} - -<# -.SYNOPSIS - Sets the value for the specified output binding. -.DESCRIPTION - When running in the Functions runtime, this cmdlet is aware of the output bindings - defined for the function that is invoking this cmdlet. Hence, it's able to decide - whether an output binding accepts singleton value only or a collection of values. - - For example, the HTTP output binding only accepts one response object, while the - queue output binding can accept one or multiple queue messages. - - With this knowledge, the 'Push-OutputBinding' cmdlet acts differently based on the - value specified for '-Name': - - - If the specified name cannot be resolved to a valid output binding, then an error - will be thrown; - - - If the output binding corresponding to that name accepts a collection of values, - then it's allowed to call 'Push-OutputBinding' with the same name repeatedly in - the function script to push multiple values; - - - If the output binding corresponding to that name only accepts a singleton value, - then the second time calling 'Push-OutputBinding' with that name will result in - an error, with detailed message about why it failed. -.EXAMPLE - PS > Push-OutputBinding -Name response -Value "output #1" - The output binding of "response" will have the value of "output #1" -.EXAMPLE - PS > Push-OutputBinding -Name response -Value "output #2" - The output binding is 'http', which accepts a singleton value only. - So an error will be thrown from this second run. -.EXAMPLE - PS > Push-OutputBinding -Name response -Value "output #3" -Clobber - The output binding is 'http', which accepts a singleton value only. - But you can use '-Clobber' to override the old value. - The output binding of "response" will now have the value of "output #3" -.EXAMPLE - PS > Push-OutputBinding -Name outQueue -Value "output #1" - The output binding of "outQueue" will have the value of "output #1" -.EXAMPLE - PS > Push-OutputBinding -Name outQueue -Value "output #2" - The output binding is 'queue', which accepts multiple output values. - The output binding of "outQueue" will now have a list with 2 items: "output #1", "output #2" -.EXAMPLE - PS > Push-OutputBinding -Name outQueue -Value @("output #3", "output #4") - When the value is a collection, the collection will be unrolled and elements of the collection - will be added to the list. The output binding of "outQueue" will now have a list with 4 items: - "output #1", "output #2", "output #3", "output #4". -.PARAMETER Name - The name of the output binding you want to set. -.PARAMETER Value - The value of the output binding you want to set. -.PARAMETER Clobber - (Optional) If specified, will force the value to be set for a specified output binding. -#> -function Push-OutputBinding -{ - [CmdletBinding()] - param ( - [Parameter(Mandatory = $true, Position = 0)] - [string] $Name, - - [Parameter(Mandatory = $true, Position = 1, ValueFromPipeline = $true)] - [object] $Value, - - [switch] $Clobber - ) - - Begin - { - $bindingInfo = Get-BindingInfo -Name $Name - $behavior = Get-DataCollectingBehavior -BindingInfo $bindingInfo - } - - process - { - $bindingType = "Unknown" - if ($null -ne $bindingInfo) - { - $bindingType = $bindingInfo.Type - } - - if (-not $script:_OutputBindings.ContainsKey($Name)) - { - switch ($behavior) - { - ([DataCollectingBehavior]::Singleton) - { - $script:_OutputBindings[$Name] = $Value - return - } - - ([DataCollectingBehavior]::Collection) - { - $newValue = Merge-Collection -OldData $null -NewData $Value - $script:_OutputBindings[$Name] = $newValue - return - } - - default - { - $errorMsg = $LocalizedData.UnrecognizedBehavior -f $behavior - throw $errorMsg - } - } - } - - ## Key already exists in _OutputBindings - switch ($behavior) - { - ([DataCollectingBehavior]::Singleton) - { - if ($Clobber.IsPresent) - { - $script:_OutputBindings[$Name] = $Value - return - } - else - { - $errorMsg = $LocalizedData.OutputBindingAlreadySet -f $Name, $bindingType - throw $errorMsg - } - } - - ([DataCollectingBehavior]::Collection) - { - if ($Clobber.IsPresent) - { - $newValue = Merge-Collection -OldData $null -NewData $Value - } - else - { - $oldValue = $script:_OutputBindings[$Name] - $newValue = Merge-Collection -OldData $oldValue -NewData $Value - } - - $script:_OutputBindings[$Name] = $newValue - return - } - - default - { - $errorMsg = $LocalizedData.UnrecognizedBehavior -f $behavior - throw $errorMsg - } - } - } -} - -<# -.SYNOPSIS - Writes the formatted output of the pipeline object to the information stream before passing the object down to the pipeline. -.DESCRIPTION - INTERNAL POWERSHELL WORKER USE ONLY. Writes the formatted output of the pipeline object to the information stream before passing the object down to the pipeline. -.PARAMETER InputObject - The object from pipeline. -#> -function Trace-PipelineObject { - - [CmdletBinding()] - param( - [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [object] - $InputObject - ) - - <# - This function behaves like 'Tee-Object'. - An input pipeline object is first pushed through a steppable pipeline that consists of 'Out-String | Write-Information -Tags "__PipelineObject__"', - and then it's written out back to the pipeline without change. In this approach, we can intercept and trace the pipeline objects in a streaming way - and keep the objects in pipeline at the same time. - #> - - Begin { - # A micro-optimization: we use the cached 'CmdletInfo' objects to avoid command resolution every time this cmdlet is called. - $stepPipeline = $script:tracingSb.GetSteppablePipeline([CommandOrigin]::Internal) - $stepPipeline.Begin($PSCmdlet) - } - - Process { - $stepPipeline.Process($InputObject) - $InputObject - } - - End { - $stepPipeline.End() - } -} diff --git a/src/Modules/Microsoft.Azure.Functions.PowerShellWorker/PowerShellWorker.Resource.psd1 b/src/Modules/Microsoft.Azure.Functions.PowerShellWorker/PowerShellWorker.Resource.psd1 deleted file mode 100644 index f7f0741d..00000000 --- a/src/Modules/Microsoft.Azure.Functions.PowerShellWorker/PowerShellWorker.Resource.psd1 +++ /dev/null @@ -1,13 +0,0 @@ -# -# Copyright (c) Microsoft. All rights reserved. -# Licensed under the MIT license. See LICENSE file in the project root for full license information. -# - -ConvertFrom-StringData @' -###PSLOC - OutputBindingAlreadySet=The output binding '{0}' is already set with a value. The type of the output binding is '{1}'. It only accepts one message/record/file per a Function invocation. To override the value, use -Clobber. - DontPushOutputOutsideWorkerRunspace='Push-OutputBinding' should only be used in the PowerShell Language Worker's default Runspace(s). Do not use it in a custom Runsapce created during the function execution because the pushed values cannot be collected. - BindingNameNotExist=The specified name '{0}' cannot be resolved to a valid output binding of this function. - UnrecognizedBehavior=Unrecognized data collecting behavior '{0}'. -###PSLOC -'@ diff --git a/src/PowerShell/PowerShellExtensions.cs b/src/PowerShell/PowerShellExtensions.cs index d19c9392..1e276f0d 100644 --- a/src/PowerShell/PowerShellExtensions.cs +++ b/src/PowerShell/PowerShellExtensions.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using System.Collections; using System.Collections.ObjectModel; namespace Microsoft.Azure.Functions.PowerShellWorker.PowerShell @@ -24,6 +25,19 @@ public static void InvokeAndClearCommands(this PowerShell pwsh) } } + public static void InvokeAndClearCommands(this PowerShell pwsh, IEnumerable input) + { + try + { + pwsh.Invoke(input); + } + finally + { + pwsh.Streams.ClearStreams(); + pwsh.Commands.Clear(); + } + } + public static Collection InvokeAndClearCommands(this PowerShell pwsh) { try @@ -37,5 +51,19 @@ public static Collection InvokeAndClearCommands(this PowerShell pwsh) pwsh.Commands.Clear(); } } + + public static Collection InvokeAndClearCommands(this PowerShell pwsh, IEnumerable input) + { + try + { + var result = pwsh.Invoke(input); + return result; + } + finally + { + pwsh.Streams.ClearStreams(); + pwsh.Commands.Clear(); + } + } } } diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 19ec6f19..5ed6ea90 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; +using System.Reflection; using Microsoft.Azure.Functions.PowerShellWorker.Utility; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; @@ -20,6 +21,17 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.PowerShell internal class PowerShellManager { + private const BindingFlags NonPublicInstance = BindingFlags.NonPublic | BindingFlags.Instance; + + private readonly static object[] s_argumentsGetJobs = new object[] { null, false, false, null }; + private readonly static MethodInfo s_methodGetJobs = typeof(JobManager).GetMethod( + "GetJobs", + NonPublicInstance, + binder: null, + callConvention: CallingConventions.Any, + new Type[] { typeof(Cmdlet), typeof(bool), typeof(bool), typeof(string[]) }, + modifiers: null); + private readonly ILogger _logger; private readonly PowerShell _pwsh; private bool _runspaceInited; @@ -86,8 +98,13 @@ internal void Initialize() { if (!_runspaceInited) { + // Register stream events RegisterStreamEvents(); + // Deploy functions from the function App + DeployAzFunctionToRunspace(); + // Run the profile.ps1 InvokeProfile(FunctionLoader.FunctionAppProfilePath); + _runspaceInited = true; } } @@ -106,6 +123,27 @@ private void RegisterStreamEvents() _pwsh.Streams.Warning.DataAdding += streamHandler.WarningDataAdding; } + /// + /// Create the PowerShell function that is equivalent to the 'scriptFile' when possible. + /// + private void DeployAzFunctionToRunspace() + { + foreach (AzFunctionInfo functionInfo in FunctionLoader.GetLoadedFunctions()) + { + if (functionInfo.FuncScriptBlock != null) + { + // Create PS constant function for the Az function. + // Constant function cannot be changed or removed, it stays till the session ends. + _pwsh.AddCommand("New-Item") + .AddParameter("Path", @"Function:\") + .AddParameter("Name", functionInfo.DeployedPSFuncName) + .AddParameter("Value", functionInfo.FuncScriptBlock) + .AddParameter("Options", "Constant") + .InvokeAndClearCommands(); + } + } + } + /// /// This method invokes the FunctionApp's profile.ps1. /// @@ -124,9 +162,9 @@ internal void InvokeProfile(string profilePath) // Import-Module on a .ps1 file will evaluate the script in the global scope. _pwsh.AddCommand(Utils.ImportModuleCmdletInfo) .AddParameter("Name", profilePath) - .AddParameter("PassThru", true) + .AddParameter("PassThru", Utils.BoxedTrue) .AddCommand(Utils.RemoveModuleCmdletInfo) - .AddParameter("Force", true) + .AddParameter("Force", Utils.BoxedTrue) .AddParameter("ErrorAction", "SilentlyContinue") .InvokeAndClearCommands(); } @@ -155,18 +193,16 @@ internal Hashtable InvokeFunction( { string scriptPath = functionInfo.ScriptPath; string entryPoint = functionInfo.EntryPoint; - string moduleName = null; try { if (string.IsNullOrEmpty(entryPoint)) { - _pwsh.AddCommand(scriptPath); + _pwsh.AddCommand(functionInfo.DeployedPSFuncName ?? scriptPath); } else { // If an entry point is defined, we import the script module. - moduleName = Path.GetFileNameWithoutExtension(scriptPath); _pwsh.AddCommand(Utils.ImportModuleCmdletInfo) .AddParameter("Name", scriptPath) .InvokeAndClearCommands(); @@ -195,9 +231,9 @@ internal Hashtable InvokeFunction( Collection pipelineItems = _pwsh.AddCommand("Microsoft.Azure.Functions.PowerShellWorker\\Trace-PipelineObject") .InvokeAndClearCommands(); - Hashtable result = _pwsh.AddCommand("Microsoft.Azure.Functions.PowerShellWorker\\Get-OutputBinding") - .AddParameter("Purge", true) - .InvokeAndClearCommands()[0]; + Hashtable outputBindings = FunctionMetadata.GetOutputBindingHashtable(_pwsh.Runspace.InstanceId); + Hashtable result = new Hashtable(outputBindings, StringComparer.OrdinalIgnoreCase); + outputBindings.Clear(); /* * TODO: See GitHub issue #82. We are not settled on how to handle the Azure Functions concept of the $returns Output Binding @@ -217,31 +253,32 @@ internal Hashtable InvokeFunction( } finally { - ResetRunspace(moduleName); + ResetRunspace(); } } - private void ResetRunspace(string moduleName) + private void ResetRunspace() { - // Reset the runspace to the Initial Session State - _pwsh.Runspace.ResetRunspaceState(); - - if (!string.IsNullOrEmpty(moduleName)) + var jobs = (List)s_methodGetJobs.Invoke(_pwsh.Runspace.JobManager, s_argumentsGetJobs); + if (jobs != null && jobs.Count > 0) { - // If the function had an entry point, this will remove the module that was loaded - _pwsh.AddCommand(Utils.RemoveModuleCmdletInfo) - .AddParameter("Name", moduleName) - .AddParameter("Force", true) + // Clean up jobs started during the function execution. + _pwsh.AddCommand(Utils.RemoveJobCmdletInfo) + .AddParameter("Force", Utils.BoxedTrue) .AddParameter("ErrorAction", "SilentlyContinue") - .InvokeAndClearCommands(); + .InvokeAndClearCommands(jobs); } - // Clean up jobs started during the function execution. - _pwsh.AddCommand(Utils.GetJobCmdletInfo) - .AddCommand(Utils.RemoveJobCmdletInfo) - .AddParameter("Force", true) - .AddParameter("ErrorAction", "SilentlyContinue") - .InvokeAndClearCommands(); + // We need to clean up new global variables generated from the invocation. + // After turning 'run.ps1' to PowerShell function, if '$script:' is used, that variable + // will be made a global variable because there is no script scope from the file. + // + // We don't use 'ResetRunspaceState' because it does more than needed: + // - reset the current path; + // - reset the debugger (this causes breakpoints not work properly); + // - create new event manager and transaction manager; + // We should only remove the new global variables and does nothing else. + Utils.CleanupGlobalVariables(_pwsh); } } } diff --git a/src/Public/Commands/GetOutputBindingCommand.cs b/src/Public/Commands/GetOutputBindingCommand.cs new file mode 100644 index 00000000..6bef5c35 --- /dev/null +++ b/src/Public/Commands/GetOutputBindingCommand.cs @@ -0,0 +1,92 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Management.Automation; +using System.Management.Automation.Runspaces; + +namespace Microsoft.Azure.Functions.PowerShellWorker.Commands +{ + /// + /// Gets the hashtable of the output bindings set so far. + /// + /// + /// .EXAMPLE + /// PS > Get-OutputBinding + /// Gets the hashtable of all the output bindings set so far. + /// .EXAMPLE + /// PS > Get-OutputBinding -Name res + /// Gets the hashtable of specific output binding. + /// .EXAMPLE + /// PS > Get-OutputBinding -Name r* + /// Gets the hashtable of output bindings that match the wildcard. + /// + [Cmdlet(VerbsCommon.Get, "OutputBinding")] + public sealed class GetOutputBindingCommand : PSCmdlet + { + /// + /// The name of the output binding you want to get. Supports wildcards. + /// + [Parameter(Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [SupportsWildcards] + [ValidateNotNullOrEmpty] + public string Name { get; set; } = "*"; + + /// + /// Clear all stored output binding values. + /// + [Parameter] + public SwitchParameter Purge { get; set; } + + private Hashtable _outputBindings; + private Hashtable _retHashtable; + + /// + /// BeginProcessing override. + /// + protected override void BeginProcessing() + { + _retHashtable = new Hashtable(StringComparer.OrdinalIgnoreCase); + _outputBindings = FunctionMetadata.GetOutputBindingHashtable(Runspace.DefaultRunspace.InstanceId); + } + + /// + /// ProcessRecord override. + /// + protected override void ProcessRecord() + { + if (_outputBindings == null || _outputBindings.Count == 0) + { + return; + } + + var namePattern = new WildcardPattern(Name); + foreach (DictionaryEntry entry in _outputBindings) + { + var bindingName = (string)entry.Key; + + if (namePattern.IsMatch(bindingName) && !_retHashtable.ContainsKey(bindingName)) + { + _retHashtable.Add(bindingName, entry.Value); + } + } + } + + /// + /// ProcessRecord override. + /// + protected override void EndProcessing() + { + if (Purge.IsPresent) + { + _outputBindings.Clear(); + } + + WriteObject(_retHashtable); + } + } +} diff --git a/src/Public/Commands/PushOutputBindingCommand.cs b/src/Public/Commands/PushOutputBindingCommand.cs new file mode 100644 index 00000000..336889c8 --- /dev/null +++ b/src/Public/Commands/PushOutputBindingCommand.cs @@ -0,0 +1,279 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Management.Automation; +using System.Management.Automation.Runspaces; + +namespace Microsoft.Azure.Functions.PowerShellWorker.Commands +{ + /// + /// Sets the value for the specified output binding. + /// + /// + /// When running in the Functions runtime, this cmdlet is aware of the output bindings + /// defined for the function that is invoking this cmdlet. Hence, it's able to decide + /// whether an output binding accepts singleton value only or a collection of values. + /// + /// For example, the HTTP output binding only accepts one response object, while the + /// queue output binding can accept one or multiple queue messages. + /// + /// With this knowledge, the 'Push-OutputBinding' cmdlet acts differently based on the + /// value specified for '-Name': + /// + /// - If the specified name cannot be resolved to a valid output binding, then an error + /// will be thrown; + /// + /// - If the output binding corresponding to that name accepts a collection of values, + /// then it's allowed to call 'Push-OutputBinding' with the same name repeatedly in + /// the function script to push multiple values; + /// + /// - If the output binding corresponding to that name only accepts a singleton value, + /// then the second time calling 'Push-OutputBinding' with that name will result in + /// an error, with detailed message about why it failed. + /// + /// .EXAMPLE + /// PS > Push-OutputBinding -Name response -Value "output #1" + /// The output binding of "response" will have the value of "output #1" + /// .EXAMPLE + /// PS > Push-OutputBinding -Name response -Value "output #2" + /// The output binding is 'http', which accepts a singleton value only. + /// So an error will be thrown from this second run. + /// .EXAMPLE + /// PS > Push-OutputBinding -Name response -Value "output #3" -Clobber + /// The output binding is 'http', which accepts a singleton value only. + /// But you can use '-Clobber' to override the old value. + /// The output binding of "response" will now have the value of "output #3" + /// .EXAMPLE + /// PS > Push-OutputBinding -Name outQueue -Value "output #1" + /// The output binding of "outQueue" will have the value of "output #1" + /// .EXAMPLE + /// PS > Push-OutputBinding -Name outQueue -Value "output #2" + /// The output binding is 'queue', which accepts multiple output values. + /// The output binding of "outQueue" will now have a list with 2 items: "output #1", "output #2" + /// .EXAMPLE + /// PS > Push-OutputBinding -Name outQueue -Value @("output #3", "output #4") + /// When the value is a collection, the collection will be unrolled and elements of the collection + /// will be added to the list. The output binding of "outQueue" will now have a list with 4 items: + /// "output #1", "output #2", "output #3", "output #4". + /// + [Cmdlet(VerbsCommon.Push, "OutputBinding")] + public sealed class PushOutputBindingCommand : PSCmdlet + { + /// + /// The name of the output binding you want to set. + /// + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } + + /// + /// The value of the output binding you want to set. + /// + [Parameter(Mandatory = true, Position = 1, ValueFromPipeline = true)] + public object Value { get; set; } + + /// + /// (Optional) If specified, will force the value to be set for a specified output binding. + /// + [Parameter] + public SwitchParameter Clobber { get; set; } + + private ReadOnlyBindingInfo _bindingInfo; + private DataCollectingBehavior _behavior; + private Hashtable _outputBindings; + + /// + /// BeginProcessing override. + /// + protected override void BeginProcessing() + { + _bindingInfo = GetBindingInfo(Name); + _behavior = GetDataCollectingBehavior(_bindingInfo); + _outputBindings = FunctionMetadata.GetOutputBindingHashtable(Runspace.DefaultRunspace.InstanceId); + } + + /// + /// ProcessRecord override. + /// + protected override void ProcessRecord() + { + if (!_outputBindings.ContainsKey(Name)) + { + switch (_behavior) + { + case DataCollectingBehavior.Singleton: + _outputBindings[Name] = Value; + return; + + case DataCollectingBehavior.Collection: + var newValue = MergeCollection(oldData: null, newData: Value); + _outputBindings[Name] = newValue; + return; + + default: + throw new InvalidOperationException( + string.Format(PowerShellWorkerStrings.UnrecognizedBehavior, _behavior.ToString())); + } + } + + // Key already exists in _outputBindings + switch (_behavior) + { + case DataCollectingBehavior.Singleton: + if (Clobber.IsPresent) + { + _outputBindings[Name] = Value; + } + else + { + string errorMsg = string.Format(PowerShellWorkerStrings.OutputBindingAlreadySet, Name, _bindingInfo.Type); + ErrorRecord er = new ErrorRecord( + new InvalidOperationException(errorMsg), + nameof(PowerShellWorkerStrings.OutputBindingAlreadySet), + ErrorCategory.InvalidOperation, + targetObject: _bindingInfo.Type); + + this.ThrowTerminatingError(er); + } + break; + + case DataCollectingBehavior.Collection: + object oldValue = Clobber.IsPresent ? null : _outputBindings[Name]; + object newValue = MergeCollection(oldData: oldValue, newData: Value); + _outputBindings[Name] = newValue; + break; + + default: + throw new InvalidOperationException( + string.Format(PowerShellWorkerStrings.UnrecognizedBehavior, _behavior.ToString())); + } + } + + /// + /// Helper private function that resolve the name to the corresponding binding information. + /// + private ReadOnlyBindingInfo GetBindingInfo(string name) + { + Guid currentRunspaceId = Runspace.DefaultRunspace.InstanceId; + var bindingMap = FunctionMetadata.GetOutputBindingInfo(currentRunspaceId); + + // If the instance id doesn't get us back a binding map, then we are not running in one of the PS worker's Runspace(s). + // This could happen when a custom Runspace is created in the function script, and 'Push-OutputBinding' is called in that Runspace. + if (bindingMap == null) + { + string errorMsg = PowerShellWorkerStrings.DontPushOutputOutsideWorkerRunspace; + ErrorRecord er = new ErrorRecord( + new InvalidOperationException(errorMsg), + nameof(PowerShellWorkerStrings.DontPushOutputOutsideWorkerRunspace), + ErrorCategory.InvalidOperation, + targetObject: currentRunspaceId); + + this.ThrowTerminatingError(er); + } + + if (!bindingMap.TryGetValue(name, out ReadOnlyBindingInfo bindingInfo)) + { + string errorMsg = string.Format(PowerShellWorkerStrings.BindingNameNotExist, name); + ErrorRecord er = new ErrorRecord( + new InvalidOperationException(errorMsg), + nameof(PowerShellWorkerStrings.BindingNameNotExist), + ErrorCategory.InvalidOperation, + targetObject: name); + + this.ThrowTerminatingError(er); + } + + return bindingInfo; + } + + /// + /// Helper private function that maps an output binding to a data collecting behavior. + /// + private DataCollectingBehavior GetDataCollectingBehavior(ReadOnlyBindingInfo bindingInfo) + { + switch (bindingInfo.Type) + { + case "http": return DataCollectingBehavior.Singleton; + case "blob": return DataCollectingBehavior.Singleton; + + case "sendGrid": return DataCollectingBehavior.Singleton; + case "onedrive": return DataCollectingBehavior.Singleton; + case "outlook": return DataCollectingBehavior.Singleton; + case "notificationHub": return DataCollectingBehavior.Singleton; + + case "excel": return DataCollectingBehavior.Collection; + case "table": return DataCollectingBehavior.Collection; + case "queue": return DataCollectingBehavior.Collection; + case "eventHub": return DataCollectingBehavior.Collection; + case "documentDB": return DataCollectingBehavior.Collection; + case "mobileTable": return DataCollectingBehavior.Collection; + case "serviceBus": return DataCollectingBehavior.Collection; + case "signalR": return DataCollectingBehavior.Collection; + case "twilioSms": return DataCollectingBehavior.Collection; + case "graphWebhookSubscription": return DataCollectingBehavior.Collection; + + // Be conservative on new output bindings + default: return DataCollectingBehavior.Singleton; + } + } + + /// + /// Combine the new data with the existing data for a output binding with 'Collection' behavior. + /// Here is what this command does: + /// - when there is no existing data + /// - if the new data is considered enumerable by PowerShell, + /// then all its elements get added to a List[object], and that list is returned. + /// - otherwise, the new data is returned intact. + /// + /// - when there is existing data + /// - if the existing data is a singleton, then a List[object] is created and the existing data + /// is added to the list. + /// - otherwise, the existing data is already a List[object] + /// - Then, depending on whether the new data is enumerable or not, its elements or itself will also be added to the list. + /// - That list is returned. + /// + private object MergeCollection(object oldData, object newData) + { + bool isNewDataEnumerable = LanguagePrimitives.IsObjectEnumerable(newData); + if (oldData == null && !isNewDataEnumerable) + { + return newData; + } + + var list = oldData as List; + if (list == null) + { + list = new List(); + if (oldData != null) + { + list.Add(oldData); + } + } + + if (isNewDataEnumerable) + { + var newDataEnumerable = LanguagePrimitives.GetEnumerable(newData); + foreach (var item in newDataEnumerable) + { + list.Add(item); + } + } + else + { + list.Add(newData); + } + + return list; + } + + private enum DataCollectingBehavior + { + Singleton, + Collection + } + } +} diff --git a/src/Public/Commands/TracePipelineObjectCommand.cs b/src/Public/Commands/TracePipelineObjectCommand.cs new file mode 100644 index 00000000..432bbace --- /dev/null +++ b/src/Public/Commands/TracePipelineObjectCommand.cs @@ -0,0 +1,70 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using Microsoft.PowerShell.Commands; +using Microsoft.Azure.Functions.PowerShellWorker.Utility; + +namespace Microsoft.Azure.Functions.PowerShellWorker.Commands +{ + using System.Management.Automation; + + /// + /// Writes the formatted output of the pipeline object to the information stream before passing the object down to the pipeline. + /// + /// + /// This Cmdlet behaves like 'Tee-Object'. + /// An input pipeline object is first pushed through a steppable pipeline that consists of 'Out-String | Write-Information -Tags "__PipelineObject__"', + /// and then it's written out back to the pipeline without change. In this approach, we can intercept and trace the pipeline objects in a streaming way + /// and keep the objects in pipeline at the same time. + /// + [Cmdlet(VerbsDiagnostic.Trace, "PipelineObject")] + public sealed class TracePipelineObjectCommand : PSCmdlet + { + /// + /// The object from pipeline. + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true)] + public object InputObject { get; set; } + + private static PowerShell s_pwsh; + private SteppablePipeline _stepPipeline; + + static TracePipelineObjectCommand() + { + s_pwsh = PowerShell.Create(); + s_pwsh.AddCommand(Utils.OutStringCmdletInfo).AddParameter("Stream", Utils.BoxedTrue) + .AddCommand(Utils.WriteInformationCmdletInfo).AddParameter("Tags", "__PipelineObject__"); + } + + /// + /// BeginProcessing override. + /// + protected override void BeginProcessing() + { + _stepPipeline = s_pwsh.GetSteppablePipeline(); + _stepPipeline.Begin(this); + } + + /// + /// ProcessRecord override. + /// + protected override void ProcessRecord() + { + _stepPipeline.Process(InputObject); + WriteObject(InputObject); + } + + /// + /// EndProcessing override. + /// + protected override void EndProcessing() + { + _stepPipeline.End(); + } + } +} diff --git a/src/Public/FunctionMetadata.cs b/src/Public/FunctionMetadata.cs index 0fcf942a..6ce88ab0 100644 --- a/src/Public/FunctionMetadata.cs +++ b/src/Public/FunctionMetadata.cs @@ -4,6 +4,7 @@ // using System; +using System.Collections; using System.Collections.Concurrent; using System.Collections.ObjectModel; @@ -14,8 +15,11 @@ namespace Microsoft.Azure.Functions.PowerShellWorker /// public static class FunctionMetadata { - internal static ConcurrentDictionary> OutputBindingCache + private static ConcurrentDictionary> OutputBindingCache = new ConcurrentDictionary>(); + private static ConcurrentDictionary OutputBindingValues = new ConcurrentDictionary(); + + private static Func s_delegate = key => new Hashtable(StringComparer.OrdinalIgnoreCase); /// /// Get the binding metadata for the given Runspace instance id. @@ -27,6 +31,14 @@ public static ReadOnlyDictionary GetOutputBindingIn return outputBindings; } + /// + /// Get the Hashtable that is holding the output binding values for the given Runspace. + /// + internal static Hashtable GetOutputBindingHashtable(Guid runspaceInstanceId) + { + return OutputBindingValues.GetOrAdd(runspaceInstanceId, s_delegate); + } + /// /// Helper method to set the output binding metadata for the function that is about to run. /// diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index f0d37245..e46d1b0c 100644 --- a/src/RequestProcessor.cs +++ b/src/RequestProcessor.cs @@ -20,7 +20,6 @@ namespace Microsoft.Azure.Functions.PowerShellWorker { internal class RequestProcessor { - private readonly FunctionLoader _functionLoader; private readonly MessagingStream _msgStream; private readonly PowerShellManagerPool _powershellPool; private readonly DependencyManager _dependencyManager; @@ -38,7 +37,6 @@ internal RequestProcessor(MessagingStream msgStream) { _msgStream = msgStream; _powershellPool = new PowerShellManagerPool(msgStream); - _functionLoader = new FunctionLoader(); _dependencyManager = new DependencyManager(); // Host sends capabilities/init data to worker @@ -194,7 +192,8 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) try { - _functionLoader.LoadFunction(functionLoadRequest); + // Load the metadata of the function. + FunctionLoader.LoadFunction(functionLoadRequest); } catch (Exception e) { @@ -230,7 +229,7 @@ internal StreamingMessage ProcessInvocationRequest(StreamingMessage request) } else { - AzFunctionInfo functionInfo = _functionLoader.GetFunctionInfo(request.InvocationRequest.FunctionId); + AzFunctionInfo functionInfo = FunctionLoader.GetFunctionInfo(request.InvocationRequest.FunctionId); PowerShellManager psManager = _powershellPool.CheckoutIdleWorker(request, functionInfo); if (_powershellPool.UpperBound == 1) diff --git a/src/Utility/Utils.cs b/src/Utility/Utils.cs index 349bbe5d..18bdfd3a 100644 --- a/src/Utility/Utils.cs +++ b/src/Utility/Utils.cs @@ -4,6 +4,7 @@ // using System; +using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; @@ -18,10 +19,16 @@ internal class Utils { internal readonly static CmdletInfo ImportModuleCmdletInfo = new CmdletInfo("Import-Module", typeof(ImportModuleCommand)); internal readonly static CmdletInfo RemoveModuleCmdletInfo = new CmdletInfo("Remove-Module", typeof(RemoveModuleCommand)); - internal readonly static CmdletInfo GetJobCmdletInfo = new CmdletInfo("Get-Job", typeof(GetJobCommand)); internal readonly static CmdletInfo RemoveJobCmdletInfo = new CmdletInfo("Remove-Job", typeof(RemoveJobCommand)); + internal readonly static CmdletInfo OutStringCmdletInfo = new CmdletInfo("Out-String", typeof(OutStringCommand)); + internal readonly static CmdletInfo WriteInformationCmdletInfo = new CmdletInfo("Write-Information", typeof(WriteInformationCommand)); + internal readonly static object BoxedTrue = (object)true; + internal readonly static object BoxedFalse = (object)false; + + private const string VariableDriveRoot = @"Variable:\"; private static InitialSessionState s_iss; + private static HashSet s_globalVariables; /// /// Create a new PowerShell instance using our singleton InitialSessionState instance. @@ -52,7 +59,75 @@ internal static PowerShell NewPwshInstance() } } - return PowerShell.Create(s_iss); + var pwsh = PowerShell.Create(s_iss); + if (s_globalVariables == null) + { + // Get the names of the built-in global variables + ICollection globalVars = GetGlobalVariables(pwsh); + s_globalVariables = new HashSet(globalVars.Count + 3, StringComparer.OrdinalIgnoreCase) + { + // These 3 variables are not in the built-in variables in a fresh Runspace, + // but they show up after we evaluate the 'profile.ps1' in the global scope. + "PSScriptRoot", "PSCommandPath", "MyInvocation" + }; + + foreach (PSVariable var in globalVars) + { + s_globalVariables.Add(var.Name); + } + } + + return pwsh; + } + + /// + /// Clean up the global variables added by the function invocation. + /// + internal static void CleanupGlobalVariables(PowerShell pwsh) + { + List varsToRemove = null; + ICollection globalVars = GetGlobalVariables(pwsh); + + foreach (PSVariable var in globalVars) + { + // The variable is one of the built-in global variables. + if (s_globalVariables.Contains(var.Name)) { continue; } + + // We cannot remove a constant variable, so leave it as is. + if (var.Options.HasFlag(ScopedItemOptions.Constant)) { continue; } + + // The variable is exposed by a module. We don't remove modules, so leave it as is. + if (var.Module != null) { continue; } + + // The variable is not a regular PSVariable. + // It's likely not created by the user, so leave it as is. + if (var.GetType() != typeof(PSVariable)) { continue; } + + if (varsToRemove == null) + { + // Create a list only if it's needed. + varsToRemove = new List(); + } + + // Add the variable path. + varsToRemove.Add($"{VariableDriveRoot}{var.Name}"); + } + + if (varsToRemove != null) + { + // Remove the global variable added by the function invocation. + pwsh.Runspace.SessionStateProxy.InvokeProvider.Item.Remove( + varsToRemove.ToArray(), + recurse: true, + force: true, + literalPath: true); + } + } + + private static ICollection GetGlobalVariables(PowerShell pwsh) + { + PSObject item = pwsh.Runspace.SessionStateProxy.InvokeProvider.Item.Get(VariableDriveRoot)[0]; + return (ICollection)item.BaseObject; } /// diff --git a/src/resources/PowerShellWorkerStrings.resx b/src/resources/PowerShellWorkerStrings.resx index 2af57f0c..27ac7c97 100644 --- a/src/resources/PowerShellWorkerStrings.resx +++ b/src/resources/PowerShellWorkerStrings.resx @@ -214,4 +214,16 @@ Managed dependency download is in progress, function execution will continue when it's done. + + The output binding '{0}' is already set with a value. The type of the output binding is '{1}'. It only accepts one message/record/file per a Function invocation. To override the value, use '-Clobber'. + + + 'Push-OutputBinding' should only be used in the PowerShell Language Worker's default Runspace(s). Do not use it in a custom Runsapce created during the function execution because the pushed values cannot be collected. + + + The specified name '{0}' cannot be resolved to a valid output binding of this function. + + + Unrecognized data collecting behavior '{0}'. + \ No newline at end of file diff --git a/test/Unit/Function/FunctionLoaderTests.cs b/test/Unit/Function/FunctionLoaderTests.cs index 34e25407..247f8a06 100644 --- a/test/Unit/Function/FunctionLoaderTests.cs +++ b/test/Unit/Function/FunctionLoaderTests.cs @@ -10,7 +10,7 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.Test { - public class FunctionLoaderTests + public class FunctionLoaderTests : IDisposable { private readonly string _functionDirectory; private readonly FunctionLoadRequest _functionLoadRequest; @@ -47,6 +47,11 @@ private FunctionLoadRequest GetFuncLoadRequest(string scriptFile, string entryPo return functionLoadRequest; } + public void Dispose() + { + FunctionLoader.ClearLoadedFunctions(); + } + [Fact] public void TestFunctionLoaderGetFunc() { @@ -54,14 +59,38 @@ public void TestFunctionLoaderGetFunc() var entryPointToUse = string.Empty; var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); - var functionLoader = new FunctionLoader(); - functionLoader.LoadFunction(functionLoadRequest); + FunctionLoader.LoadFunction(functionLoadRequest); + var funcInfo = FunctionLoader.GetFunctionInfo(functionLoadRequest.FunctionId); + + Assert.Equal(scriptFileToUse, funcInfo.ScriptPath); + Assert.Equal(string.Empty, funcInfo.EntryPoint); + + Assert.NotNull(funcInfo.FuncScriptBlock); + + Assert.Equal(2, funcInfo.FuncParameters.Count); + Assert.True(funcInfo.FuncParameters.ContainsKey("req")); + Assert.True(funcInfo.FuncParameters.ContainsKey("inputBlob")); + + Assert.Equal(3, funcInfo.AllBindings.Count); + Assert.Equal(2, funcInfo.InputBindings.Count); + Assert.Single(funcInfo.OutputBindings); + } + + [Fact] + public void TestFunctionLoaderGetFuncWithRequires() + { + var scriptFileToUse = Path.Join(_functionDirectory, "FuncWithRequires.ps1"); + var entryPointToUse = string.Empty; + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); - var funcInfo = functionLoader.GetFunctionInfo(functionLoadRequest.FunctionId); + FunctionLoader.LoadFunction(functionLoadRequest); + var funcInfo = FunctionLoader.GetFunctionInfo(functionLoadRequest.FunctionId); Assert.Equal(scriptFileToUse, funcInfo.ScriptPath); Assert.Equal(string.Empty, funcInfo.EntryPoint); + Assert.Null(funcInfo.FuncScriptBlock); + Assert.Equal(2, funcInfo.FuncParameters.Count); Assert.True(funcInfo.FuncParameters.ContainsKey("req")); Assert.True(funcInfo.FuncParameters.ContainsKey("inputBlob")); @@ -78,14 +107,14 @@ public void TestFunctionLoaderGetFuncWithTriggerMetadataParam() var entryPointToUse = string.Empty; var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); - var functionLoader = new FunctionLoader(); - functionLoader.LoadFunction(functionLoadRequest); - - var funcInfo = functionLoader.GetFunctionInfo(functionLoadRequest.FunctionId); + FunctionLoader.LoadFunction(functionLoadRequest); + var funcInfo = FunctionLoader.GetFunctionInfo(functionLoadRequest.FunctionId); Assert.Equal(scriptFileToUse, funcInfo.ScriptPath); Assert.Equal(string.Empty, funcInfo.EntryPoint); + Assert.NotNull(funcInfo.FuncScriptBlock); + Assert.Equal(3, funcInfo.FuncParameters.Count); Assert.True(funcInfo.FuncParameters.ContainsKey("req")); Assert.True(funcInfo.FuncParameters.ContainsKey("inputBlob")); @@ -103,14 +132,14 @@ public void TestFunctionLoaderGetFuncWithEntryPoint() var entryPointToUse = "Run"; var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); - var functionLoader = new FunctionLoader(); - functionLoader.LoadFunction(functionLoadRequest); - - var funcInfo = functionLoader.GetFunctionInfo(functionLoadRequest.FunctionId); + FunctionLoader.LoadFunction(functionLoadRequest); + var funcInfo = FunctionLoader.GetFunctionInfo(functionLoadRequest.FunctionId); Assert.Equal(scriptFileToUse, funcInfo.ScriptPath); Assert.Equal(entryPointToUse, funcInfo.EntryPoint); + Assert.Null(funcInfo.FuncScriptBlock); + Assert.Equal(2, funcInfo.FuncParameters.Count); Assert.True(funcInfo.FuncParameters.ContainsKey("req")); Assert.True(funcInfo.FuncParameters.ContainsKey("inputBlob")); @@ -128,7 +157,7 @@ public void EntryPointIsSupportedWithPsm1FileOnly() var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("entryPoint", exception.Message); Assert.Contains("(.psm1)", exception.Message); } @@ -141,7 +170,7 @@ public void Psm1IsSupportedWithEntryPointOnly() var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("entryPoint", exception.Message); Assert.Contains("(.psm1)", exception.Message); } @@ -154,7 +183,7 @@ public void ParseErrorInScriptFileShouldBeDetected() var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("parsing errors", exception.Message); } @@ -166,7 +195,7 @@ public void EntryPointFunctionShouldExist() var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("CallMe", exception.Message); Assert.Contains("FuncWithEntryPoint.psm1", exception.Message); } @@ -179,7 +208,7 @@ public void MultipleEntryPointFunctionsShouldBeDetected() var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("Run", exception.Message); Assert.Contains("FuncWithMultiEntryPoints.psm1", exception.Message); } @@ -195,7 +224,7 @@ public void ParametersShouldMatchInputBinding() functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("inputTable", exception.Message); Assert.Contains("inputBlob", exception.Message); } @@ -211,7 +240,7 @@ public void ParametersShouldMatchInputBindingWithTriggerMetadataParam() functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("inputTable", exception.Message); Assert.Contains("inputBlob", exception.Message); } @@ -227,7 +256,7 @@ public void EntryPointParametersShouldMatchInputBinding() functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("inputTable", exception.Message); Assert.Contains("inputBlob", exception.Message); } @@ -243,7 +272,7 @@ public void EntryPointParametersShouldMatchInputBindingWithTriggerMetadataParam( functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("inputTable", exception.Message); Assert.Contains("inputBlob", exception.Message); } @@ -258,7 +287,7 @@ public void InOutBindingIsNotSupported() functionLoadRequest.Metadata.Bindings.Add("inoutBinding", new BindingInfo { Direction = BindingInfo.Types.Direction.Inout, Type = "queue" }); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("inoutBinding", exception.Message); Assert.Contains("InOut", exception.Message); } @@ -271,7 +300,7 @@ public void ScriptNeedToHaveParameters() var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("req", exception.Message); Assert.Contains("inputBlob", exception.Message); } @@ -284,7 +313,7 @@ public void EntryPointNeedToHaveParameters() var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); var exception = Assert.Throws( - () => new FunctionLoader().LoadFunction(functionLoadRequest)); + () => FunctionLoader.LoadFunction(functionLoadRequest)); Assert.Contains("req", exception.Message); Assert.Contains("inputBlob", exception.Message); } diff --git a/test/Unit/Function/TestScripts/FuncWithRequires.ps1 b/test/Unit/Function/TestScripts/FuncWithRequires.ps1 new file mode 100644 index 00000000..a2a4f309 --- /dev/null +++ b/test/Unit/Function/TestScripts/FuncWithRequires.ps1 @@ -0,0 +1,10 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +#Requires -Modules ThreadJob + +param($req, $inputBlob) + +"DoNothing" diff --git a/test/Unit/Modules/HelperModuleTests.cs b/test/Unit/Modules/HelperModuleTests.cs new file mode 100644 index 00000000..5318affa --- /dev/null +++ b/test/Unit/Modules/HelperModuleTests.cs @@ -0,0 +1,309 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Azure.Functions.PowerShellWorker.PowerShell; +using Microsoft.Azure.Functions.PowerShellWorker.Utility; +using Microsoft.Azure.WebJobs.Script.Grpc.Messages; +using Xunit; + +namespace Microsoft.Azure.Functions.PowerShellWorker.Test +{ + using System.Management.Automation; + + public class HelperModuleTests : IDisposable + { + private const string Response = "response"; + private const string Queue = "queue"; + private const string Foo = "Foo"; + private const string Bar = "Bar"; + private const string Food = "Food"; + + private readonly static AzFunctionInfo s_funcInfo; + private readonly static PowerShell s_pwsh; + + static HelperModuleTests() + { + var funcDirectory = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "TestScripts", "PowerShell"); + var rpcFuncMetadata = new RpcFunctionMetadata() + { + Name = "TestFuncApp", + Directory = funcDirectory, + ScriptFile = Path.Join(funcDirectory, "testBasicFunction.ps1"), + EntryPoint = string.Empty, + Bindings = + { + { "req" , new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "httpTrigger" } }, + { Response, new BindingInfo { Direction = BindingInfo.Types.Direction.Out, Type = "http" } }, + { Queue, new BindingInfo { Direction = BindingInfo.Types.Direction.Out, Type = "queue" } }, + { Foo, new BindingInfo { Direction = BindingInfo.Types.Direction.Out, Type = "new" } }, + { Bar, new BindingInfo { Direction = BindingInfo.Types.Direction.Out, Type = "new" } }, + { Food, new BindingInfo { Direction = BindingInfo.Types.Direction.Out, Type = "new" } } + } + }; + + var funcLoadReq = new FunctionLoadRequest { FunctionId = "FunctionId", Metadata = rpcFuncMetadata }; + FunctionLoader.SetupWellKnownPaths(funcLoadReq); + s_pwsh = Utils.NewPwshInstance(); + s_funcInfo = new AzFunctionInfo(rpcFuncMetadata); + } + + public HelperModuleTests() + { + FunctionMetadata.RegisterFunctionMetadata(s_pwsh.Runspace.InstanceId, s_funcInfo); + } + + public void Dispose() + { + FunctionMetadata.UnregisterFunctionMetadata(s_pwsh.Runspace.InstanceId); + s_pwsh.AddScript("Get-OutputBinding -Purge").InvokeAndClearCommands(); + } + + [Fact] + public void BasicPushGetValueTests() + { + // The first item added to 'queue' is the value itself + s_pwsh.AddScript("Push-OutputBinding -Name queue -Value 5").InvokeAndClearCommands(); + var results = s_pwsh.AddScript("Get-OutputBinding").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Single(results[0]); + Assert.Equal(5, results[0][Queue]); + + // The second item added to 'queue' will make it a list + s_pwsh.AddScript("Push-OutputBinding -Name queue -Value 6").InvokeAndClearCommands(); + results = s_pwsh.AddScript("Get-OutputBinding").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Single(results[0]); + Assert.IsType>(results[0][Queue]); + + var list = (List)results[0][Queue]; + Assert.Equal(2, list.Count); + Assert.Equal(5, list[0]); + Assert.Equal(6, list[1]); + + // The array added to 'queue' will get unraveled + var array = new object[] { 7, 8 }; + s_pwsh.AddScript("Push-OutputBinding -Name queue -Value @(7, 8)").InvokeAndClearCommands(); + results = s_pwsh.AddScript("Get-OutputBinding -Purge").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Single(results[0]); + Assert.IsType>(results[0][Queue]); + + list = (List)results[0][Queue]; + Assert.Equal(4, list.Count); + Assert.Equal(5, list[0]); + Assert.Equal(6, list[1]); + Assert.Equal(7, list[2]); + Assert.Equal(8, list[3]); + + // The array gets unraveled and added to a list + s_pwsh.AddScript("Push-OutputBinding -Name queue -Value @(1, 2)").InvokeAndClearCommands(); + results = s_pwsh.AddScript("Get-OutputBinding -Purge").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Single(results[0]); + Assert.IsType>(results[0][Queue]); + + list = (List)results[0][Queue]; + Assert.Equal(2, list.Count); + Assert.Equal(1, list[0]); + Assert.Equal(2, list[1]); + } + + [Fact] + public void BindingNameWithDifferentCaseShouldWork() + { + s_pwsh.AddScript("Push-OutputBinding -Name RESPONSE -Value 'UpperCase'").InvokeAndClearCommands(); + s_pwsh.AddScript("Push-OutputBinding -Name QUeue -Value 'MixedCase'").InvokeAndClearCommands(); + var results = s_pwsh.AddScript("Get-OutputBinding -Purge").InvokeAndClearCommands(); + + Assert.Single(results); + Assert.Equal(2, results[0].Count); + Assert.Equal("UpperCase", results[0][Response]); + Assert.Equal("MixedCase", results[0][Queue]); + } + + [Fact] + public void PushOutBindingShouldWorkWithPipelineInput() + { + s_pwsh.AddScript("'Baz' | Push-OutputBinding -Name response").InvokeAndClearCommands(); + s_pwsh.AddScript("'item1', 'item2', 'item3' | Push-OutputBinding -Name queue").InvokeAndClearCommands(); + var results = s_pwsh.AddScript("Get-OutputBinding -Purge").InvokeAndClearCommands(); + + Assert.Single(results); + Assert.Equal(2, results[0].Count); + Assert.Equal("Baz", results[0][Response].ToString()); + + Assert.IsType>(results[0][Queue]); + var list = (List)results[0][Queue]; + Assert.Equal(3, list.Count); + Assert.Equal("item1", list[0].ToString()); + Assert.Equal("item2", list[1].ToString()); + Assert.Equal("item3", list[2].ToString()); + } + + [Fact] + public void PushToHttpResponseTwiceShouldFail() + { + s_pwsh.AddScript("Push-OutputBinding -Name response -Value res").InvokeAndClearCommands(); + var results = s_pwsh.AddScript("Get-OutputBinding").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Single(results[0]); + Assert.Equal("res", results[0][Response]); + + s_pwsh.AddScript("Push-OutputBinding -Name response -Value baz").Invoke(); + Assert.Single(s_pwsh.Streams.Error); + + var error = s_pwsh.Streams.Error[0]; + Assert.IsType(error.Exception); + Assert.Contains("http", error.Exception.Message); + Assert.Contains("-Clobber", error.Exception.Message); + } + + [Fact] + public void PushWithNonExistingBindingNameShouldThrow() + { + s_pwsh.AddScript("Push-OutputBinding nonExist baz").Invoke(); + Assert.Single(s_pwsh.Streams.Error); + + var error = s_pwsh.Streams.Error[0]; + Assert.IsType(error.Exception); + Assert.Contains("nonExist", error.Exception.Message); + } + + [Fact] + public void OverwritingShouldWork() + { + s_pwsh.AddScript("Push-OutputBinding response 5").InvokeAndClearCommands(); + var results = s_pwsh.AddScript("Get-OutputBinding").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Single(results[0]); + Assert.Equal(5, results[0][Response]); + + // Overwrite the old value for the for 'response' output binding with -Clobber. + s_pwsh.AddScript("Push-OutputBinding response 6 -Clobber").InvokeAndClearCommands(); + results = s_pwsh.AddScript("Get-OutputBinding -Purge").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Single(results[0]); + Assert.Equal(6, results[0][Response]); + + s_pwsh.AddScript("Push-OutputBinding queue 1").InvokeAndClearCommands(); + results = s_pwsh.AddScript("Get-OutputBinding").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Single(results[0]); + Assert.Equal(1, results[0][Queue]); + + // Even queue output binding accept multiple values, when -Clobber is specified, the old value is overwritten. + s_pwsh.AddScript("Push-OutputBinding queue 2 -Clobber").InvokeAndClearCommands(); + results = s_pwsh.AddScript("Get-OutputBinding").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Single(results[0]); + Assert.Equal(2, results[0][Queue]); + + // Overwrite with an array will make the value a list that contains the items from the array. + s_pwsh.AddScript("Push-OutputBinding queue @(3, 4) -Clobber").InvokeAndClearCommands(); + results = s_pwsh.AddScript("Get-OutputBinding -Purge").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Single(results[0]); + Assert.IsType>(results[0][Queue]); + var list = (List)results[0][Queue]; + Assert.Equal(2, list.Count); + Assert.Equal(3, list[0]); + Assert.Equal(4, list[1]); + } + + [Fact] + public void GetOutputBindingShouldWork() + { + s_pwsh.AddScript("Push-OutputBinding -Name Foo 1").InvokeAndClearCommands(); + s_pwsh.AddScript("Push-OutputBinding Bar -Value Baz").InvokeAndClearCommands(); + s_pwsh.AddScript("Push-OutputBinding -Name Food -Value apple").InvokeAndClearCommands(); + + // No name specified + var results = s_pwsh.AddScript("Get-OutputBinding").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Equal(3, results[0].Count); + + Assert.Equal(1, results[0][Foo]); + Assert.Equal("Baz", results[0][Bar]); + Assert.Equal("apple", results[0][Food]); + + // Specify the name + results = s_pwsh.AddScript("Get-OutputBinding -Name Foo").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Single(results[0]); + Assert.Equal(1, results[0][Foo]); + + // Explicit name specified that does not exist + results = s_pwsh.AddScript("Get-OutputBinding -Name DoesNotExist").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Empty(results[0]); + + // Wildcard name specified + results = s_pwsh.AddScript("Get-OutputBinding -Name F*").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Equal(2, results[0].Count); + + Assert.Equal(1, results[0][Foo]); + Assert.Equal("apple", results[0][Food]); + + // User -Purge should clear the output binding values + results = s_pwsh.AddScript("Get-OutputBinding -Purge").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Equal(3, results[0].Count); + + Assert.Equal(1, results[0][Foo]); + Assert.Equal("Baz", results[0][Bar]); + Assert.Equal("apple", results[0][Food]); + + // Values should have been cleared. + results = s_pwsh.AddScript("Get-OutputBinding").InvokeAndClearCommands(); + Assert.Single(results); + Assert.Empty(results[0]); + } + + [Fact] + public void TracePipelineObjectShouldWork() + { + string script = @" + $cmd = Get-Command -Name Get-Command + function Write-TestObject { + foreach ($i in 1..20) { + Write-Output $cmd + } + Write-Information '__LAST_INFO_MSG__' + }"; + + s_pwsh.AddScript(script).InvokeAndClearCommands(); + + var outStringResults = s_pwsh.AddScript("Write-TestObject | Out-String -Stream").InvokeAndClearCommands(); + var results = s_pwsh.AddScript("Write-TestObject | Trace-PipelineObject").Invoke(); + Assert.Equal(20, results.Count); + foreach (var item in results) + { + Assert.Equal("Get-Command", item.Name); + } + + Assert.Equal(outStringResults.Count + 1, s_pwsh.Streams.Information.Count); + + int lastNonWhitespaceItem = outStringResults.Count - 1; + while (string.IsNullOrWhiteSpace(outStringResults[lastNonWhitespaceItem])) { + lastNonWhitespaceItem --; + } + + for (int i = 0; i <= lastNonWhitespaceItem; i++) { + Assert.Equal(outStringResults[i], s_pwsh.Streams.Information[i].MessageData.ToString()); + Assert.Single(s_pwsh.Streams.Information[i].Tags); + Assert.Equal("__PipelineObject__", s_pwsh.Streams.Information[i].Tags[0]); + } + + Assert.Equal("__LAST_INFO_MSG__", s_pwsh.Streams.Information[lastNonWhitespaceItem + 1].MessageData.ToString()); + Assert.Empty(s_pwsh.Streams.Information[lastNonWhitespaceItem + 1].Tags); + } + } +} diff --git a/test/Unit/Modules/Microsoft.Azure.Functions.PowerShellWorker.Tests.ps1 b/test/Unit/Modules/Microsoft.Azure.Functions.PowerShellWorker.Tests.ps1 deleted file mode 100644 index 85337b9c..00000000 --- a/test/Unit/Modules/Microsoft.Azure.Functions.PowerShellWorker.Tests.ps1 +++ /dev/null @@ -1,314 +0,0 @@ -# -# Copyright (c) Microsoft. All rights reserved. -# Licensed under the MIT license. See LICENSE file in the project root for full license information. -# - -Describe 'Azure Functions PowerShell Langauge Worker Helper Module Tests' { - - BeforeAll { - - ## Create the type 'FunctionMetadata' for our test - $code = @' -namespace Microsoft.Azure.Functions.PowerShellWorker -{ - using System; - using System.Collections; - - public class FunctionMetadata - { - public static Hashtable GetOutputBindingInfo(Guid guid) - { - var hash = new Hashtable(StringComparer.OrdinalIgnoreCase); - var bi1 = new BindingInfo() { Type = "http", Direction = "out" }; - hash.Add("response", bi1); - var bi2 = new BindingInfo() { Type = "queue", Direction = "out" }; - hash.Add("queue", bi2); - - var bi3 = new BindingInfo() { Type = "new", Direction = "out" }; - hash.Add("Foo", bi3); - hash.Add("Bar", bi3); - hash.Add("Food", bi3); - - return hash; - } - } - - public class BindingInfo - { - public string Type; - public string Direction; - } -} -'@ - $type = "Microsoft.Azure.Functions.PowerShellWorker.FunctionMetadata" -as [Type] - if ($null -eq $type) { - Add-Type -TypeDefinition $code - } - - # Move the .psd1 and .psm1 files to the publish folder so that the dlls can be found - $binFolder = Resolve-Path -Path "$PSScriptRoot/../bin" - $workerDll = Get-ChildItem -Path $binFolder -Filter "Microsoft.Azure.Functions.PowerShellWorker.dll" -Recurse | Select-Object -First 1 - - $moduleFolder = Join-Path -Path $workerDll.Directory.FullName -ChildPath "Modules\Microsoft.Azure.Functions.PowerShellWorker" - $modulePath = Join-Path -Path $moduleFolder -ChildPath "Microsoft.Azure.Functions.PowerShellWorker.psd1" - Import-Module $modulePath - - # Helper function that tests hashtable equality - function IsEqualHashtable ($h1, $h2) { - # Handle nulls - if (!$h1) { - if(!$h2) { - return $true - } - return $false - } - if (!$h2) { - return $false - } - - # If they don't have the same amount of key value pairs, fail early - if ($h1.Count -ne $h2.Count){ - return $false - } - - # Check to make sure every key exists in the other and that the values are the same - foreach ($key in $h1.Keys) { - if (!$h2.ContainsKey($key)) { - return $false - } - if ($h1[$key] -eq $h2[$key]) { - continue - } - if ($h1[$key] -is [System.Collections.Generic.List[object]] -and $h2[$key] -is [object[]]) { - $s1 = $h1[$key] -join "," - $s2 = $h2[$key] -join "," - if ($s1 -eq $s2) { - continue - } - } - return $false - } - return $true - } - } - - Context 'Push-OutputBinding tests' { - - AfterAll { - Get-OutputBinding -Purge > $null - } - - It 'Can add a value via parameters' { - $Key = 'queue' - - ## The first item added to 'queue' is the value itself - Push-OutputBinding -Name $Key -Value 5 - $result = Get-OutputBinding - $result[$Key] | Should -BeExactly 5 - - ## The second item added to 'queue' will make it a list - Push-OutputBinding -Name $Key -Value 6 - $result = Get-OutputBinding - $result[$Key] -is [System.Collections.Generic.List[object]] | Should -BeTrue - $result[$Key][0] | Should -BeExactly 5 - $result[$Key][1] | Should -BeExactly 6 - - ## The array added to 'queue' will get unraveled - Push-OutputBinding -Name $Key -Value @(7, 8) - $result = Get-OutputBinding -Purge - - $result[$Key] -is [System.Collections.Generic.List[object]] | Should -BeTrue - $result[$Key][0] | Should -BeExactly 5 - $result[$Key][1] | Should -BeExactly 6 - $result[$Key][2] | Should -BeExactly 7 - $result[$Key][3] | Should -BeExactly 8 - - ## The array gets unraveled and added to a list - Push-OutputBinding -Name $Key -Value @(1, 2) - $result = Get-OutputBinding -Purge - $result[$Key] -is [System.Collections.Generic.List[object]] | Should -BeTrue - $result[$Key][0] | Should -BeExactly 1 - $result[$Key][1] | Should -BeExactly 2 - } - - It 'Can add value with binding name that differs in case' { - Push-OutputBinding -Name RESPONSE -Value 'UpperCase' - Push-OutputBinding -Name QUeue -Value 'MixedCase' - - $result = Get-OutputBinding -Purge - if ($IsWindows) { - $result["response"] | Should -BeExactly 'UpperCase' - $result["queue"] | Should -BeExactly 'MixedCase' - } else { - # Hashtable on Ubuntu 18.04 server is case-sensitive. - # It's fixed in 6.2, but the 'pwsh' used in AppVeyor is not 6.2 - $result["RESPONSE"] | Should -BeExactly 'UpperCase' - $result["QUeue"] | Should -BeExactly 'MixedCase' - } - } - - It 'Can add a value via pipeline' { - 'Baz' | Push-OutputBinding -Name response - 'item1', 'item2', 'item3' | Push-OutputBinding -Name queue - $expected = @{ response = 'Baz'; queue = @('item1', 'item2', 'item3') } - $result = Get-OutputBinding -Purge - IsEqualHashtable $result $expected | Should -BeTrue ` - -Because 'The hashtables should be identical' - } - - It 'Throws if you attempt to overwrite an Output binding' { - try { - Push-OutputBinding response 'res' - { Push-OutputBinding response 'baz' } | Should -Throw - } finally { - Get-OutputBinding -Purge > $null - } - } - - It 'Throw if you use a non-existent output binding name' { - { Push-OutputBinding nonexist 'baz' } | Should -Throw - } - - It 'Can overwrite values if "-Clobber" is specified' { - Push-OutputBinding response 5 - $result = Get-OutputBinding - IsEqualHashtable @{response = 5} $result | Should -BeTrue ` - -Because 'The hashtables should be identical' - - Push-OutputBinding response 6 -Clobber - $result = Get-OutputBinding -Purge - IsEqualHashtable @{response = 6} $result | Should -BeTrue ` - -Because '-Clobber should let you overwrite the output binding' - - Push-OutputBinding 'queue' 1 - $result = Get-OutputBinding - $result['queue'] | Should -BeExactly 1 - - Push-OutputBinding 'queue' 2 -Clobber - $result = Get-OutputBinding - $result['queue'] | Should -BeExactly 2 - - Push-OutputBinding 'queue' @(3, 4) -Clobber - $result = Get-OutputBinding -Purge - $result['queue'] -is [System.Collections.Generic.List[object]] | Should -BeTrue - $result['queue'][0] | Should -BeExactly 3 - $result['queue'][1] | Should -BeExactly 4 - } - } - - Context 'Get-OutputBinding tests' { - BeforeAll { - Push-OutputBinding -Name Foo -Value 1 - Push-OutputBinding -Name Bar -Value 'Baz' - Push-OutputBinding -Name Food -Value 'apple' - } - - AfterAll { - Get-OutputBinding -Purge - } - - It 'Can get the output binding hashmap - ' -TestCases @( - @{ - Query = @{} - Expected = @{ Foo = 1; Bar = 'Baz'; Food = 'apple'} - Description = 'No name specified' - }, - @{ - Query = @{ Name = 'Foo' } - Expected = @{ Foo = 1; } - Description = 'Explicit name specified' - }, - @{ - Query = @{ Name = 'DoesNotExist' } - Expected = @{} - Description = 'Explicit name specified that does not exist' - }, - @{ - Query = @{ Name = 'F*' } - Expected = @{ Foo = 1; Food = 'apple' } - Description = 'Wildcard name specified' - }) -Test { - param ( - [object] $Query, - [hashtable] $Expected, - [string] $Description - ) - - $result = Get-OutputBinding @Query - IsEqualHashtable $result $Expected | Should -BeTrue ` - -Because 'The hashtables should be identical' - } - - It 'Can use the "-Purge" flag to clear the Output bindings' { - $inputData = @{ Foo = 1; Bar = 'Baz'; Food = 'apple'} - $result = Get-OutputBinding -Purge - IsEqualHashtable $result $inputData | Should -BeTrue ` - -Because 'The full hashtable should be returned' - - $newState = Get-OutputBinding - IsEqualHashtable @{} $newState | Should -BeTrue ` - -Because 'The OutputBindings should be empty' - } - } - - Context 'Trace-PipelineObject tests' { - BeforeAll { - $scriptToRun = @' - param($cmd, $modulePath) - Import-Module $modulePath - function Write-TestObject { - foreach ($i in 1..20) { - Write-Output $cmd - } - Write-Information '__LAST_INFO_MSG__' - } -'@ - $cmd = Get-Command Get-Command - $ps = [powershell]::Create() - $ps.AddScript($scriptToRun).AddParameter("cmd", $cmd).AddParameter("modulePath", $modulePath).Invoke() - $ps.Commands.Clear() - $ps.Streams.ClearStreams() - - function Write-TestObject { - foreach ($i in 1..20) { - Write-Output $cmd - } - Write-Information '__LAST_INFO_MSG__' - } - } - - AfterAll { - $ps.Dispose() - } - - AfterEach { - $ps.Commands.Clear() - $ps.Streams.ClearStreams() - } - - It "Can write tracing to information stream while keeps input object in pipeline" { - $results = $ps.AddCommand("Write-TestObject").AddCommand("Trace-PipelineObject").Invoke() - - $results.Count | Should -BeExactly 20 - for ($i = 0; $i -lt 20; $i++) { - $results[0].Name | Should -BeExactly $cmd.Name - } - - $outStringResults = Write-TestObject | Out-String -Stream - $ps.Streams.Information.Count | Should -BeExactly ($outStringResults.Count + 1) - - $lastNonWhitespaceItem = $outStringResults.Count - 1 - while ([string]::IsNullOrWhiteSpace($outStringResults[$lastNonWhitespaceItem])) { - $lastNonWhitespaceItem-- - } - - for ($i = 0; $i -le $lastNonWhitespaceItem; $i++) { - $ps.Streams.Information[$i].MessageData | Should -BeExactly $outStringResults[$i] - $ps.Streams.Information[$i].Tags | Should -BeExactly "__PipelineObject__" - } - - $ps.Streams.Information[$i].MessageData | Should -BeExactly "__LAST_INFO_MSG__" - $ps.Streams.Information[$i].Tags | Should -BeNullOrEmpty - } - } -} diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index 75e5a3fb..2a04d71b 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -7,6 +7,7 @@ using System.Collections; using System.Collections.Generic; using System.IO; +using System.Linq; using Microsoft.Azure.Functions.PowerShellWorker.PowerShell; using Microsoft.Azure.Functions.PowerShellWorker.Utility; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; @@ -18,20 +19,46 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.Test internal class TestUtils { - internal const string TestInputBindingName = "req"; - internal const string TestOutputBindingName = "res"; + // Helper method to wait for debugger to attach and set a breakpoint. + internal static void Break() + { + while (!System.Diagnostics.Debugger.IsAttached) + { + System.Threading.Thread.Sleep(200); + } + System.Diagnostics.Debugger.Break(); + } + } + + public class PowerShellManagerTests : IDisposable + { + private const string TestInputBindingName = "req"; + private const string TestOutputBindingName = "res"; + private const string TestStringData = "Foo"; - internal static readonly string FunctionDirectory; - internal static readonly RpcFunctionMetadata RpcFunctionMetadata; - internal static readonly FunctionLoadRequest FunctionLoadRequest; + private readonly static string s_funcDirectory; + private readonly static FunctionLoadRequest s_functionLoadRequest; - static TestUtils() + private readonly static ConsoleLogger s_testLogger; + private readonly static List s_testInputData; + + static PowerShellManagerTests() { - FunctionDirectory = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "TestScripts", "PowerShell"); - RpcFunctionMetadata = new RpcFunctionMetadata() + s_funcDirectory = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "TestScripts", "PowerShell"); + s_testLogger = new ConsoleLogger(); + s_testInputData = new List + { + new ParameterBinding + { + Name = TestInputBindingName, + Data = new TypedData { String = TestStringData } + } + }; + + var rpcFunctionMetadata = new RpcFunctionMetadata() { Name = "TestFuncApp", - Directory = FunctionDirectory, + Directory = s_funcDirectory, Bindings = { { TestInputBindingName , new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "httpTrigger" } }, @@ -39,8 +66,8 @@ static TestUtils() } }; - FunctionLoadRequest = new FunctionLoadRequest { FunctionId = "FunctionId", Metadata = RpcFunctionMetadata }; - FunctionLoader.SetupWellKnownPaths(FunctionLoadRequest); + s_functionLoadRequest = new FunctionLoadRequest { FunctionId = "FunctionId", Metadata = rpcFunctionMetadata }; + FunctionLoader.SetupWellKnownPaths(s_functionLoadRequest); } // Have a single place to get a PowerShellManager for testing. @@ -50,132 +77,175 @@ internal static PowerShellManager NewTestPowerShellManager(ConsoleLogger logger, return pwsh != null ? new PowerShellManager(logger, pwsh) : new PowerShellManager(logger, id: 2); } - internal static AzFunctionInfo NewAzFunctionInfo(string scriptFile, string entryPoint) + private static (AzFunctionInfo, PowerShellManager) PrepareFunction(string scriptFile, string entryPoint) { - RpcFunctionMetadata.ScriptFile = scriptFile; - RpcFunctionMetadata.EntryPoint = entryPoint; - RpcFunctionMetadata.Directory = Path.GetDirectoryName(scriptFile); - return new AzFunctionInfo(RpcFunctionMetadata); + s_functionLoadRequest.Metadata.ScriptFile = scriptFile; + s_functionLoadRequest.Metadata.EntryPoint = entryPoint; + s_functionLoadRequest.Metadata.Directory = Path.GetDirectoryName(scriptFile); + + FunctionLoader.LoadFunction(s_functionLoadRequest); + var funcInfo = FunctionLoader.GetFunctionInfo(s_functionLoadRequest.FunctionId); + var psManager = NewTestPowerShellManager(s_testLogger); + + return (funcInfo, psManager); } - // Helper method to wait for debugger to attach and set a breakpoint. - internal static void Break() + public void Dispose() { - while (!System.Diagnostics.Debugger.IsAttached) + FunctionLoader.ClearLoadedFunctions(); + s_testLogger.FullLog.Clear(); + } + + [Fact] + public void InvokeBasicFunctionWorks() + { + string path = Path.Join(s_funcDirectory, "testBasicFunction.ps1"); + var (functionInfo, testManager) = PrepareFunction(path, string.Empty); + + try { - System.Threading.Thread.Sleep(200); + FunctionMetadata.RegisterFunctionMetadata(testManager.InstanceId, functionInfo); + Hashtable result = testManager.InvokeFunction(functionInfo, null, s_testInputData); + + // The outputBinding hashtable for the runspace should be cleared after 'InvokeFunction' + Hashtable outputBindings = FunctionMetadata.GetOutputBindingHashtable(testManager.InstanceId); + Assert.Empty(outputBindings); + + // A PowerShell function should be created fro the Az function. + string expectedResult = $"{TestStringData},{functionInfo.DeployedPSFuncName}"; + Assert.Equal(expectedResult, result[TestOutputBindingName]); + } + finally + { + FunctionMetadata.UnregisterFunctionMetadata(testManager.InstanceId); } - System.Diagnostics.Debugger.Break(); } - } - public class PowerShellManagerTests - { - private const string TestStringData = "Foo"; + [Fact] + public void InvokeFunctionWithSpecialVariableWorks() + { + string path = Path.Join(s_funcDirectory, "testBasicFunctionSpecialVariables.ps1"); + var (functionInfo, testManager) = PrepareFunction(path, string.Empty); - private readonly ConsoleLogger _testLogger; - private readonly PowerShellManager _testManager; - private readonly List _testInputData; + try + { + FunctionMetadata.RegisterFunctionMetadata(testManager.InstanceId, functionInfo); + Hashtable result = testManager.InvokeFunction(functionInfo, null, s_testInputData); - public PowerShellManagerTests() - { - _testLogger = new ConsoleLogger(); - _testManager = TestUtils.NewTestPowerShellManager(_testLogger); + // The outputBinding hashtable for the runspace should be cleared after 'InvokeFunction' + Hashtable outputBindings = FunctionMetadata.GetOutputBindingHashtable(testManager.InstanceId); + Assert.Empty(outputBindings); - _testInputData = new List + // A PowerShell function should be created fro the Az function. + string expectedResult = $"{s_funcDirectory},{path},{functionInfo.DeployedPSFuncName}"; + Assert.Equal(expectedResult, result[TestOutputBindingName]); + } + finally { - new ParameterBinding - { - Name = TestUtils.TestInputBindingName, - Data = new TypedData - { - String = TestStringData - } - } - }; + FunctionMetadata.UnregisterFunctionMetadata(testManager.InstanceId); + } } [Fact] - public void InvokeBasicFunctionWorks() + public void InvokeBasicFunctionWithRequiresWorks() { - string path = Path.Join(TestUtils.FunctionDirectory, "testBasicFunction.ps1"); - var functionInfo = TestUtils.NewAzFunctionInfo(path, string.Empty); + string path = Path.Join(s_funcDirectory, "testBasicFunctionWithRequires.ps1"); + var (functionInfo, testManager) = PrepareFunction(path, string.Empty); try { - FunctionMetadata.RegisterFunctionMetadata(_testManager.InstanceId, functionInfo); - Hashtable result = _testManager.InvokeFunction(functionInfo, null, _testInputData); + FunctionMetadata.RegisterFunctionMetadata(testManager.InstanceId, functionInfo); + Hashtable result = testManager.InvokeFunction(functionInfo, null, s_testInputData); - Assert.Equal(TestStringData, result[TestUtils.TestOutputBindingName]); + // The outputBinding hashtable for the runspace should be cleared after 'InvokeFunction' + Hashtable outputBindings = FunctionMetadata.GetOutputBindingHashtable(testManager.InstanceId); + Assert.Empty(outputBindings); + + // When function script has #requires, not PowerShell function will be created for the Az function, + // and the invocation uses the file path directly. + string expectedResult = $"{TestStringData},ThreadJob,testBasicFunctionWithRequires.ps1"; + Assert.Equal(expectedResult, result[TestOutputBindingName]); } finally { - FunctionMetadata.UnregisterFunctionMetadata(_testManager.InstanceId); + FunctionMetadata.UnregisterFunctionMetadata(testManager.InstanceId); } } [Fact] public void InvokeBasicFunctionWithTriggerMetadataWorks() { - string path = Path.Join(TestUtils.FunctionDirectory, "testBasicFunctionWithTriggerMetadata.ps1"); - var functionInfo = TestUtils.NewAzFunctionInfo(path, string.Empty); + string path = Path.Join(s_funcDirectory, "testBasicFunctionWithTriggerMetadata.ps1"); + var (functionInfo, testManager) = PrepareFunction(path, string.Empty); + Hashtable triggerMetadata = new Hashtable(StringComparer.OrdinalIgnoreCase) { - { TestUtils.TestInputBindingName, TestStringData } + { TestInputBindingName, TestStringData } }; try { - FunctionMetadata.RegisterFunctionMetadata(_testManager.InstanceId, functionInfo); - Hashtable result = _testManager.InvokeFunction(functionInfo, triggerMetadata, _testInputData); + FunctionMetadata.RegisterFunctionMetadata(testManager.InstanceId, functionInfo); + Hashtable result = testManager.InvokeFunction(functionInfo, triggerMetadata, s_testInputData); - Assert.Equal(TestStringData, result[TestUtils.TestOutputBindingName]); + // The outputBinding hashtable for the runspace should be cleared after 'InvokeFunction' + Hashtable outputBindings = FunctionMetadata.GetOutputBindingHashtable(testManager.InstanceId); + Assert.Empty(outputBindings); + + // A PowerShell function should be created fro the Az function. + string expectedResult = $"{TestStringData},{functionInfo.DeployedPSFuncName}"; + Assert.Equal(expectedResult, result[TestOutputBindingName]); } finally { - FunctionMetadata.UnregisterFunctionMetadata(_testManager.InstanceId); + FunctionMetadata.UnregisterFunctionMetadata(testManager.InstanceId); } } [Fact] public void InvokeFunctionWithEntryPointWorks() { - string path = Path.Join(TestUtils.FunctionDirectory, "testFunctionWithEntryPoint.psm1"); - var functionInfo = TestUtils.NewAzFunctionInfo(path, "Run"); + string path = Path.Join(s_funcDirectory, "testFunctionWithEntryPoint.psm1"); + var (functionInfo, testManager) = PrepareFunction(path, "Run"); try { - FunctionMetadata.RegisterFunctionMetadata(_testManager.InstanceId, functionInfo); - Hashtable result = _testManager.InvokeFunction(functionInfo, null, _testInputData); + FunctionMetadata.RegisterFunctionMetadata(testManager.InstanceId, functionInfo); + Hashtable result = testManager.InvokeFunction(functionInfo, null, s_testInputData); + + // The outputBinding hashtable for the runspace should be cleared after 'InvokeFunction' + Hashtable outputBindings = FunctionMetadata.GetOutputBindingHashtable(testManager.InstanceId); + Assert.Empty(outputBindings); - Assert.Equal(TestStringData, result[TestUtils.TestOutputBindingName]); + string expectedResult = $"{TestStringData},Run"; + Assert.Equal(expectedResult, result[TestOutputBindingName]); } finally { - FunctionMetadata.UnregisterFunctionMetadata(_testManager.InstanceId); + FunctionMetadata.UnregisterFunctionMetadata(testManager.InstanceId); } } [Fact] public void FunctionShouldCleanupVariableTable() { - string path = Path.Join(TestUtils.FunctionDirectory, "testFunctionCleanup.ps1"); - var functionInfo = TestUtils.NewAzFunctionInfo(path, string.Empty); + string path = Path.Join(s_funcDirectory, "testFunctionCleanup.ps1"); + var (functionInfo, testManager) = PrepareFunction(path, string.Empty); try { - FunctionMetadata.RegisterFunctionMetadata(_testManager.InstanceId, functionInfo); + FunctionMetadata.RegisterFunctionMetadata(testManager.InstanceId, functionInfo); - Hashtable result1 = _testManager.InvokeFunction(functionInfo, null, _testInputData); - Assert.Equal("is not set", result1[TestUtils.TestOutputBindingName]); + Hashtable result1 = testManager.InvokeFunction(functionInfo, null, s_testInputData); + Assert.Equal("is not set", result1[TestOutputBindingName]); // the value should not change if the variable table is properly cleaned up. - Hashtable result2 = _testManager.InvokeFunction(functionInfo, null, _testInputData); - Assert.Equal("is not set", result2[TestUtils.TestOutputBindingName]); + Hashtable result2 = testManager.InvokeFunction(functionInfo, null, s_testInputData); + Assert.Equal("is not set", result2[TestOutputBindingName]); } finally { - FunctionMetadata.UnregisterFunctionMetadata(_testManager.InstanceId); + FunctionMetadata.UnregisterFunctionMetadata(testManager.InstanceId); } } @@ -191,72 +261,81 @@ public void ModulePathShouldBeSetCorrectly() [Fact] public void RegisterAndUnregisterFunctionMetadataShouldWork() { - string path = Path.Join(TestUtils.FunctionDirectory, "testBasicFunction.ps1"); - var functionInfo = TestUtils.NewAzFunctionInfo(path, string.Empty); - - Assert.Empty(FunctionMetadata.OutputBindingCache); - FunctionMetadata.RegisterFunctionMetadata(_testManager.InstanceId, functionInfo); - Assert.Single(FunctionMetadata.OutputBindingCache); - FunctionMetadata.UnregisterFunctionMetadata(_testManager.InstanceId); - Assert.Empty(FunctionMetadata.OutputBindingCache); + string path = Path.Join(s_funcDirectory, "testBasicFunction.ps1"); + var (functionInfo, testManager) = PrepareFunction(path, string.Empty); + + FunctionMetadata.RegisterFunctionMetadata(testManager.InstanceId, functionInfo); + var outBindingMap = FunctionMetadata.GetOutputBindingInfo(testManager.InstanceId); + Assert.Single(outBindingMap); + Assert.Equal(TestOutputBindingName, outBindingMap.First().Key); + + FunctionMetadata.UnregisterFunctionMetadata(testManager.InstanceId); + outBindingMap = FunctionMetadata.GetOutputBindingInfo(testManager.InstanceId); + Assert.Null(outBindingMap); } [Fact] public void ProfileShouldWork() { - //initialize fresh log - _testLogger.FullLog.Clear(); - var profilePath = Path.Join(TestUtils.FunctionDirectory, "ProfileBasic", "profile.ps1"); - _testManager.InvokeProfile(profilePath); + var profilePath = Path.Join(s_funcDirectory, "ProfileBasic", "profile.ps1"); + var testManager = NewTestPowerShellManager(s_testLogger); - Assert.Single(_testLogger.FullLog); - Assert.Equal("Information: INFORMATION: Hello PROFILE", _testLogger.FullLog[0]); + // Clear log stream + s_testLogger.FullLog.Clear(); + testManager.InvokeProfile(profilePath); + + Assert.Single(s_testLogger.FullLog); + Assert.Equal("Information: INFORMATION: Hello PROFILE", s_testLogger.FullLog[0]); } [Fact] public void ProfileWithTerminatingError() { - //initialize fresh log - _testLogger.FullLog.Clear(); - var profilePath = Path.Join(TestUtils.FunctionDirectory, "ProfileWithTerminatingError", "profile.ps1"); + var profilePath = Path.Join(s_funcDirectory, "ProfileWithTerminatingError", "profile.ps1"); + var testManager = NewTestPowerShellManager(s_testLogger); + + // Clear log stream + s_testLogger.FullLog.Clear(); - Assert.Throws(() => _testManager.InvokeProfile(profilePath)); - Assert.Single(_testLogger.FullLog); - Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", _testLogger.FullLog[0]); + Assert.Throws(() => testManager.InvokeProfile(profilePath)); + Assert.Single(s_testLogger.FullLog); + Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", s_testLogger.FullLog[0]); } [Fact] public void ProfileWithNonTerminatingError() { - //initialize fresh log - _testLogger.FullLog.Clear(); - var profilePath = Path.Join(TestUtils.FunctionDirectory, "ProfileWithNonTerminatingError", "Profile.ps1"); - _testManager.InvokeProfile(profilePath); - - Assert.Equal(2, _testLogger.FullLog.Count); - Assert.Equal("Error: ERROR: help me!", _testLogger.FullLog[0]); - Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", _testLogger.FullLog[1]); + var profilePath = Path.Join(s_funcDirectory, "ProfileWithNonTerminatingError", "Profile.ps1"); + var testManager = NewTestPowerShellManager(s_testLogger); + + // Clear log stream + s_testLogger.FullLog.Clear(); + testManager.InvokeProfile(profilePath); + + Assert.Equal(2, s_testLogger.FullLog.Count); + Assert.Equal("Error: ERROR: help me!", s_testLogger.FullLog[0]); + Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", s_testLogger.FullLog[1]); } [Fact] public void PSManagerCtorRunsProfileByDefault() { - //initialize fresh log - _testLogger.FullLog.Clear(); - TestUtils.NewTestPowerShellManager(_testLogger); + // Clear log stream + s_testLogger.FullLog.Clear(); + NewTestPowerShellManager(s_testLogger); - Assert.Single(_testLogger.FullLog); - Assert.Equal($"Trace: No 'profile.ps1' is found at the FunctionApp root folder: {FunctionLoader.FunctionAppRootPath}.", _testLogger.FullLog[0]); + Assert.Single(s_testLogger.FullLog); + Assert.Equal($"Trace: No 'profile.ps1' is found at the FunctionApp root folder: {FunctionLoader.FunctionAppRootPath}.", s_testLogger.FullLog[0]); } [Fact] public void PSManagerCtorDoesNotRunProfileIfDelayInit() { - //initialize fresh log - _testLogger.FullLog.Clear(); - TestUtils.NewTestPowerShellManager(_testLogger, Utils.NewPwshInstance()); + // Clear log stream + s_testLogger.FullLog.Clear(); + NewTestPowerShellManager(s_testLogger, Utils.NewPwshInstance()); - Assert.Empty(_testLogger.FullLog); + Assert.Empty(s_testLogger.FullLog); } } } diff --git a/test/Unit/PowerShell/TestScripts/testBasicFunction.ps1 b/test/Unit/PowerShell/TestScripts/testBasicFunction.ps1 index bb3117a0..0c3efd92 100644 --- a/test/Unit/PowerShell/TestScripts/testBasicFunction.ps1 +++ b/test/Unit/PowerShell/TestScripts/testBasicFunction.ps1 @@ -7,5 +7,7 @@ param ($Req) # Used for logging tests Write-Verbose "a log" +$cmdName = $MyInvocation.MyCommand.Name -Push-OutputBinding -Name res -Value $Req +$result = "{0},{1}" -f $Req, $cmdName +Push-OutputBinding -Name res -Value $result diff --git a/test/Unit/PowerShell/TestScripts/testBasicFunctionSpecialVariables.ps1 b/test/Unit/PowerShell/TestScripts/testBasicFunctionSpecialVariables.ps1 new file mode 100644 index 00000000..991fd952 --- /dev/null +++ b/test/Unit/PowerShell/TestScripts/testBasicFunctionSpecialVariables.ps1 @@ -0,0 +1,13 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +param ($Req) + +# Used for logging tests +Write-Verbose "a log" +$cmdName = $MyInvocation.MyCommand.Name + +$result = "{0},{1},{2}" -f $PSScriptRoot, $PSCommandPath, $cmdName +Push-OutputBinding -Name res -Value $result diff --git a/test/Unit/PowerShell/TestScripts/testBasicFunctionWithRequires.ps1 b/test/Unit/PowerShell/TestScripts/testBasicFunctionWithRequires.ps1 new file mode 100644 index 00000000..5fd81163 --- /dev/null +++ b/test/Unit/PowerShell/TestScripts/testBasicFunctionWithRequires.ps1 @@ -0,0 +1,14 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +#Requires -Modules ThreadJob + +param ($req) + +$module = Get-Module ThreadJob +$cmdName = $MyInvocation.MyCommand.Name + +$result = "{0},{1},{2}" -f $req, $module.Name, $cmdName +Push-OutputBinding -Name res -Value $result diff --git a/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 b/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 index 914d0e9e..401399c4 100644 --- a/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 +++ b/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 @@ -7,5 +7,7 @@ param ($Req, $TriggerMetadata) # Used for logging tests Write-Verbose "a log" +$cmdName = $MyInvocation.MyCommand.Name -Push-OutputBinding -Name res -Value $TriggerMetadata.Req +$result = "{0},{1}" -f $TriggerMetadata.Req, $cmdName +Push-OutputBinding -Name res -Value $result diff --git a/test/Unit/PowerShell/TestScripts/testFunctionCleanup.ps1 b/test/Unit/PowerShell/TestScripts/testFunctionCleanup.ps1 index fc011be2..755fdd4a 100644 --- a/test/Unit/PowerShell/TestScripts/testFunctionCleanup.ps1 +++ b/test/Unit/PowerShell/TestScripts/testFunctionCleanup.ps1 @@ -1,4 +1,4 @@ -# +# # Copyright (c) Microsoft. All rights reserved. # Licensed under the MIT license. See LICENSE file in the project root for full license information. # diff --git a/test/Unit/PowerShell/TestScripts/testFunctionWithEntryPoint.psm1 b/test/Unit/PowerShell/TestScripts/testFunctionWithEntryPoint.psm1 index bcf35306..ab13f2c9 100644 --- a/test/Unit/PowerShell/TestScripts/testFunctionWithEntryPoint.psm1 +++ b/test/Unit/PowerShell/TestScripts/testFunctionWithEntryPoint.psm1 @@ -4,5 +4,8 @@ # function Run($Req) { - Push-OutputBinding -Name res -Value $Req + $cmdName = $MyInvocation.MyCommand.Name + + $result = "{0},{1}" -f $Req, $cmdName + Push-OutputBinding -Name res -Value $result } diff --git a/tools/helper.psm1 b/tools/helper.psm1 index b15f972d..b2921bc9 100644 --- a/tools/helper.psm1 +++ b/tools/helper.psm1 @@ -186,22 +186,6 @@ function Get-WebFile { Invoke-RestMethod $Url -OutFile $OutFile } -function Invoke-Tests -{ - param( - [string] $Path, - [string] $OutputFile - ) - - if($env:APPVEYOR) { - $res = Invoke-Pester $Path -OutputFormat NUnitXml -OutputFile $OutputFile -PassThru - (New-Object 'System.Net.WebClient').UploadFile("https://ci.appveyor.com/api/testresults/nunit/$($env:APPVEYOR_JOB_ID)", (Resolve-Path $OutputFile)) - if ($res.FailedCount -gt 0) { throw "$($res.FailedCount) tests failed." } - } else { - Invoke-Pester $Path - } -} - function Write-Log { param(