diff --git a/README.md b/README.md index cf8b00c..4d0f8b6 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,13 @@ that specifically configures the `ipConfigurations` property. Whereas in `Microsoft.Network/publicIpAddresses`, `ipConfigurations` is meaningless, but `publicIPAllocationMethod` allows you to configure the IP allocation method. +#### NOTE + +Because ARM properties can have any name and are implemented as functions +(which are masked by aliases), +PowerShell aliases (except for ARM aliases) are disabled in an `Arm` block. +You are free to define your own aliases or restore normal ones however. + ### ARM template functions and expressions The ARM template language has a template expression language embedded in JSON string values that it evaluates at deployment time, diff --git a/examples/simple-storage-account/storage-account.psarm.ps1 b/examples/simple-storage-account/storage-account.psarm.ps1 index 43d17e6..65caa71 100644 --- a/examples/simple-storage-account/storage-account.psarm.ps1 +++ b/examples/simple-storage-account/storage-account.psarm.ps1 @@ -1,3 +1,7 @@ + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + param( [Parameter(Mandatory)] [string] diff --git a/src/Commands/Template/NewPSArmTemplateCommand.cs b/src/Commands/Template/NewPSArmTemplateCommand.cs index 0fe4458..1825ce2 100644 --- a/src/Commands/Template/NewPSArmTemplateCommand.cs +++ b/src/Commands/Template/NewPSArmTemplateCommand.cs @@ -43,46 +43,51 @@ protected override void EndProcessing() } } - ScriptBlock transformedBody; - ArmObject armParameters; - ArmObject armVariables; - object[] psArgsArray; - - using (var pwsh = PowerShell.Create(RunspaceMode.CurrentRunspace)) + // Create the ARM template in an alias-free environment + ArmTemplate template = null; + using (PSAliasContext.EnterCleanAliasContext(SessionState)) { - try - { - transformedBody = new TemplateScriptBlockTransformer(pwsh).GetDeparameterizedTemplateScriptBlock( - Body, - out armParameters, - out armVariables, - out psArgsArray); - } - catch (Exception e) + ScriptBlock transformedBody; + ArmObject armParameters; + ArmObject armVariables; + object[] psArgsArray; + + using (var pwsh = PowerShell.Create(RunspaceMode.CurrentRunspace)) { - this.ThrowTerminatingError(e, "TemplateScriptBlockTransformationFailure", ErrorCategory.InvalidArgument, Body); - return; + try + { + transformedBody = new TemplateScriptBlockTransformer(pwsh).GetDeparameterizedTemplateScriptBlock( + Body, + out armParameters, + out armVariables, + out psArgsArray); + } + catch (Exception e) + { + this.ThrowTerminatingError(e, "TemplateScriptBlockTransformationFailure", ErrorCategory.InvalidArgument, Body); + return; + } } - } - var template = new ArmTemplate(templateName); + template = new ArmTemplate(templateName); - if (armParameters is not null && armParameters.Count > 0) - { - template.Parameters = armParameters; - } + if (armParameters is not null && armParameters.Count > 0) + { + template.Parameters = armParameters; + } - if (armVariables is not null && armVariables.Count > 0) - { - template.Variables = armVariables; - } + if (armVariables is not null && armVariables.Count > 0) + { + template.Variables = armVariables; + } - var templateBuilder = new ArmBuilder(template); - foreach (PSObject output in InvokeCommand.InvokeScript(useLocalScope: true, transformedBody, input: null, psArgsArray)) - { - if (output.BaseObject is ArmEntry armEntry) + var templateBuilder = new ArmBuilder(template); + foreach (PSObject output in InvokeCommand.InvokeScript(useLocalScope: true, transformedBody, input: null, psArgsArray)) { - templateBuilder.AddEntry(armEntry); + if (output.BaseObject is ArmEntry armEntry) + { + templateBuilder.AddEntry(armEntry); + } } } diff --git a/src/Execution/PSAliasContext.cs b/src/Execution/PSAliasContext.cs new file mode 100644 index 0000000..58b7211 --- /dev/null +++ b/src/Execution/PSAliasContext.cs @@ -0,0 +1,244 @@ + +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using PSArm.Commands.Primitive; +using PSArm.Commands.Template; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Management.Automation; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace PSArm.Execution +{ + internal sealed class PSAliasContext : IDisposable + { + private static readonly Func>> s_getAliasTable; + + private static readonly Action s_setAlias; + + private static readonly Action s_removeAlias; + + private static readonly HashSet s_psArmAliases = new HashSet(new[] + { + NewPSArmTemplateCommand.KeywordName, + NewPSArmArrayCommand.KeywordName, + NewPSArmElementCommand.KeywordName, + NewPSArmSkuCommand.KeywordName, + NewPSArmDependsOnCommand.KeywordName, + NewPSArmOutputCommand.KeywordName, + NewPSArmFunctionCallCommand.KeywordName, + NewPSArmEntryCommand.KeywordName, + NewPSArmResourceCommand.KeywordName, + }); + + static PSAliasContext() + { + // Our choices for alias manipulation are: + // - Call the cmdlets for each alias (and lose scope info needed for restore) + // - Use the provider for each alias (and lose scope info needed for restore) + // - Use reflection to run internal engine methods + // - Use reflection, but compile it to make it more efficient at the cost of readability + // + // Since we want to restore aliases exactly as we found them, + // and also may be running an arbitrary number of ARM templates in a session, + // we choose the last option. + + PropertyInfo ssInternalProperty = typeof(SessionState) + .GetProperty("Internal", BindingFlags.NonPublic | BindingFlags.Instance); + + Type ssInternalType = ssInternalProperty.PropertyType; + MethodInfo ssInternalGetter = ssInternalProperty.GetGetMethod(nonPublic: true); + + s_getAliasTable = GenerateGetAliasTableFunction(ssInternalType, ssInternalGetter); + s_setAlias = GenerateSetAliasFunction(ssInternalType, ssInternalGetter); + s_removeAlias = GenerateRemoveAliasFunction(ssInternalType, ssInternalGetter); + } + + public static PSAliasContext EnterCleanAliasContext(SessionState sessionState) + { + List> aliasTable = EnterCleanScope(sessionState); + return new PSAliasContext(sessionState, aliasTable); + } + + private readonly SessionState _sessionState; + private readonly List> _aliasTable; + + private PSAliasContext(SessionState sessionState, List> aliasTable) + { + _sessionState = sessionState; + _aliasTable = aliasTable; + } + + public void Dispose() + { + RestoreOldScope(_sessionState, _aliasTable); + } + + private static List> EnterCleanScope(SessionState sessionState) + { + List> aliasTable = s_getAliasTable(sessionState); + + foreach (Dictionary scope in aliasTable) + { + foreach (string alias in scope.Keys) + { + if (!s_psArmAliases.Contains(alias)) + { + s_removeAlias(sessionState, alias); + } + } + } + + return aliasTable; + } + + private static void RestoreOldScope(SessionState sessionState, List> aliasTable) + { + // Traverse the alias table from highest scope to lowest + aliasTable.Reverse(); + for (int i = 0; i < aliasTable.Count; i++) + { + foreach (KeyValuePair alias in aliasTable[i]) + { + s_setAlias(sessionState, alias.Value, i.ToString()); + } + } + } + + private static Func>> GenerateGetAliasTableFunction( + Type ssInternalType, + MethodInfo ssInternalGetter) + { + // This field got renamed at some point since PS 5.1 -- for now we assume we're safe with the Framework/Core condition +#if CoreCLR + FieldInfo ssInternalCurrentScopeField = ssInternalType.GetField("_currentScope", BindingFlags.NonPublic | BindingFlags.Instance); +#else + FieldInfo ssInternalCurrentScopeField = ssInternalType.GetField("currentScope", BindingFlags.NonPublic | BindingFlags.Instance); +#endif + Type scopeType = ssInternalCurrentScopeField.FieldType; + ConstructorInfo scopeEnumeratorConstructor = ssInternalType.Assembly.GetType("System.Management.Automation.SessionStateScopeEnumerator") + .GetConstructor( + BindingFlags.NonPublic | BindingFlags.Instance, + binder: null, + new Type[] { scopeType }, + modifiers: null); + MethodInfo scopeGetAliasesMethod = scopeType.GetMethod( + "GetAliases", + BindingFlags.NonPublic | BindingFlags.Instance, + binder: null, + Array.Empty(), + modifiers: null); + MethodInfo aggregateMethod = typeof(PSAliasContext).GetMethod( + nameof(PSAliasContext.Aggregate), + BindingFlags.NonPublic | BindingFlags.Static).MakeGenericMethod(scopeType, typeof(Dictionary)); + ConstructorInfo dictionaryConstructor = typeof(Dictionary) + .GetConstructor(new[] { typeof(Dictionary) }); + + var ssParameter = Expression.Parameter(typeof(SessionState)); + var scopeParameter = Expression.Parameter(scopeType); + + // We want to generate code like: + // + // Aggregate(new ScopeEnumerator(SessionState.Internal._currentScope), (scope) => new Dictionary(scope.GetAliases())) + + return Expression.Lambda>>>( + Expression.Call( + aggregateMethod, + Expression.New( + scopeEnumeratorConstructor, + Expression.Field( + Expression.Call( + ssParameter, + ssInternalGetter), + ssInternalCurrentScopeField)), + Expression.Lambda( + Expression.New( + dictionaryConstructor, + Expression.Call( + scopeParameter, + scopeGetAliasesMethod)), + scopeParameter)), + ssParameter).Compile(); + } + + private static Action GenerateSetAliasFunction( + Type ssInternalType, + MethodInfo ssInternalGetter) + { + MethodInfo ssInternalSetAliasItemAtScopeMethod = ssInternalType.GetMethod( + "SetAliasItemAtScope", + BindingFlags.NonPublic | BindingFlags.Instance, + binder: null, + new[] { typeof(AliasInfo), typeof(string), typeof(bool), typeof(CommandOrigin) }, + modifiers: null); + + var paramSessionState = Expression.Parameter(typeof(SessionState)); + var paramAliasInfo = Expression.Parameter(typeof(AliasInfo)); + var paramScopeName = Expression.Parameter(typeof(string)); + + // Generate code like: + // + // SessionState.Internal.SetAliasItemAtScope(alias, scope, force: true, CommandOrigin.Internal) + + return Expression.Lambda>( + Expression.Call( + Expression.Call( + paramSessionState, + ssInternalGetter), + ssInternalSetAliasItemAtScopeMethod, + paramAliasInfo, + paramScopeName, + Expression.Constant(true), + Expression.Constant(CommandOrigin.Internal)), + paramSessionState, + paramAliasInfo, + paramScopeName).Compile(); + } + + private static Action GenerateRemoveAliasFunction( + Type ssInternalType, + MethodInfo ssInternalGetter) + { + MethodInfo ssInternalRemoveAliasMethod = ssInternalType.GetMethod( + "RemoveAlias", + BindingFlags.NonPublic | BindingFlags.Instance, + binder: null, + new[] { typeof(string), typeof(bool) }, + modifiers: null); + + var paramSessionState = Expression.Parameter(typeof(SessionState)); + var paramAliasName = Expression.Parameter(typeof(string)); + + // Generate code like: + // + // SessionState.Internal.RemoveAlias(aliasName, force: true) + + return Expression.Lambda>( + Expression.Call( + Expression.Call( + paramSessionState, + ssInternalGetter), + ssInternalRemoveAliasMethod, + paramAliasName, + Expression.Constant(true)), + paramSessionState, + paramAliasName).Compile(); + } + + private static List Aggregate(IEnumerable enumerable, Func func) + { + var list = new List(); + foreach (T item in enumerable) + { + list.Add(func(item)); + } + return list; + } + } +} diff --git a/test/pester/Alias.Tests.ps1 b/test/pester/Alias.Tests.ps1 new file mode 100644 index 0000000..ccda47e --- /dev/null +++ b/test/pester/Alias.Tests.ps1 @@ -0,0 +1,29 @@ + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +BeforeAll { + Import-Module "$PSScriptRoot/../tools/TestHelper.psm1" +} + +Describe "PSArm templates working with PS aliases" { + It "Disables aliases within the Arm block, but restores them afterward" { + function DoNothing {} + + Set-Alias -Name addressPrefix -Value DoNothing + + $psArmScriptPath = "$PSScriptRoot/assets/aliastest.psarm.ps1" + $expectedTemplatePath = "$PSScriptRoot/assets/aliastest-template.json" + + $template = Publish-PSArmTemplate -Path $psArmScriptPath -NoHashTemplate -NoWriteFile -PassThru + $template.Metadata.GeneratorMetadata.Remove('psarm-psversion') + + $generatedJson = $template.ToJson() + $referenceJson = Get-Content -Raw -LiteralPath $expectedTemplatePath | ConvertFrom-Json + + (Get-Alias -Name addressPrefix).Definition | Should -BeExactly DoNothing + (Get-Alias -Name '%' -Scope Global).Definition | Should -BeExactly ForEach-Object + Assert-StructurallyEqual -ComparisonObject $referenceJson -JsonObject $generatedJson + } +} diff --git a/test/pester/Metadata.Tests.ps1 b/test/pester/Metadata.Tests.ps1 index a2be43a..e448b88 100644 --- a/test/pester/Metadata.Tests.ps1 +++ b/test/pester/Metadata.Tests.ps1 @@ -1,3 +1,7 @@ + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + Describe "Module and assembly metadata" { It "Should have matching module and assembly metadata" { $module = Get-Module 'PSArm' diff --git a/test/pester/assets/aliastest-template.json b/test/pester/assets/aliastest-template.json new file mode 100644 index 0000000..3dae717 --- /dev/null +++ b/test/pester/assets/aliastest-template.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "psarm", + "version": "0.1.0.0" + } + }, + "resources": [ + { + "name": "aliastest", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2019-10-01", + "properties": { + "mode": "Incremental", + "expressionEvaluationOptions": { + "scope": "inner" + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [ + { + "name": "mySubnet", + "apiVersion": "2019-11-01", + "type": "Microsoft.Network/virtualNetworks/subnets", + "properties": { + "addressPrefix": "10.0.0.0/24" + } + } + ] + } + } + } + ] +} diff --git a/test/pester/assets/aliastest.psarm.ps1 b/test/pester/assets/aliastest.psarm.ps1 new file mode 100644 index 0000000..1686c34 --- /dev/null +++ b/test/pester/assets/aliastest.psarm.ps1 @@ -0,0 +1,11 @@ + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Arm { + Resource "mySubnet" -Namespace Microsoft.Network -ApiVersion 2019-11-01 -Type virtualNetworks/subnets { + properties { + addressPrefix 10.0.0.0/24 + } + } +}