diff --git a/README.md b/README.md index fa54a7e3631..9ba5c796381 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,8 @@ The following conceptual topics exist in the `PSRule.Rules.Azure` module: - [Azure_AKSNodeMinimumMaxPods](docs/concepts/PSRule.Rules.Azure/en-US/about_PSRule_Azure_Configuration.md#azure_aksnodeminimummaxpods) - [Azure_AllowedRegions](docs/concepts/PSRule.Rules.Azure/en-US/about_PSRule_Azure_Configuration.md#azure_allowedregions) - [Azure_MinimumCertificateLifetime](docs/concepts/PSRule.Rules.Azure/en-US/about_PSRule_Azure_Configuration.md#azure_minimumcertificatelifetime) + - [AZURE_RESOURCE_GROUP](docs/concepts/PSRule.Rules.Azure/en-US/about_PSRule_Azure_Configuration.md#azure_resource_group) + - [AZURE_SUBSCRIPTION](docs/concepts/PSRule.Rules.Azure/en-US/about_PSRule_Azure_Configuration.md#azure_subscription) ## Related projects diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 63eac9b4198..f3621d8b744 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -2,6 +2,32 @@ Do Not Translate or Localize This file is based on or incorporates material from the projects listed below (Third Party IP). The original copyright notice and the license under which Microsoft received such Third Party IP, are set forth below. Such licenses and notices are provided for informational purposes only. Microsoft licenses the Third Party IP to you under the licensing terms for the Microsoft product. Microsoft reserves all other rights not expressly granted under this agreement, whether by implication, estoppel or otherwise. +--------------------------------------------- +File: YamlDotNet +--------------------------------------------- + +https://github.com/aaubry/YamlDotNet + +Copyright (c) 2008, 2009, 2010, 2011, 2012, 2013, 2014 Antoine Aubry and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + --------------------------------------------- File: Newtonsoft.Json --------------------------------------------- diff --git a/docs/CHANGELOG-v1.md b/docs/CHANGELOG-v1.md index cf904f284a4..37141d6cdf7 100644 --- a/docs/CHANGELOG-v1.md +++ b/docs/CHANGELOG-v1.md @@ -2,6 +2,14 @@ ## Unreleased +What's changed since pre-release v1.1.0-B2102015: + +- New features: + - Exporting template with `Export-AzRuleTemplateData` supports custom resource group and subscription. [#651](https://github.com/microsoft/PSRule.Rules.Azure/issues/651) + - Subscription and resource group used for deployment can be specified instead of using defaults. + - `ResourceGroupName` parameter of `Export-AzRuleTemplateData` has been renamed to `ResourceGroup`. + - Added a parameter alias for `ResourceGroupName` on `Export-AzRuleTemplateData`. + ## v1.1.0-B2102015 (pre-release) What's changed since pre-release v1.1.0-B2102010: diff --git a/docs/commands/PSRule.Rules.Azure/en-US/Export-AzRuleTemplateData.md b/docs/commands/PSRule.Rules.Azure/en-US/Export-AzRuleTemplateData.md index 0b67cf3e637..b8f968b92ea 100644 --- a/docs/commands/PSRule.Rules.Azure/en-US/Export-AzRuleTemplateData.md +++ b/docs/commands/PSRule.Rules.Azure/en-US/Export-AzRuleTemplateData.md @@ -15,7 +15,8 @@ Export resource configuration data from Azure templates. ```text Export-AzRuleTemplateData [[-Name] ] -TemplateFile [-ParameterFile ] - [-ResourceGroupName ] [-Subscription ] [-OutputPath ] [-PassThru] [] + [-ResourceGroup ] [-Subscription ] [-OutputPath ] + [-PassThru] [] ``` ## DESCRIPTION @@ -23,15 +24,31 @@ Export-AzRuleTemplateData [[-Name] ] -TemplateFile [-ParameterF Export resource configuration data by merging Azure Resource Manager (ARM) template and parameter files. Template and parameters are merged by resolving template parameters, variables and functions. +This function does not check template files for strict compliance with Azure schemas. + By default this is an offline process, requiring no connectivity to Azure. Some functions that may be included in templates dynamically query Azure for current state. For these functions standard placeholder values are used by default. +Functions that use placeholders include `reference`, `list*`. -Functions that use placeholders include `subscription`, `resourceGroup`, `reference`, `list*`. +The `subscription()` function will return the following unless overridden: -This function does not check template files for strict compliance with Azure schemas. +- subscriptionId: 'ffffffff-ffff-ffff-ffff-ffffffffffff' +- tenantId: 'ffffffff-ffff-ffff-ffff-ffffffffffff' +- displayName: 'PSRule Test Subscription' +- state: 'NotDefined' + +The `resourceGroup()` function will return the following unless overridden: -Currently the following limitations also apply: +- name: 'ps-rule-test-rg' +- location: 'eastus' +- tags: { } +- properties: + - provisioningState: 'Succeeded' + +To override, set the `AZURE_SUBSCRIPTION` and `AZURE_RESOURCE_GROUP` in configuration. + +Currently the following limitations apply: - Nested templates are expanded, external templates are not. - Deployment resources that link to an external template are returned as a resource. @@ -51,6 +68,42 @@ Export-AzRuleTemplateData -TemplateFile .\template.json -ParameterFile .\paramet Export resource configuration data based on merging a template and parameter file together. +### Example 2 + +```powershell +Get-AzRuleTemplateLink | Export-AzRuleTemplateData; +``` + +Recursively scan the current working path and export linked templates. + +### Example 3 + +```powershell +$subscription = @{ + subscriptionId = 'nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn' + displayName = 'My Azure Subscription' + tenantId = 'nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn' +} +Get-AzRuleTemplateLink | Export-AzRuleTemplateData -Subscription $subscription; +``` + +Export linked templates from the current working path using a specific subscription. + +### Example 4 + +```powershell +$rg = @{ + name = 'my-test-rg' + location = 'australiaeast' + tags = @{ + env = 'prod' + } +} +Get-AzRuleTemplateLink | Export-AzRuleTemplateData -ResourceGroup $rg; +``` + +Export linked templates from the current working path using a specific resource group. + ## PARAMETERS ### -Name @@ -143,17 +196,24 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -ResourceGroupName - -The name of the Resource Group where the deployment will occur. -If this option is specified, the properties of the Resource Group will be looked up and used during export. +### -ResourceGroup +A name or hashtable of the Resource Group where the deployment will occur. This Resource Group specified here will be used to resolve the `resourceGroup()` function. +When the name of Resource Group is specified, the Resource Group will be looked up and used during export. +This requires an authenticated connection to Azure with permissions to read the specified Resource Group. + +Alternately, a hashtable of a Resource Group object can be specified. +This option does not require an authenticated Azure connection. +The hashtable will override the defaults for any specified properties. + +For more details see about_PSRule_Azure_Configuration. + ```yaml -Type: String +Type: ResourceGroupReference Parameter Sets: (All) -Aliases: +Aliases: ResourceGroupName Required: False Position: Named @@ -164,13 +224,20 @@ Accept wildcard characters: False ### -Subscription -The name of the Subscription where the deployment will occur. -If this option is specified, the properties of the Subscription will be looked up and used during export. - +The name or hashtable of the Subscription where the deployment will occur. This subscription specified here will be used to resolve the `subscription()` function. +When a subscription name is specified, the Subscription will be looked up and used during export. +This requires an authenticated connection to Azure with permissions to read the specified Subscription. + +Alternately, a hashtable of a Subscription object can be specified. +This option does not require an authenticated Azure connection. +The hashtable will override the defaults for any specified properties. + +For more details see about_PSRule_Azure_Configuration. + ```yaml -Type: String +Type: SubscriptionReference Parameter Sets: (All) Aliases: diff --git a/docs/commands/PSRule.Rules.Azure/en-US/Get-AzRuleTemplateLink.md b/docs/commands/PSRule.Rules.Azure/en-US/Get-AzRuleTemplateLink.md index 1e427d9934a..cc1aa6e7dc6 100644 --- a/docs/commands/PSRule.Rules.Azure/en-US/Get-AzRuleTemplateLink.md +++ b/docs/commands/PSRule.Rules.Azure/en-US/Get-AzRuleTemplateLink.md @@ -22,12 +22,12 @@ Get-AzRuleTemplateLink [[-InputPath] ] [-SkipUnlinked] [[-Path] dictionary, string key, out object value) + { + return dictionary.TryGetValue(key, out value) && dictionary.Remove(key); + } + + [DebuggerStepThrough] + public static bool TryPopValue(this IDictionary dictionary, string key, out T value) + { + value = default; + if (dictionary.TryGetValue(key, out object v) && dictionary.Remove(key) && v is T result) + { + value = result; + return true; + } + return false; + } + + [DebuggerStepThrough] + public static bool TryPopBool(this IDictionary dictionary, string key, out bool value) + { + value = default; + return dictionary.TryGetValue(key, out object v) && dictionary.Remove(key) && bool.TryParse(v.ToString(), out value); + } + + public static bool TryGetBool(this IDictionary dictionary, string key, out bool? value) + { + value = null; + if (!dictionary.TryGetValue(key, out object o)) + return false; + + if (o is bool bvalue || (o is string svalue && bool.TryParse(svalue, out bvalue))) + { + value = bvalue; + return true; + } + return false; + } + + [DebuggerStepThrough] + public static void AddUnique(this IDictionary dictionary, IEnumerable> values) + { + foreach (var kv in values) + if (!dictionary.ContainsKey(kv.Key)) + dictionary.Add(kv.Key, kv.Value); + } + } +} diff --git a/src/PSRule.Rules.Azure/Configuration/ConfigurationOption.cs b/src/PSRule.Rules.Azure/Configuration/ConfigurationOption.cs new file mode 100644 index 00000000000..61f455c3fe0 --- /dev/null +++ b/src/PSRule.Rules.Azure/Configuration/ConfigurationOption.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.ComponentModel; +using YamlDotNet.Serialization; + +namespace PSRule.Rules.Azure.Configuration +{ + public sealed class ConfigurationOption : IEquatable + { + internal static readonly ConfigurationOption Default = new ConfigurationOption + { + Subscription = SubscriptionOption.Default, + ResourceGroup = ResourceGroupOption.Default + }; + + public ConfigurationOption() + { + Subscription = null; + ResourceGroup = null; + } + + internal ConfigurationOption(ConfigurationOption option) + { + if (option == null) + throw new ArgumentNullException(nameof(option)); + + Subscription = option.Subscription; + ResourceGroup = option.ResourceGroup; + } + + public override bool Equals(object obj) + { + return obj is ConfigurationOption option && Equals(option); + } + + public bool Equals(ConfigurationOption other) + { + return other != null && + Subscription == other.Subscription && + ResourceGroup == other.ResourceGroup; + } + + public override int GetHashCode() + { + unchecked // Overflow is fine + { + int hash = 17; + hash = hash * 23 + (Subscription != null ? Subscription.GetHashCode() : 0); + hash = hash * 23 + (ResourceGroup != null ? ResourceGroup.GetHashCode() : 0); + return hash; + } + } + + internal static ConfigurationOption Combine(ConfigurationOption o1, ConfigurationOption o2) + { + var result = new ConfigurationOption(o1); + result.ResourceGroup = o1.ResourceGroup ?? o2.ResourceGroup; + result.Subscription = o1.Subscription ?? o2.Subscription; + return result; + } + + /// + /// The file path location to save results. + /// + [DefaultValue(null)] + [YamlMember(Alias = "AZURE_SUBSCRIPTION", ApplyNamingConventions = false)] + public SubscriptionOption Subscription { get; set; } + + [YamlMember(Alias = "AZURE_RESOURCE_GROUP", ApplyNamingConventions = false)] + public ResourceGroupOption ResourceGroup { get; set; } + } +} diff --git a/src/PSRule.Rules.Azure/Configuration/OutputOption.cs b/src/PSRule.Rules.Azure/Configuration/OutputOption.cs index 705dbbd46db..b3f9652ac80 100644 --- a/src/PSRule.Rules.Azure/Configuration/OutputOption.cs +++ b/src/PSRule.Rules.Azure/Configuration/OutputOption.cs @@ -56,6 +56,13 @@ public override int GetHashCode() } } + internal static OutputOption Combine(OutputOption o1, OutputOption o2) + { + var result = new OutputOption(o1); + result.Path = o1.Path ?? o2.Path; + return result; + } + /// /// The encoding to use when writing results to file. /// diff --git a/src/PSRule.Rules.Azure/Configuration/PSRuleOption.cs b/src/PSRule.Rules.Azure/Configuration/PSRuleOption.cs index c57d30af9ce..773744dc120 100644 --- a/src/PSRule.Rules.Azure/Configuration/PSRuleOption.cs +++ b/src/PSRule.Rules.Azure/Configuration/PSRuleOption.cs @@ -1,9 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; +using System.Collections; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Management.Automation; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; namespace PSRule.Rules.Azure.Configuration { @@ -14,8 +19,13 @@ namespace PSRule.Rules.Azure.Configuration public sealed class PSRuleOption { + private const string DEFAULT_FILENAME = "ps-rule.yaml"; + + private string SourcePath; + internal static readonly PSRuleOption Default = new PSRuleOption { + Configuration = ConfigurationOption.Default, Output = OutputOption.Default }; @@ -27,9 +37,21 @@ public sealed class PSRuleOption public PSRuleOption() { // Set defaults + Configuration = new ConfigurationOption(); Output = new OutputOption(); } + private PSRuleOption(string sourcePath, PSRuleOption option) + { + SourcePath = sourcePath; + + // Set from existing option instance + Configuration = new ConfigurationOption(option?.Configuration); + Output = new OutputOption(option?.Output); + } + + public ConfigurationOption Configuration { get; set; } + /// /// Options that affect how output is generated. /// @@ -49,6 +71,7 @@ public static void UseExecutionContext(EngineIntrinsics executionContext) _GetWorkingPath = () => Directory.GetCurrentDirectory(); return; } + _GetWorkingPath = () => executionContext.SessionState.Path.CurrentFileSystemLocation.Path; } @@ -57,11 +80,76 @@ public static string GetWorkingPath() return _GetWorkingPath(); } + /// + /// Load a YAML formatted PSRuleOption object from disk. + /// + /// A file or directory to read options from. + /// An options object. + /// + /// This method is called from PowerShell. + /// + public static PSRuleOption FromFileOrDefault(string path) + { + // Get a rooted file path instead of directory or relative path + var filePath = GetFilePath(path); + + // Return empty options if file does not exist + var result = !File.Exists(filePath) ? new PSRuleOption() : FromYaml(path: filePath, yaml: File.ReadAllText(filePath)); + return PSRuleOption.Combine(result, PSRuleOption.Default); + } + + private static PSRuleOption Combine(PSRuleOption o1, PSRuleOption o2) + { + var result = new PSRuleOption(o1?.SourcePath ?? o2?.SourcePath, o1); + result.Configuration = ConfigurationOption.Combine(result.Configuration, o2?.Configuration); + result.Output = OutputOption.Combine(result.Output, o2?.Output); + return result; + } + + private static PSRuleOption FromYaml(string path, string yaml) + { + var d = new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + var option = d.Deserialize(yaml) ?? new PSRuleOption(); + option.SourcePath = path; + return option; + } + + /// + /// Get a fully qualified file path. + /// + /// A file or directory path. + /// + public static string GetFilePath(string path) + { + var rootedPath = GetRootedPath(path); + if (Path.HasExtension(rootedPath)) + { + var ext = Path.GetExtension(rootedPath); + if (string.Equals(ext, ".yaml", StringComparison.OrdinalIgnoreCase) || string.Equals(ext, ".yml", StringComparison.OrdinalIgnoreCase)) + { + return rootedPath; + } + } + + // Check if default files exist and + return UseFilePath(path: rootedPath, name: "ps-rule.yaml") ?? + UseFilePath(path: rootedPath, name: "ps-rule.yml") ?? + UseFilePath(path: rootedPath, name: "psrule.yaml") ?? + UseFilePath(path: rootedPath, name: "psrule.yml") ?? + Path.Combine(rootedPath, DEFAULT_FILENAME); + } + /// /// Get a full path instead of a relative path that may be passed from PowerShell. /// internal static string GetRootedPath(string path) { + if (string.IsNullOrEmpty(path)) + return GetWorkingPath(); + return Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(GetWorkingPath(), path)); } @@ -77,6 +165,27 @@ internal static string GetRootedBasePath(string path) return File.Exists(rootedPath) ? rootedPath : string.Concat(rootedPath, Path.DirectorySeparatorChar); } + internal static Dictionary BuildIndex(Hashtable hashtable) + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in hashtable) + index.Add(entry.Key.ToString(), entry.Value); + + return index; + } + + /// + /// Determine if the combined file path is exists. + /// + /// A directory path where a options file may be stored. + /// A file name of an options file. + /// Returns a file path if the file exists or null if the file does not exist. + private static string UseFilePath(string path, string name) + { + var filePath = Path.Combine(path, name); + return File.Exists(filePath) ? filePath : null; + } + [DebuggerStepThrough] private static bool IsSeparator(char c) { diff --git a/src/PSRule.Rules.Azure/Configuration/ResourceGroupOption.cs b/src/PSRule.Rules.Azure/Configuration/ResourceGroupOption.cs new file mode 100644 index 00000000000..2d3d2fd1de9 --- /dev/null +++ b/src/PSRule.Rules.Azure/Configuration/ResourceGroupOption.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using System.ComponentModel; +using YamlDotNet.Serialization; + +namespace PSRule.Rules.Azure.Configuration +{ + public sealed class ResourceGroupOption + { + private const string DEFAULT_SUBSCRIPTIONID = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + private const string DEFAULT_NAME = "ps-rule-test-rg"; + private const string DEFAULT_TYPE = "Microsoft.Resources/resourceGroups"; + private const string DEFAULT_LOCATION = "eastus"; + private const string DEFAULT_MANAGEDBY = null; + private const Hashtable DEFAULT_TAGS = null; + private const string DEFAULT_PROVISIONINGSTATE = "Succeeded"; + + private const string ID_PREFIX = "/subscriptions/"; + private const string RGID_PREFIX = "/resourceGroups/"; + + internal readonly static ResourceGroupOption Default = new ResourceGroupOption + { + SubscriptionId = DEFAULT_SUBSCRIPTIONID, + Name = DEFAULT_NAME, + Location = DEFAULT_LOCATION, + ManagedBy = DEFAULT_MANAGEDBY, + Tags = DEFAULT_TAGS, + Properties = new ResourceGroupProperties(DEFAULT_PROVISIONINGSTATE), + }; + + private string _SubscriptionId; + private string _Name; + + public ResourceGroupOption() + { + Name = null; + Location = null; + ManagedBy = null; + Tags = null; + Properties = null; + } + + internal ResourceGroupOption(string name, string location, string managedBy, Hashtable tags, string provisioningState) + { + Name = name ?? DEFAULT_NAME; + Location = location ?? DEFAULT_LOCATION; + ManagedBy = managedBy ?? DEFAULT_MANAGEDBY; + Tags = tags ?? DEFAULT_TAGS; + Properties = new ResourceGroupProperties(provisioningState); + } + + public sealed class ResourceGroupProperties + { + public readonly string ProvisioningState; + + public ResourceGroupProperties() + { + ProvisioningState = DEFAULT_PROVISIONINGSTATE; + } + + internal ResourceGroupProperties(string provisioningState) + { + ProvisioningState = provisioningState ?? DEFAULT_PROVISIONINGSTATE; + } + } + + /// + /// The unique GUID associated with the subscription. + /// + [YamlIgnore] + public string SubscriptionId + { + get { return _SubscriptionId; } + set + { + _SubscriptionId = value; + if (string.IsNullOrEmpty(_SubscriptionId) || string.IsNullOrEmpty(Name)) + return; + + Id = string.Concat(ID_PREFIX, _SubscriptionId, RGID_PREFIX, _Name); + } + } + + /// + /// A unique identifier for the resource group. + /// + /// + /// This is a calculated property based on SubscriptionId and Name. + /// + [YamlIgnore] + public string Id { get; private set; } + + [DefaultValue(null)] + public string Name + { + get { return _Name; } + set + { + _Name = value; + if (string.IsNullOrEmpty(_SubscriptionId) || string.IsNullOrEmpty(Name)) + return; + + Id = string.Concat(ID_PREFIX, SubscriptionId, RGID_PREFIX, _Name); + } + } + + [YamlIgnore] + public string Type => DEFAULT_TYPE; + + [DefaultValue(null)] + public string Location { get; set; } + + [DefaultValue(null)] + public string ManagedBy { get; set; } + + [DefaultValue(null)] + public Hashtable Tags { get; set; } + + [DefaultValue(null)] + public ResourceGroupProperties Properties { get; set; } + } + + public sealed class ResourceGroupReference + { + private ResourceGroupReference() { } + + private ResourceGroupReference(string name) + { + Name = name; + FromName = true; + } + + public string Name { get; set; } + + public string Location { get; set; } + + public string ManagedBy { get; set; } + + public Hashtable Tags { get; set; } + + public string ProvisioningState { get; set; } + + public bool FromName { get; private set; } + + public static implicit operator ResourceGroupReference(Hashtable hashtable) + { + return FromHashtable(hashtable); + } + + public static implicit operator ResourceGroupReference(string resourceGroupName) + { + return FromString(resourceGroupName); + } + + public static ResourceGroupReference FromHashtable(Hashtable hashtable) + { + var index = PSRuleOption.BuildIndex(hashtable); + var option = new ResourceGroupReference(); + if (index.TryPopValue("Name", out string svalue)) + option.Name = svalue; + + if (index.TryPopValue("Location", out svalue)) + option.Location = svalue; + + if (index.TryPopValue("ManagedBy", out svalue)) + option.ManagedBy = svalue; + + if (index.TryPopValue("ProvisioningState", out svalue)) + option.ProvisioningState = svalue; + + if (index.TryPopValue("Tags", out Hashtable tags)) + option.Tags = tags; + + return option; + } + + public static ResourceGroupReference FromString(string resourceGroupName) + { + return new ResourceGroupReference(resourceGroupName); + } + + public ResourceGroupOption ToResourceGroupOption() + { + return new ResourceGroupOption(Name, Location, ManagedBy, Tags, ProvisioningState); + } + } +} diff --git a/src/PSRule.Rules.Azure/Configuration/SubscriptionOption.cs b/src/PSRule.Rules.Azure/Configuration/SubscriptionOption.cs new file mode 100644 index 00000000000..e6a7c7c9598 --- /dev/null +++ b/src/PSRule.Rules.Azure/Configuration/SubscriptionOption.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using System.ComponentModel; +using YamlDotNet.Serialization; + +namespace PSRule.Rules.Azure.Configuration +{ + public sealed class SubscriptionOption + { + private const string DEFAULT_SUBSCRIPTIONID = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + private const string DEFAULT_TENANTID = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + private const string DEFAULT_DISPLAYNAME = "PSRule Test Subscription"; + private const string DEFAULT_STATE = "NotDefined"; + + private const string ID_PREFIX = "/subscriptions/"; + + internal readonly static SubscriptionOption Default = new SubscriptionOption + { + SubscriptionId = DEFAULT_SUBSCRIPTIONID, + TenantId = DEFAULT_TENANTID, + DisplayName = DEFAULT_DISPLAYNAME, + State = DEFAULT_STATE + }; + + private string _SubscriptionId; + + public SubscriptionOption() + { + SubscriptionId = null; + TenantId = null; + DisplayName = null; + State = null; + } + + internal SubscriptionOption(string subscriptionId, string tenantId, string displayName, string state) + { + SubscriptionId = subscriptionId ?? DEFAULT_SUBSCRIPTIONID; + TenantId = tenantId ?? DEFAULT_TENANTID; + DisplayName = displayName ?? DEFAULT_DISPLAYNAME; + State = state ?? DEFAULT_STATE; + } + + /// + /// A unique identifier for the subscription. + /// + /// + /// This is a calculated property based on SubscriptionId. + /// + [YamlIgnore] + public string Id { get; private set; } + + /// + /// The unique GUID associated with the subscription. + /// + [DefaultValue(null)] + public string SubscriptionId + { + get { return _SubscriptionId; } + set + { + _SubscriptionId = value; + Id = string.Concat(ID_PREFIX, SubscriptionId); + } + } + + [DefaultValue(null)] + public string TenantId { get; set; } + + [DefaultValue(null)] + public string DisplayName { get; set; } + + [DefaultValue(null)] + public string State { get; set; } + } + + public sealed class SubscriptionReference + { + private SubscriptionReference() { } + + private SubscriptionReference(string displayName) + { + DisplayName = displayName; + FromName = true; + } + + public string SubscriptionId { get; set; } + + public string TenantId { get; set; } + + public string DisplayName { get; set; } + + public string State { get; set; } + + public bool FromName { get; private set; } + + public static implicit operator SubscriptionReference(Hashtable hashtable) + { + return FromHashtable(hashtable); + } + + public static implicit operator SubscriptionReference(string displayName) + { + return FromString(displayName); + } + + public static SubscriptionReference FromHashtable(Hashtable hashtable) + { + var index = PSRuleOption.BuildIndex(hashtable); + var option = new SubscriptionReference(); + if (index.TryPopValue("SubscriptionId", out string svalue)) + option.SubscriptionId = svalue; + + if (index.TryPopValue("TenantId", out svalue)) + option.TenantId = svalue; + + if (index.TryPopValue("DisplayName", out svalue)) + option.DisplayName = svalue; + + if (index.TryPopValue("State", out svalue)) + option.State = svalue; + + return option; + } + + public static SubscriptionReference FromString(string displayName) + { + return new SubscriptionReference(displayName); + } + + public SubscriptionOption ToSubscriptionOption() + { + return new SubscriptionOption(SubscriptionId, TenantId, DisplayName, State); + } + } +} diff --git a/src/PSRule.Rules.Azure/Data/Template/Models.cs b/src/PSRule.Rules.Azure/Data/Template/Models.cs index 2d2b8b4d38e..2f98efe803f 100644 --- a/src/PSRule.Rules.Azure/Data/Template/Models.cs +++ b/src/PSRule.Rules.Azure/Data/Template/Models.cs @@ -4,7 +4,6 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using System; -using System.Collections; using System.Collections.Generic; using System.Text; @@ -28,98 +27,6 @@ internal enum ParameterType SecureObject } - public sealed class Subscription - { - private const string DEFAULT_ID = "/subscriptions/{{Subscription.SubscriptionId}}"; - private const string DEFAULT_SUBSCRIPTIONID = "{{Subscription.SubscriptionId}}"; - private const string DEFAULT_TENANTID = "{{Subscription.TenantId}}"; - private const string DEFAULT_DISPLAYNAME = "{{Subscription.Name}}"; - - internal readonly static Subscription Default = new Subscription(); - - internal Subscription() - { - SubscriptionId = DEFAULT_SUBSCRIPTIONID; - TenantId = DEFAULT_TENANTID; - DisplayName = DEFAULT_DISPLAYNAME; - Id = DEFAULT_ID; - } - - internal Subscription(string subscriptionId, string tenantId, string displayName) - { - SubscriptionId = subscriptionId; - TenantId = tenantId; - DisplayName = displayName; - Id = string.Concat("/subscriptions/", SubscriptionId); - } - - public readonly string Id; - - public readonly string SubscriptionId; - - public readonly string TenantId; - - public readonly string DisplayName; - } - - public sealed class ResourceGroup - { - private const string DEFAULT_ID = "/subscriptions/{{Subscription.SubscriptionId}}/resourceGroups/{{ResourceGroup.Name}}"; - private const string DEFAULT_NAME = "{{ResourceGroup.Name}}"; - private const string DEFAULT_TYPE = "Microsoft.Resources/resourceGroups"; - private const string DEFAULT_LOCATION = "{{ResourceGroup.Location}}"; - private const string DEFAULT_MANAGEDBY = "{{ResourceGroup.ManagedBy}}"; - private const Hashtable DEFAULT_TAGS = null; - private const string DEFAULT_PROVISIONINGSTATE = "Succeeded"; - - internal readonly static ResourceGroup Default = new ResourceGroup(); - - internal ResourceGroup() - { - Id = DEFAULT_ID; - Name = DEFAULT_NAME; - Type = DEFAULT_TYPE; - Location = DEFAULT_LOCATION; - ManagedBy = DEFAULT_MANAGEDBY; - Tags = DEFAULT_TAGS; - Properties = new ResourceGroupProperties(DEFAULT_PROVISIONINGSTATE); - } - - internal ResourceGroup(string id, string name, string location, string managedBy, Hashtable tags) - : this() - { - Id = id; - Name = name; - Location = location; - ManagedBy = managedBy; - Tags = tags; - } - - public sealed class ResourceGroupProperties - { - public readonly string ProvisioningState; - - internal ResourceGroupProperties(string provisioningState) - { - ProvisioningState = provisioningState; - } - } - - public readonly string Id; - - public readonly string Name; - - public readonly string Type; - - public readonly string Location; - - public readonly string ManagedBy; - - public readonly Hashtable Tags; - - public readonly ResourceGroupProperties Properties; - } - public sealed class ResourceProvider { internal ResourceProvider() diff --git a/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs b/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs index 2a8a642237c..dcd7fd3986f 100644 --- a/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs +++ b/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs @@ -3,6 +3,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using PSRule.Rules.Azure.Configuration; using PSRule.Rules.Azure.Resources; using System; using System.Collections.Generic; @@ -53,14 +54,14 @@ internal TemplateContext() Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); Variables = new Dictionary(StringComparer.OrdinalIgnoreCase); CopyIndex = new CopyIndexStore(); - ResourceGroup = new ResourceGroup(); - Subscription = new Subscription(); + ResourceGroup = ResourceGroupOption.Default; + Subscription = SubscriptionOption.Default; _Deployment = new Stack(); _Providers = ReadProviders(); _Environments = ReadEnvironments(); } - internal TemplateContext(Subscription subscription, ResourceGroup resourceGroup) + internal TemplateContext(SubscriptionOption subscription, ResourceGroupOption resourceGroup) : this() { if (subscription != null) @@ -78,9 +79,9 @@ internal TemplateContext(Subscription subscription, ResourceGroup resourceGroup) public CopyIndexStore CopyIndex { get; } - public ResourceGroup ResourceGroup { get; internal set; } + public ResourceGroupOption ResourceGroup { get; internal set; } - public Subscription Subscription { get; internal set; } + public SubscriptionOption Subscription { get; internal set; } public JObject Deployment { diff --git a/src/PSRule.Rules.Azure/PSRule.Rules.Azure.csproj b/src/PSRule.Rules.Azure/PSRule.Rules.Azure.csproj index 984e738003c..4945fdf10d4 100644 --- a/src/PSRule.Rules.Azure/PSRule.Rules.Azure.csproj +++ b/src/PSRule.Rules.Azure/PSRule.Rules.Azure.csproj @@ -36,6 +36,7 @@ This project uses GitHub Issues to track bugs and feature requests. See GitHub p + diff --git a/src/PSRule.Rules.Azure/PSRule.Rules.Azure.psm1 b/src/PSRule.Rules.Azure/PSRule.Rules.Azure.psm1 index 899922071dc..f067810a2f3 100644 --- a/src/PSRule.Rules.Azure/PSRule.Rules.Azure.psm1 +++ b/src/PSRule.Rules.Azure/PSRule.Rules.Azure.psm1 @@ -107,10 +107,11 @@ function Export-AzRuleTemplateData { [String[]]$ParameterFile, [Parameter(Mandatory = $False)] - [String]$ResourceGroupName, + [Alias('ResourceGroupName')] + [PSRule.Rules.Azure.Configuration.ResourceGroupReference]$ResourceGroup, [Parameter(Mandatory = $False)] - [String]$Subscription, + [PSRule.Rules.Azure.Configuration.SubscriptionReference]$Subscription, [Parameter(Mandatory = $False)] [String]$OutputPath = $PWD, @@ -124,7 +125,7 @@ function Export-AzRuleTemplateData { Write-Warning -Message "The cmdlet 'Export-AzTemplateRuleData' is has been renamed to 'Export-AzRuleTemplateData'. Use of 'Export-AzTemplateRuleData' is deprecated and will be removed in the next major version." } - $Option = [PSRule.Rules.Azure.Configuration.PSRuleOption]::new(); + $Option = [PSRule.Rules.Azure.Configuration.PSRuleOption]::FromFileOrDefault($PWD); $Option.Output.Path = $OutputPath; # Build the pipeline @@ -134,16 +135,16 @@ function Export-AzRuleTemplateData { # Bind to subscription context if ($PSBoundParameters.ContainsKey('Subscription')) { - $subscriptionObject = GetSubscription -Subscription $Subscription -ErrorAction SilentlyContinue; - if ($Null -ne $subscriptionObject) { - $builder.Subscription($subscriptionObject); + $subscriptionOption = GetSubscription -InputObject $Subscription -ErrorAction SilentlyContinue; + if ($Null -ne $subscriptionOption) { + $builder.Subscription($subscriptionOption); } } # Bind to resource group - if ($PSBoundParameters.ContainsKey('ResourceGroupName')) { - $resourceGroupObject = GetResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue; - if ($Null -ne $resourceGroupObject) { - $builder.ResourceGroup($resourceGroupObject); + if ($PSBoundParameters.ContainsKey('ResourceGroup')) { + $resourceGroupOption = GetResourceGroup -InputObject $ResourceGroup -ErrorAction SilentlyContinue; + if ($Null -ne $resourceGroupOption) { + $builder.ResourceGroup($resourceGroupOption); } } @@ -252,23 +253,46 @@ function Get-AzRuleTemplateLink { function GetResourceGroup { [CmdletBinding()] + [OutputType([PSRule.Rules.Azure.Configuration.ResourceGroupOption])] param ( [Parameter(Mandatory = $True)] - [String]$Name + [PSRule.Rules.Azure.Configuration.ResourceGroupReference]$InputObject ) process { - return Get-AzResourceGroup -Name $Name -ErrorAction SilentlyContinue; + $result = $InputObject.ToResourceGroupOption(); + if ($InputObject.FromName) { + $o = Get-AzResourceGroup -Name $InputObject.Name -ErrorAction SilentlyContinue; + if ($Null -ne $o) { + $result.Name = $o.ResourceGroupName + $result.Location = $o.Location + $result.ManagedBy = $o.ManagedBy + $result.Properties.ProvisioningState = $o.ProvisioningState + $result.Tags = $o.Tags + } + } + return $result; } } function GetSubscription { [CmdletBinding()] + [OutputType([PSRule.Rules.Azure.Configuration.SubscriptionOption])] param ( [Parameter(Mandatory = $True)] - [String]$Subscription + [PSRule.Rules.Azure.Configuration.SubscriptionReference]$InputObject ) process { - return (Set-AzContext -Subscription $Subscription -ErrorAction SilentlyContinue).Subscription; + $result = $InputObject.ToSubscriptionOption(); + if ($InputObject.FromName) { + $o = (Set-AzContext -Subscription $InputObject.DisplayName -ErrorAction SilentlyContinue).Subscription; + if ($Null -ne $o) { + $result.DisplayName = $o.Name + $result.SubscriptionId = $o.SubscriptionId + $result.State = $o.State + $result.TenantId = $o.TenantId + } + } + return $result; } } diff --git a/src/PSRule.Rules.Azure/Pipeline/PipelineBuilder.cs b/src/PSRule.Rules.Azure/Pipeline/PipelineBuilder.cs index 1300afd2751..311f6c54e1e 100644 --- a/src/PSRule.Rules.Azure/Pipeline/PipelineBuilder.cs +++ b/src/PSRule.Rules.Azure/Pipeline/PipelineBuilder.cs @@ -79,6 +79,7 @@ public virtual IPipelineBuilder Configure(PSRuleOption option) return this; Option.Output = new OutputOption(option.Output); + Option.Configuration = new ConfigurationOption(option.Configuration); return this; } diff --git a/src/PSRule.Rules.Azure/Pipeline/TemplatePipeline.cs b/src/PSRule.Rules.Azure/Pipeline/TemplatePipeline.cs index 76a089c518c..52f858d32d9 100644 --- a/src/PSRule.Rules.Azure/Pipeline/TemplatePipeline.cs +++ b/src/PSRule.Rules.Azure/Pipeline/TemplatePipeline.cs @@ -8,7 +8,6 @@ using PSRule.Rules.Azure.Pipeline.Output; using PSRule.Rules.Azure.Resources; using System; -using System.Collections; using System.Collections.Generic; using System.IO; using System.Management.Automation; @@ -21,9 +20,9 @@ public interface ITemplatePipelineBuilder : IPipelineBuilder { void Deployment(string deploymentName); - void ResourceGroup(PSObject resourceGroup); + void ResourceGroup(ResourceGroupOption resourceGroup); - void Subscription(PSObject subscription); + void Subscription(SubscriptionOption subscription); void PassThru(bool passThru); } @@ -45,16 +44,14 @@ internal sealed class TemplatePipelineBuilder : PipelineBuilderBase, ITemplatePi private const string SUBSCRIPTION_NAME = "Name"; private string _DeploymentName; - private ResourceGroup _ResourceGroup; - private Subscription _Subscription; + private ResourceGroupOption _ResourceGroup; + private SubscriptionOption _Subscription; private bool _PassThru; internal TemplatePipelineBuilder(PSRuleOption option) : base() { _DeploymentName = string.Concat(DEPLOYMENTNAME_PREFIX, Guid.NewGuid().ToString().Substring(0, 8)); - _ResourceGroup = Data.Template.ResourceGroup.Default; - _Subscription = Data.Template.Subscription.Default; Configure(option); } @@ -66,24 +63,20 @@ public void Deployment(string deploymentName) _DeploymentName = deploymentName; } - public void ResourceGroup(PSObject resourceGroup) + public void ResourceGroup(ResourceGroupOption resourceGroup) { - _ResourceGroup = new ResourceGroup( - id: GetProperty(resourceGroup, RESOURCEGROUP_RESOURCEID), - name: GetProperty(resourceGroup, RESOURCEGROUP_RESOURCEGROUPNAME), - location: GetProperty(resourceGroup, RESOURCEGROUP_LOCATION), - managedBy: GetProperty(resourceGroup, RESOURCEGROUP_MANAGEDBY), - tags: GetProperty(resourceGroup, RESOURCEGROUP_TAGS) - ); + if (resourceGroup == null) + return; + + _ResourceGroup = resourceGroup; } - public void Subscription(PSObject subscription) + public void Subscription(SubscriptionOption subscription) { - _Subscription = new Subscription( - subscriptionId: GetProperty(subscription, SUBSCRIPTION_SUBSCRIPTIONID), - tenantId: GetProperty(subscription, SUBSCRIPTION_TENANTID), - displayName: GetProperty(subscription, SUBSCRIPTION_NAME) - ); + if (subscription == null) + return; + + _Subscription = subscription; } public void PassThru(bool passThru) @@ -91,11 +84,6 @@ public void PassThru(bool passThru) _PassThru = passThru; } - private T GetProperty(PSObject obj, string propertyName) - { - return null == obj.Properties[propertyName] ? default(T) : (T)obj.Properties[propertyName].Value; - } - protected override PipelineWriter GetOutput() { // Redirect to file instead @@ -120,6 +108,10 @@ protected override PipelineWriter PrepareWriter() public override IPipeline Build() { + _ResourceGroup = _ResourceGroup ?? Option.Configuration.ResourceGroup; + _Subscription = _Subscription ?? Option.Configuration.Subscription; + + _ResourceGroup.SubscriptionId = _Subscription.SubscriptionId; return new TemplatePipeline(PrepareContext(), PrepareWriter(), _DeploymentName, _ResourceGroup, _Subscription); } @@ -156,10 +148,10 @@ private static Encoding GetEncoding(OutputEncoding? encoding) internal sealed class TemplatePipeline : PipelineBase { private readonly string _DeploymentName; - private readonly ResourceGroup _ResourceGroup; - private readonly Subscription _Subscription; + private readonly ResourceGroupOption _ResourceGroup; + private readonly SubscriptionOption _Subscription; - internal TemplatePipeline(PipelineContext context, PipelineWriter writer, string deploymentName, ResourceGroup resourceGroup, Subscription subscription) + internal TemplatePipeline(PipelineContext context, PipelineWriter writer, string deploymentName, ResourceGroupOption resourceGroup, SubscriptionOption subscription) : base(context, writer) { _DeploymentName = deploymentName; diff --git a/tests/PSRule.Rules.Azure.Tests/Cmdlet.Common.Tests.ps1 b/tests/PSRule.Rules.Azure.Tests/Cmdlet.Common.Tests.ps1 index 3cd8345f1bb..918e5ad3b77 100644 --- a/tests/PSRule.Rules.Azure.Tests/Cmdlet.Common.Tests.ps1 +++ b/tests/PSRule.Rules.Azure.Tests/Cmdlet.Common.Tests.ps1 @@ -320,47 +320,68 @@ Describe 'Export-AzRuleTemplateData' -Tag 'Cmdlet','Export-AzRuleTemplateData' { } } - Context 'With -Subscription' { - Mock -CommandName 'GetSubscription' -ModuleName 'PSRule.Rules.Azure' -MockWith { - return [PSCustomObject]@{ - SubscriptionId = '00000000-0000-0000-0000-000000000000' - TenantId = '00000000-0000-0000-0000-000000000000' - Name = 'test-sub' + Context 'With -Subscription lookup' { + It 'From context' { + Mock -CommandName 'GetSubscription' -ModuleName 'PSRule.Rules.Azure' -MockWith { + $result = [PSRule.Rules.Azure.Configuration.SubscriptionOption]::new(); + $result.SubscriptionId = '00000000-0000-0000-0000-000000000000'; + $result.TenantId = '00000000-0000-0000-0000-000000000000'; + $result.DisplayName = 'test-sub'; + return $result; } - } - It 'Exports template' { $exportParams = @{ TemplateFile = $templatePath ParameterFile = $parametersPath Subscription = 'test-sub' } + + # With lookup $result = Export-AzRuleTemplateData @exportParams -PassThru; $result | Should -Not -BeNullOrEmpty; $result.Length | Should -Be 9; $result[0].properties.subnets.Length | Should -Be 3; $result[0].properties.subnets[2].properties.networkSecurityGroup.id | Should -Match '^/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/[\w\{\}\-\.]{1,}/providers/Microsoft\.Network/networkSecurityGroups/nsg-subnet2$'; $result[0].properties.subnets[2].properties.routeTable.id | Should -Match '^/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/[\w\{\}\-\.]{1,}/providers/Microsoft\.Network/routeTables/route-subnet2$'; + $result[0].tags.role | Should -Match 'Networking'; } } - Context 'With -ResourceGroup' { - Mock -CommandName 'GetResourceGroup' -ModuleName 'PSRule.Rules.Azure' -MockWith { - return [PSCustomObject]@{ - ResourceId = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg' - ResourceGroupName = 'test-rg' - Location = 'region' - ManagedBy = 'testuser' - Tags = @{ - test = 'true' + Context 'With -Subscription object' { + It 'From hashtable' { + $exportParams = @{ + TemplateFile = $templatePath + ParameterFile = $parametersPath + Subscription = @{ + SubscriptionId = 'nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn'; + TenantId = 'nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn'; } } + $result = Export-AzRuleTemplateData @exportParams -PassThru; + $result | Should -Not -BeNullOrEmpty; + $result.Length | Should -Be 9; + $result[0].tags.role | Should -Be 'Custom'; } - It 'Exports template' { + } + + Context 'With -ResourceGroup lookup' { + It 'From context' { + Mock -CommandName 'GetResourceGroup' -ModuleName 'PSRule.Rules.Azure' -MockWith { + $result = [PSRule.Rules.Azure.Configuration.ResourceGroupOption]::new(); + $result.Name = 'test-rg'; + $result.Location = 'region' + $result.ManagedBy = 'testuser' + $result.Tags = @{ + test = 'true' + } + return $result; + } $exportParams = @{ TemplateFile = $templatePath ParameterFile = $parametersPath - ResourceGroupName = 'test-rg' + ResourceGroup = 'test-rg' } + + # With lookup $result = Export-AzRuleTemplateData @exportParams -PassThru; $result | Should -Not -BeNullOrEmpty; $result.Length | Should -Be 9; @@ -370,6 +391,22 @@ Describe 'Export-AzRuleTemplateData' -Tag 'Cmdlet','Export-AzRuleTemplateData' { } } + Context 'With -ResourceGroup object' { + It 'From hashtable' { + $exportParams = @{ + TemplateFile = $templatePath + ParameterFile = $parametersPath + ResourceGroup = @{ + Location = 'custom'; + } + } + $result = Export-AzRuleTemplateData @exportParams -PassThru; + $result | Should -Not -BeNullOrEmpty; + $result.Length | Should -Be 9; + $result[0].location | Should -Be 'Custom'; + } + } + Context 'With Export-AzTemplateRuleData alias' { It 'Returns warning' { $outputFile = Join-Path -Path $outputPath -ChildPath 'template-with-defaults.json' diff --git a/tests/PSRule.Rules.Azure.Tests/FunctionTests.cs b/tests/PSRule.Rules.Azure.Tests/FunctionTests.cs index 409e05e4da4..c164e7ecc73 100644 --- a/tests/PSRule.Rules.Azure.Tests/FunctionTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/FunctionTests.cs @@ -3,6 +3,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using PSRule.Rules.Azure.Configuration; using PSRule.Rules.Azure.Data.Template; using System; using System.Globalization; @@ -512,8 +513,8 @@ public void ResourceGroup() { var context = GetContext(); - var actual1 = Functions.ResourceGroup(context, null) as ResourceGroup; - Assert.Equal("{{ResourceGroup.Name}}", actual1.Name); + var actual1 = Functions.ResourceGroup(context, null) as ResourceGroupOption; + Assert.Equal("ps-rule-test-rg", actual1.Name); } [Fact] @@ -528,11 +529,11 @@ public void ResourceId() var actual4 = Functions.ResourceId(context, new object[] { "Unit.Test/type/subtype", "a", "b" }) as string; var actual5 = Functions.ResourceId(context, new object[] { "rg-test", "Unit.Test/type/subtype", "a", "b" }) as string; var actual6 = Functions.ResourceId(context, new object[] { "00000000-0000-0000-0000-000000000000", "rg-test", "Unit.Test/type/subtype", "a", "b" }) as string; - Assert.Equal("/subscriptions/{{Subscription.SubscriptionId}}/resourceGroups/{{ResourceGroup.Name}}/providers/Unit.Test/type/a", actual1); - Assert.Equal("/subscriptions/{{Subscription.SubscriptionId}}/resourceGroups/rg-test/providers/Unit.Test/type/a", actual2); + Assert.Equal("/subscriptions/ffffffff-ffff-ffff-ffff-ffffffffffff/resourceGroups/ps-rule-test-rg/providers/Unit.Test/type/a", actual1); + Assert.Equal("/subscriptions/ffffffff-ffff-ffff-ffff-ffffffffffff/resourceGroups/rg-test/providers/Unit.Test/type/a", actual2); Assert.Equal("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Unit.Test/type/a", actual3); - Assert.Equal("/subscriptions/{{Subscription.SubscriptionId}}/resourceGroups/{{ResourceGroup.Name}}/providers/Unit.Test/type/subtype/a/b", actual4); - Assert.Equal("/subscriptions/{{Subscription.SubscriptionId}}/resourceGroups/rg-test/providers/Unit.Test/type/subtype/a/b", actual5); + Assert.Equal("/subscriptions/ffffffff-ffff-ffff-ffff-ffffffffffff/resourceGroups/ps-rule-test-rg/providers/Unit.Test/type/subtype/a/b", actual4); + Assert.Equal("/subscriptions/ffffffff-ffff-ffff-ffff-ffffffffffff/resourceGroups/rg-test/providers/Unit.Test/type/subtype/a/b", actual5); Assert.Equal("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Unit.Test/type/subtype/a/b", actual6); Assert.Throws(() => Functions.ResourceId(context, null)); @@ -547,8 +548,11 @@ public void Subscription() { var context = GetContext(); - var actual1 = Functions.Subscription(context, null) as Subscription; - Assert.Equal("{{Subscription.Name}}", actual1.DisplayName); + var actual1 = Functions.Subscription(context, null) as SubscriptionOption; + Assert.Equal("ffffffff-ffff-ffff-ffff-ffffffffffff", actual1.SubscriptionId); + Assert.Equal("PSRule Test Subscription", actual1.DisplayName); + Assert.Equal("ffffffff-ffff-ffff-ffff-ffffffffffff", actual1.TenantId); + Assert.Equal("NotDefined", actual1.State); } [Fact] @@ -561,9 +565,9 @@ public void SubscriptionResourceId() var actual2 = Functions.SubscriptionResourceId(context, new object[] { "00000000-0000-0000-0000-000000000000", "Unit.Test/type", "a" }) as string; var actual3 = Functions.SubscriptionResourceId(context, new object[] { "Unit.Test/type/subtype", "a", "b" }) as string; var actual4 = Functions.SubscriptionResourceId(context, new object[] { "00000000-0000-0000-0000-000000000000", "Unit.Test/type/subtype", "a", "b" }) as string; - Assert.Equal("/subscriptions/{{Subscription.SubscriptionId}}/providers/Unit.Test/type/a", actual1); + Assert.Equal("/subscriptions/ffffffff-ffff-ffff-ffff-ffffffffffff/providers/Unit.Test/type/a", actual1); Assert.Equal("/subscriptions/00000000-0000-0000-0000-000000000000/providers/Unit.Test/type/a", actual2); - Assert.Equal("/subscriptions/{{Subscription.SubscriptionId}}/providers/Unit.Test/type/subtype/a/b", actual3); + Assert.Equal("/subscriptions/ffffffff-ffff-ffff-ffff-ffffffffffff/providers/Unit.Test/type/subtype/a/b", actual3); Assert.Equal("/subscriptions/00000000-0000-0000-0000-000000000000/providers/Unit.Test/type/subtype/a/b", actual4); Assert.Throws(() => Functions.SubscriptionResourceId(context, null)); @@ -1442,8 +1446,8 @@ public void UriComponentToString() private static TemplateContext GetContext() { var context = new TemplateContext(); - context.ResourceGroup = new ResourceGroup(); - context.Subscription = new Subscription(); + context.ResourceGroup = ResourceGroupOption.Default; + context.Subscription = SubscriptionOption.Default; context.Load(JObject.Parse("{ \"parameters\": { \"name\": { \"value\": \"abcdef\" } } }")); return context; } diff --git a/tests/PSRule.Rules.Azure.Tests/OptionsTests.cs b/tests/PSRule.Rules.Azure.Tests/OptionsTests.cs new file mode 100644 index 00000000000..e053afe648d --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/OptionsTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using PSRule.Rules.Azure.Configuration; +using System; +using System.Collections; +using System.IO; +using Xunit; + +namespace PSRule.Rules.Azure +{ + public sealed class OptionsTests + { + [Fact] + public void GetOptions() + { + var actual1 = PSRuleOption.FromFileOrDefault(null); + var actual2 = PSRuleOption.FromFileOrDefault(GetSourcePath("ps-rule-options.yaml")); + + Assert.NotNull(actual1); + Assert.Equal("PSRule Test Subscription", actual1.Configuration.Subscription.DisplayName); + + Assert.NotNull(actual2); + Assert.Equal("Unit Test Subscription", actual2.Configuration.Subscription.DisplayName); + } + + [Fact] + public void SubscriptionOption() + { + var hashtable = new Hashtable(); + hashtable["SubscriptionId"] = "00000000-0000-0000-0000-000000000000"; + hashtable["DisplayName"] = "Subscription option unit tests"; + hashtable["TenantId"] = "Test tenant"; + hashtable["State"] = "Test state"; + + var option = SubscriptionReference.FromHashtable(hashtable).ToSubscriptionOption(); + Assert.NotNull(option); + Assert.Equal(hashtable["SubscriptionId"], option.SubscriptionId); + Assert.Equal(hashtable["DisplayName"], option.DisplayName); + Assert.Equal(hashtable["TenantId"], option.TenantId); + Assert.Equal(hashtable["State"], option.State); + } + + [Fact] + public void ResourceGroupOption() + { + var hashtable = new Hashtable(); + hashtable["Name"] = "RG option unit tests"; + hashtable["Location"] = "westus"; + hashtable["ManagedBy"] = "Test managed by"; + var tags = new Hashtable(); + tags["env"] = "prod"; + hashtable["Tags"] = tags; + hashtable["ProvisioningState"] = "Test"; + + var option = ResourceGroupReference.FromHashtable(hashtable).ToResourceGroupOption(); + Assert.NotNull(option); + Assert.Equal(hashtable["Name"], option.Name); + Assert.Equal(hashtable["Location"], option.Location); + Assert.Equal(hashtable["ManagedBy"], option.ManagedBy); + Assert.Equal(tags["env"], option.Tags["env"]); + Assert.Equal(hashtable["ProvisioningState"], option.Properties.ProvisioningState); + } + + private static string GetSourcePath(string fileName) + { + return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileName); + } + } +} diff --git a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj index 5b3968b9b99..ce606a2db5c 100644 --- a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj +++ b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj @@ -34,6 +34,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/tests/PSRule.Rules.Azure.Tests/Resources.Template.json b/tests/PSRule.Rules.Azure.Tests/Resources.Template.json index 2a77537986f..a0ea681ee2c 100644 --- a/tests/PSRule.Rules.Azure.Tests/Resources.Template.json +++ b/tests/PSRule.Rules.Azure.Tests/Resources.Template.json @@ -43,6 +43,22 @@ } }, "variables": { + "subscriptionDefautTags": { + "ffffffff-ffff-ffff-ffff-ffffffffffff": { + "role": "Networking" + }, + "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn": { + "role": "Custom" + }, + "00000000-0000-0000-0000-000000000000": { + "role": "Networking" + } + }, + "rgLocation": { + "eastus": "region-A", + "region": "region-A", + "custom": "Custom" + }, "gatewaySubnet": [ { "name": "GatewaySubnet", @@ -79,7 +95,9 @@ "copy": [ { "name": "routes", - "count": "[length(variables('allSubnets'))]", + "count": "[length( + variables('allSubnets') + )]", "input": { "name": "[concat('route-', copyIndex('routes'))]", "properties": {} @@ -102,7 +120,7 @@ "type": "Microsoft.Network/virtualNetworks", "name": "[parameters('VNETName')]", "apiVersion": "2020-06-01", - "location": "region-A", + "location": "[variables('rgLocation')[resourceGroup().location]]", "dependsOn": [ "routeIndex", "nsgIndex" @@ -111,9 +129,7 @@ "addressSpace": "[variables('vnetAddressSpace')]", "subnets": "[variabLes('AllSubnets')]" }, - "tags": { - "role": "Networking" - } + "tags": "[variables('subscriptionDefautTags')[subscription().subscriptionId]]" }, { "condition": "[parameters('delegate')]", diff --git a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs index f44ef722d28..a5e42c19218 100644 --- a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs @@ -25,6 +25,8 @@ public void ResolveTemplateTest() Assert.Equal("10.1.0.0/24", actual1["properties"]["addressSpace"]["addressPrefixes"][0]); Assert.Equal(3, actual1["properties"]["subnets"].Value().Count); Assert.Equal("10.1.0.32/28", actual1["properties"]["subnets"][1]["properties"]["addressPrefix"]); + Assert.Equal("Networking", actual1["tags"]["role"].Value()); + Assert.Equal("region-A", actual1["location"].Value()); var actual2 = resources[1]; Assert.Equal("vnet-001/subnet2", actual2["name"]); diff --git a/tests/PSRule.Rules.Azure.Tests/ps-rule-options.yaml b/tests/PSRule.Rules.Azure.Tests/ps-rule-options.yaml new file mode 100644 index 00000000000..f5896766761 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/ps-rule-options.yaml @@ -0,0 +1,15 @@ +# PSRule options for unit testing + +configuration: + AZURE_SUBSCRIPTION: + subscriptionId: 'ffffffff-ffff-ffff-ffff-ffffffffffff' + tenantId: 'ffffffff-ffff-ffff-ffff-ffffffffffff' + displayName: 'Unit Test Subscription' + state: 'NotDefined' + AZURE_RESOURCE_GROUP: + name: 'ps-rule-test-rg' + location: 'eastus' + tags: + env: 'prod' + properties: + provisioningState: 'Succeeded'