From e114f9030ee45d1715c4ce34d9276b354c9456c0 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Fri, 26 Apr 2019 20:08:30 -0700 Subject: [PATCH 1/9] Refactor and clean up based on the dependency management changes (#194) Fix no runspace available Fix regression: create the first PowerShellManager in FunctionLoad, but delay the init Update comments Integrate all changes together Clear the outputbinding hashtable for the runspace after get the results Update tests and build scripts Create constant functions so it's more secure Alter the to-be-deployed PS function name to avoid conflicts with built-in functions Use PowerShell API instead of 'InvokeScript' as the former is way faster Bring back old code to clean global variables --- build.ps1 | 34 -- docs/cmdlets/Get-OutputBinding.md | 8 +- docs/cmdlets/Push-OutputBinding.md | 36 +- docs/cmdlets/Trace-PipelineObject.md | 6 +- src/DependencyManagement/DependencyManager.cs | 4 +- src/FunctionInfo.cs | 25 +- src/FunctionLoader.cs | 28 +- ...ft.Azure.Functions.PowerShellWorker.csproj | 1 - ...soft.Azure.Functions.PowerShellWorker.psd1 | 10 +- ...soft.Azure.Functions.PowerShellWorker.psm1 | 391 ------------------ .../PowerShellWorker.Resource.psd1 | 13 - src/PowerShell/PowerShellExtensions.cs | 28 ++ src/PowerShell/PowerShellManager.cs | 85 ++-- .../Commands/GetOutputBindingCommand.cs | 92 +++++ .../Commands/PushOutputBindingCommand.cs | 280 +++++++++++++ .../Commands/TracePipelineObjectCommand.cs | 70 ++++ src/Public/FunctionMetadata.cs | 14 +- src/RequestProcessor.cs | 7 +- src/Utility/Utils.cs | 5 + src/resources/PowerShellWorkerStrings.resx | 12 + test/Unit/Function/FunctionLoaderTests.cs | 77 ++-- .../Function/TestScripts/FuncWithRequires.ps1 | 10 + test/Unit/Modules/HelperModuleTests.cs | 309 ++++++++++++++ ...Azure.Functions.PowerShellWorker.Tests.ps1 | 314 -------------- .../Unit/PowerShell/PowerShellManagerTests.cs | 273 +++++++----- .../TestScripts/testBasicFunction.ps1 | 4 +- .../testBasicFunctionWithRequires.ps1 | 14 + .../testBasicFunctionWithTriggerMetadata.ps1 | 4 +- .../TestScripts/testFunctionCleanup.ps1 | 2 +- .../testFunctionWithEntryPoint.psm1 | 5 +- tools/helper.psm1 | 16 - 31 files changed, 1207 insertions(+), 970 deletions(-) delete mode 100644 src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psm1 delete mode 100644 src/Modules/Microsoft.Azure.Functions.PowerShellWorker/PowerShellWorker.Resource.psd1 create mode 100644 src/Public/Commands/GetOutputBindingCommand.cs create mode 100644 src/Public/Commands/PushOutputBindingCommand.cs create mode 100644 src/Public/Commands/TracePipelineObjectCommand.cs create mode 100644 test/Unit/Function/TestScripts/FuncWithRequires.ps1 create mode 100644 test/Unit/Modules/HelperModuleTests.cs delete mode 100644 test/Unit/Modules/Microsoft.Azure.Functions.PowerShellWorker.Tests.ps1 create mode 100644 test/Unit/PowerShell/TestScripts/testBasicFunctionWithRequires.ps1 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..9f85c896 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: Named 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..675d68a8 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 @@ -13,7 +13,7 @@ Writes the formatted output of the pipeline object to the information stream bef ## SYNTAX ``` -Trace-PipelineObject [-InputObject] [] +Trace-PipelineObject -InputObject [] ``` ## DESCRIPTION @@ -40,7 +40,7 @@ Parameter Sets: (All) Aliases: Required: True -Position: 1 +Position: Named 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..3ec9c746 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,30 @@ 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(); + // TODO: 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. + // + // But 'ResetRunspaceState' does more than needed -- reset the current path, reset the debugger, + // create new event manager and transaction manager. We should only remove the new global variables, + // and does nothing else. + _pwsh.Runspace.ResetRunspaceState(); } } } diff --git a/src/Public/Commands/GetOutputBindingCommand.cs b/src/Public/Commands/GetOutputBindingCommand.cs new file mode 100644 index 00000000..f7daa281 --- /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(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..38ac1dc1 --- /dev/null +++ b/src/Public/Commands/PushOutputBindingCommand.cs @@ -0,0 +1,280 @@ +// +// 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 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[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..fda86dbf --- /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, 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..f1af38de 100644 --- a/src/Utility/Utils.cs +++ b/src/Utility/Utils.cs @@ -20,6 +20,11 @@ internal class Utils 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 static InitialSessionState s_iss; 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..4bf88607 --- /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 = PowerShell.Create(Utils.SingletonISS.Value); + 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..e6060820 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(); + } + } - internal static readonly string FunctionDirectory; - internal static readonly RpcFunctionMetadata RpcFunctionMetadata; - internal static readonly FunctionLoadRequest FunctionLoadRequest; + public class PowerShellManagerTests : IDisposable + { + private const string TestInputBindingName = "req"; + private const string TestOutputBindingName = "res"; + private const string TestStringData = "Foo"; + + 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,150 @@ 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) - { - System.Threading.Thread.Sleep(200); - } - System.Diagnostics.Debugger.Break(); + FunctionLoader.ClearLoadedFunctions(); + s_testLogger.FullLog.Clear(); } - } - public class PowerShellManagerTests - { - private const string TestStringData = "Foo"; + [Fact] + public void InvokeBasicFunctionWorks() + { + string path = Path.Join(s_funcDirectory, "testBasicFunction.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 = $"{TestStringData},{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,51 +236,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); - 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]); + // Clear log stream + s_testLogger.FullLog.Clear(); + + 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); + 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() + { + // Clear log stream + s_testLogger.FullLog.Clear(); + NewTestPowerShellManager(s_testLogger); + + 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() + { + // Clear log stream + s_testLogger.FullLog.Clear(); + NewTestPowerShellManager(s_testLogger, delayInit: true); - 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]); + Assert.Empty(s_testLogger.FullLog); } [Fact] 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/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( From 71a7cad36963c94a6f021b636561c054ff226393 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Mon, 6 May 2019 21:45:01 -0700 Subject: [PATCH 2/9] Minor fixes --- src/Utility/Utils.cs | 1 - test/Unit/Modules/HelperModuleTests.cs | 2 +- .../Unit/PowerShell/PowerShellManagerTests.cs | 23 +------------------ 3 files changed, 2 insertions(+), 24 deletions(-) diff --git a/src/Utility/Utils.cs b/src/Utility/Utils.cs index f1af38de..ae9c1970 100644 --- a/src/Utility/Utils.cs +++ b/src/Utility/Utils.cs @@ -18,7 +18,6 @@ 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)); diff --git a/test/Unit/Modules/HelperModuleTests.cs b/test/Unit/Modules/HelperModuleTests.cs index 4bf88607..b84beeed 100644 --- a/test/Unit/Modules/HelperModuleTests.cs +++ b/test/Unit/Modules/HelperModuleTests.cs @@ -50,7 +50,7 @@ static HelperModuleTests() var funcLoadReq = new FunctionLoadRequest { FunctionId = "FunctionId", Metadata = rpcFuncMetadata }; FunctionLoader.SetupWellKnownPaths(funcLoadReq); - s_pwsh = PowerShell.Create(Utils.SingletonISS.Value); + s_pwsh = Utils.NewPwshInstance(); s_funcInfo = new AzFunctionInfo(rpcFuncMetadata); } diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index e6060820..47e84f8b 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -308,30 +308,9 @@ public void PSManagerCtorDoesNotRunProfileIfDelayInit() { // Clear log stream s_testLogger.FullLog.Clear(); - NewTestPowerShellManager(s_testLogger, delayInit: true); + NewTestPowerShellManager(s_testLogger, Utils.NewPwshInstance()); Assert.Empty(s_testLogger.FullLog); } - - [Fact] - public void PSManagerCtorRunsProfileByDefault() - { - //initialize fresh log - _testLogger.FullLog.Clear(); - TestUtils.NewTestPowerShellManager(_testLogger); - - Assert.Single(_testLogger.FullLog); - Assert.Equal($"Trace: No 'profile.ps1' is found at the FunctionApp root folder: {FunctionLoader.FunctionAppRootPath}.", _testLogger.FullLog[0]); - } - - [Fact] - public void PSManagerCtorDoesNotRunProfileIfDelayInit() - { - //initialize fresh log - _testLogger.FullLog.Clear(); - TestUtils.NewTestPowerShellManager(_testLogger, Utils.NewPwshInstance()); - - Assert.Empty(_testLogger.FullLog); - } } } From ca561fe8bfce5cfa33c249ebe225b93755935c4f Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Tue, 7 May 2019 19:47:59 -0700 Subject: [PATCH 3/9] Update global variable cleanup --- src/PowerShell/PowerShellManager.cs | 12 +++--- src/Utility/Utils.cs | 66 ++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 3ec9c746..5ed6ea90 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -269,14 +269,16 @@ private void ResetRunspace() .InvokeAndClearCommands(jobs); } - // TODO: We need to clean up new global variables generated from the invocation. + // 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. // - // But 'ResetRunspaceState' does more than needed -- reset the current path, reset the debugger, - // create new event manager and transaction manager. We should only remove the new global variables, - // and does nothing else. - _pwsh.Runspace.ResetRunspaceState(); + // 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/Utility/Utils.cs b/src/Utility/Utils.cs index ae9c1970..9177c8d1 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; @@ -25,7 +26,9 @@ internal class Utils 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. @@ -56,7 +59,68 @@ 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 + var globalVars = (ICollection)pwsh.Runspace.SessionStateProxy.InvokeProvider.Item.Get(VariableDriveRoot)[0]; + s_globalVariables = new HashSet(globalVars.Count, StringComparer.OrdinalIgnoreCase); + 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; + var globalVars = (ICollection)pwsh.Runspace.SessionStateProxy.InvokeProvider.Item.Get(VariableDriveRoot)[0]; + + foreach (PSVariable var in globalVars) + { + if (s_globalVariables.Contains(var.Name)) + { + // The variable is one of the built-in global variables. + continue; + } + + if (var.Options.HasFlag(ScopedItemOptions.Constant)) + { + // We cannot remove a constant variable, so leave it as is. + continue; + } + + if (var.Module != null) + { + // The variable is exposed by a module. We don't remove modules, so leave it as is. + 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); + } } /// From 3ba37830641c26f802b0ec05eadeee7eca85c3b3 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Tue, 7 May 2019 20:28:30 -0700 Subject: [PATCH 4/9] Add some fixes --- src/Utility/Utils.cs | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/Utility/Utils.cs b/src/Utility/Utils.cs index 9177c8d1..863756c1 100644 --- a/src/Utility/Utils.cs +++ b/src/Utility/Utils.cs @@ -63,7 +63,7 @@ internal static PowerShell NewPwshInstance() if (s_globalVariables == null) { // Get the names of the built-in global variables - var globalVars = (ICollection)pwsh.Runspace.SessionStateProxy.InvokeProvider.Item.Get(VariableDriveRoot)[0]; + ICollection globalVars = GetGlobalVariables(pwsh); s_globalVariables = new HashSet(globalVars.Count, StringComparer.OrdinalIgnoreCase); foreach (PSVariable var in globalVars) { @@ -80,27 +80,22 @@ internal static PowerShell NewPwshInstance() internal static void CleanupGlobalVariables(PowerShell pwsh) { List varsToRemove = null; - var globalVars = (ICollection)pwsh.Runspace.SessionStateProxy.InvokeProvider.Item.Get(VariableDriveRoot)[0]; + ICollection globalVars = GetGlobalVariables(pwsh); foreach (PSVariable var in globalVars) { - if (s_globalVariables.Contains(var.Name)) - { - // The variable is one of the built-in global variables. - continue; - } + // The variable is one of the built-in global variables. + if (s_globalVariables.Contains(var.Name)) { continue; } - if (var.Options.HasFlag(ScopedItemOptions.Constant)) - { - // We cannot remove a constant variable, so leave it as is. - continue; - } + // We cannot remove a constant variable, so leave it as is. + if (var.Options.HasFlag(ScopedItemOptions.Constant)) { continue; } - if (var.Module != null) - { - // The variable is exposed by a module. We don't remove modules, so leave it as is. - 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) { @@ -123,6 +118,12 @@ internal static void CleanupGlobalVariables(PowerShell pwsh) } } + private static ICollection GetGlobalVariables(PowerShell pwsh) + { + PSObject item = pwsh.Runspace.SessionStateProxy.InvokeProvider.Item.Get(VariableDriveRoot)[0]; + return (ICollection)item.BaseObject; + } + /// /// Helper method to do additional transformation on the input value based on the type constraints specified in the script. /// From 2d4cc7ab78651240dcd125bf20f6d9edcc5ac021 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Wed, 8 May 2019 10:13:53 -0700 Subject: [PATCH 5/9] minor perf improvement --- src/Utility/Utils.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Utility/Utils.cs b/src/Utility/Utils.cs index 863756c1..993acfa0 100644 --- a/src/Utility/Utils.cs +++ b/src/Utility/Utils.cs @@ -64,7 +64,13 @@ internal static PowerShell NewPwshInstance() { // Get the names of the built-in global variables ICollection globalVars = GetGlobalVariables(pwsh); - s_globalVariables = new HashSet(globalVars.Count, StringComparer.OrdinalIgnoreCase); + s_globalVariables = new HashSet(globalVars.Count, 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); From a80acfdc61d52f04ce949f7206b2a588ae2752f6 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Sat, 11 May 2019 11:57:20 -0700 Subject: [PATCH 6/9] Address review comments --- docs/cmdlets/Get-OutputBinding.md | 6 ++--- docs/cmdlets/Trace-PipelineObject.md | 4 +-- .../Commands/GetOutputBindingCommand.cs | 2 +- .../Commands/TracePipelineObjectCommand.cs | 2 +- .../Unit/PowerShell/PowerShellManagerTests.cs | 25 +++++++++++++++++++ .../testBasicFunctionSpecialVariables.ps1 | 13 ++++++++++ 6 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 test/Unit/PowerShell/TestScripts/testBasicFunctionSpecialVariables.ps1 diff --git a/docs/cmdlets/Get-OutputBinding.md b/docs/cmdlets/Get-OutputBinding.md index 9f85c896..1f36775f 100644 --- a/docs/cmdlets/Get-OutputBinding.md +++ b/docs/cmdlets/Get-OutputBinding.md @@ -13,7 +13,7 @@ Gets the hashtable of the output bindings set so far. ## SYNTAX ``` -Get-OutputBinding [-Name ] [-Purge] [] +Get-OutputBinding [[-Name] ] [-Purge] [] ``` ## DESCRIPTION @@ -54,10 +54,10 @@ Parameter Sets: (All) Aliases: Required: False -Position: Named +Position: 0 Default value: * Accept pipeline input: True (ByPropertyName, ByValue) -Accept wildcard characters: True +Accept wildcard characters: False ``` ### -Purge diff --git a/docs/cmdlets/Trace-PipelineObject.md b/docs/cmdlets/Trace-PipelineObject.md index 675d68a8..1c503305 100644 --- a/docs/cmdlets/Trace-PipelineObject.md +++ b/docs/cmdlets/Trace-PipelineObject.md @@ -13,7 +13,7 @@ Writes the formatted output of the pipeline object to the information stream bef ## SYNTAX ``` -Trace-PipelineObject -InputObject [] +Trace-PipelineObject [-InputObject] [] ``` ## DESCRIPTION @@ -40,7 +40,7 @@ Parameter Sets: (All) Aliases: Required: True -Position: Named +Position: 0 Default value: None Accept pipeline input: True (ByValue) Accept wildcard characters: False diff --git a/src/Public/Commands/GetOutputBindingCommand.cs b/src/Public/Commands/GetOutputBindingCommand.cs index f7daa281..6bef5c35 100644 --- a/src/Public/Commands/GetOutputBindingCommand.cs +++ b/src/Public/Commands/GetOutputBindingCommand.cs @@ -31,7 +31,7 @@ public sealed class GetOutputBindingCommand : PSCmdlet /// /// The name of the output binding you want to get. Supports wildcards. /// - [Parameter(ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Parameter(Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] [SupportsWildcards] [ValidateNotNullOrEmpty] public string Name { get; set; } = "*"; diff --git a/src/Public/Commands/TracePipelineObjectCommand.cs b/src/Public/Commands/TracePipelineObjectCommand.cs index fda86dbf..432bbace 100644 --- a/src/Public/Commands/TracePipelineObjectCommand.cs +++ b/src/Public/Commands/TracePipelineObjectCommand.cs @@ -28,7 +28,7 @@ public sealed class TracePipelineObjectCommand : PSCmdlet /// /// The object from pipeline. /// - [Parameter(Mandatory = true, ValueFromPipeline = true)] + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true)] public object InputObject { get; set; } private static PowerShell s_pwsh; diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index 47e84f8b..2a04d71b 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -121,6 +121,31 @@ public void InvokeBasicFunctionWorks() } } + [Fact] + public void InvokeFunctionWithSpecialVariableWorks() + { + string path = Path.Join(s_funcDirectory, "testBasicFunctionSpecialVariables.ps1"); + var (functionInfo, testManager) = PrepareFunction(path, string.Empty); + + try + { + 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 = $"{s_funcDirectory},{path},{functionInfo.DeployedPSFuncName}"; + Assert.Equal(expectedResult, result[TestOutputBindingName]); + } + finally + { + FunctionMetadata.UnregisterFunctionMetadata(testManager.InstanceId); + } + } + [Fact] public void InvokeBasicFunctionWithRequiresWorks() { 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 From 4d44920a1d942abace0a20e2af374d79e01442ab Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Sat, 11 May 2019 12:04:32 -0700 Subject: [PATCH 7/9] Correct generated markdown help --- docs/cmdlets/Get-OutputBinding.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cmdlets/Get-OutputBinding.md b/docs/cmdlets/Get-OutputBinding.md index 1f36775f..d7b0c9e5 100644 --- a/docs/cmdlets/Get-OutputBinding.md +++ b/docs/cmdlets/Get-OutputBinding.md @@ -57,7 +57,7 @@ Required: False Position: 0 Default value: * Accept pipeline input: True (ByPropertyName, ByValue) -Accept wildcard characters: False +Accept wildcard characters: True ``` ### -Purge From 02cf57ddd2ee5cf8964341fabe6146e647ffc151 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Sat, 11 May 2019 13:14:29 -0700 Subject: [PATCH 8/9] Address the typo and a small issue --- src/Public/Commands/PushOutputBindingCommand.cs | 3 +-- src/Utility/Utils.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Public/Commands/PushOutputBindingCommand.cs b/src/Public/Commands/PushOutputBindingCommand.cs index 38ac1dc1..d62733ab 100644 --- a/src/Public/Commands/PushOutputBindingCommand.cs +++ b/src/Public/Commands/PushOutputBindingCommand.cs @@ -142,7 +142,6 @@ protected override void ProcessRecord() break; case DataCollectingBehavior.Collection: - object oldValue = Clobber.IsPresent ? null : _outputBindings[Name]; object newValue = MergeCollection(oldData: oldValue, newData: Value); _outputBindings[Name] = newValue; @@ -224,7 +223,7 @@ private DataCollectingBehavior GetDataCollectingBehavior(ReadOnlyBindingInfo bin /// /// Combine the new data with the existing data for a output binding with 'Collection' behavior. - /// Here is what this command do: + /// 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. diff --git a/src/Utility/Utils.cs b/src/Utility/Utils.cs index 993acfa0..18bdfd3a 100644 --- a/src/Utility/Utils.cs +++ b/src/Utility/Utils.cs @@ -64,7 +64,7 @@ internal static PowerShell NewPwshInstance() { // Get the names of the built-in global variables ICollection globalVars = GetGlobalVariables(pwsh); - s_globalVariables = new HashSet(globalVars.Count, StringComparer.OrdinalIgnoreCase) + 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. From c513e36a98d8b59e1009106a407c6ee545a8876c Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Mon, 13 May 2019 17:12:18 -0700 Subject: [PATCH 9/9] Address feedback from Tyler --- src/Public/Commands/PushOutputBindingCommand.cs | 2 +- test/Unit/Modules/HelperModuleTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Public/Commands/PushOutputBindingCommand.cs b/src/Public/Commands/PushOutputBindingCommand.cs index d62733ab..336889c8 100644 --- a/src/Public/Commands/PushOutputBindingCommand.cs +++ b/src/Public/Commands/PushOutputBindingCommand.cs @@ -120,7 +120,7 @@ protected override void ProcessRecord() } } - // Key already exists in _OutputBindings + // Key already exists in _outputBindings switch (_behavior) { case DataCollectingBehavior.Singleton: diff --git a/test/Unit/Modules/HelperModuleTests.cs b/test/Unit/Modules/HelperModuleTests.cs index b84beeed..5318affa 100644 --- a/test/Unit/Modules/HelperModuleTests.cs +++ b/test/Unit/Modules/HelperModuleTests.cs @@ -39,7 +39,7 @@ static HelperModuleTests() EntryPoint = string.Empty, Bindings = { - { "req" , new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "httpTrigger" } }, + { "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" } },