From 34af39baf0120ece577f663fe1ed512b38d161a1 Mon Sep 17 00:00:00 2001 From: Guy Pritchard Date: Thu, 23 Mar 2023 16:05:05 +0000 Subject: [PATCH 1/7] Fixing nested replacement and dynamic locations --- .../alz-bicep-config/v0.13.0.config.json | 213 ++++++++++++++++-- .../Edit-ALZConfigurationFilesInPlace.ps1 | 37 ++- .../Format-TokenizedConfigurationString.ps1 | 1 + src/ALZ/Private/Write-InformationColored.ps1 | 4 +- ...dit-ALZConfigurationFilesInPlace.Tests.ps1 | 24 +- 5 files changed, 245 insertions(+), 34 deletions(-) diff --git a/src/ALZ/Assets/alz-bicep-config/v0.13.0.config.json b/src/ALZ/Assets/alz-bicep-config/v0.13.0.config.json index be475e2a..66319d41 100644 --- a/src/ALZ/Assets/alz-bicep-config/v0.13.0.config.json +++ b/src/ALZ/Assets/alz-bicep-config/v0.13.0.config.json @@ -57,19 +57,19 @@ "Description": "The prefix that will be added to all resources created by this deployment. (e.g. 'alz')", "Targets": [ { - "Name": "parTopLevelManagementGroupPrefix", + "Name": "parTopLevelManagementGroupPrefix.value", "Destination": "Parameters" }, { - "Name": "parCompanyPrefix", + "Name": "parCompanyPrefix.value", "Destination": "Parameters" }, { - "Name": "parTargetManagementGroupId", + "Name": "parTargetManagementGroupId.value", "Destination": "Parameters" }, { - "Name": "parAssignableScopeManagementGroupId", + "Name": "parAssignableScopeManagementGroupId.value", "Destination": "Parameters" } ], @@ -82,7 +82,7 @@ "Description": "The suffix that will be added to all resources created by this deployment. (e.g. 'test')", "Targets": [ { - "Name": "parTopLevelManagementGroupSuffix", + "Name": "parTopLevelManagementGroupSuffix.value", "Destination": "Parameters" } ], @@ -96,24 +96,71 @@ "Value": "", "Targets": [ { - "Name": "parLocation", + "Name": "parLocation.value", "Destination": "Parameters" }, { - "Name": "parAutomationAccountLocation", + "Name": "parAutomationAccountLocation.value", "Destination": "Parameters" }, { - "Name": "parLogAnalyticsWorkspaceLocation", + "Name": "parLogAnalyticsWorkspaceLocation.value", "Destination": "Parameters" } ], "AllowedValues": { "Display": false, - "Description": "Getting Azure deployment locations.", - "Type": "PSScript", - "Script": "Get-AzLocation | Where-Object {$_.RegionType -eq 'Physical'} | Sort-Object Location | Select-Object -ExpandProperty Location", - "Values": [] + "Values": [ + "australiacentral", + "australiacentral2", + "australiaeast", + "australiasoutheast", + "brazilsouth", + "brazilsoutheast", + "canadacentral", + "canadaeast", + "centralindia", + "centralus", + "centraluseuap", + "eastasia", + "eastus", + "eastus2", + "eastus2euap", + "eastusstg", + "francecentral", + "francesouth", + "germanynorth", + "germanywestcentral", + "japaneast", + "japanwest", + "jioindiacentral", + "jioindiawest", + "koreacentral", + "koreasouth", + "northcentralus", + "northeurope", + "norwayeast", + "norwaywest", + "qatarcentral", + "southafricanorth", + "southafricawest", + "southcentralus", + "southeastasia", + "southindia", + "swedencentral", + "switzerlandnorth", + "switzerlandwest", + "uaecentral", + "uaenorth", + "uksouth", + "ukwest", + "westcentralus", + "westeurope", + "westindia", + "westus", + "westus2", + "westus3" + ] } }, "Environment": { @@ -121,7 +168,7 @@ "Description": "The Type of environment that will be created. (e.g. 'dev', 'test', 'qa', 'staging', 'prod')", "Targets": [ { - "Name": "parEnvironment", + "Name": "parEnvironment.value", "Destination": "Parameters" } ], @@ -165,16 +212,16 @@ ], "Value": "" }, - "BillingAccountId": { + "SecurityContact": { "Type": "UserInput", - "Description": "The identifier of the Billing Account. (e.g 00000000-0000-0000-0000-000000000000)", - "Valid": "^( {){0,1}[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(}){0,1}$", - "Value": "" - }, - "EnrollmentAccountId": { - "Type": "UserInput", - "Description": "The identifier of the Enrollment Account. (e.g 00000000-0000-0000-0000-000000000000)", - "Valid": "^( {){0,1}[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(}){0,1}$", + "Description": "The email address of the contact for security issues. (e.g. security@contactme.com)", + "Valid": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "Targets": [ + { + "Name":"parPolicyAssignmentParameters.value.emailSecurityContact.value", + "Destination": "Parameters" + } + ], "Value": "" }, "LogAnalyticsResourceId": { @@ -186,6 +233,128 @@ "Destination": "Parameters" } ] + }, + "AllSubscriptionIds": { + "Type": "Computed", + "Value": [ + "{%ManagementSubscriptionId%}", + "{%ConnectivitySubscriptionId%}", + "{%IdentitySubscriptionId%}" + ], + "Targets": [ + { + "Name": "parSubscriptionIds.value", + "Destination": "Parameters" + } + ] + }, + "VirtualIdToLink": { + "Type": "Computed", + "Value": "", + "Targets": [ + { + "Name": "parVirtualNetworkIdToLink.value", + "Destination": "Parameters" + } + ] + }, + "VirtualWanName":{ + "Type": "Computed", + "Value": "alz-vwan-{%Location%}", + "Targets": [ + { + "Name": "parVirtualWanName.value", + "Destination": "Parameters" + } + ] + }, + "FirewallPoliciesName":{ + "Type": "Computed", + "Value": "alz-azfwpolicy-{%Location%}", + "Targets": [ + { + "Name": "parAzFirewallPoliciesName.value", + "Destination": "Parameters" + } + ] + }, + "PrivateDnsZones": { + "Type": "Computed", + "Value": [ + "privatelink.{%Location%}.azmk8s.io", + "privatelink.{%Location%}.batch.azure.com", + "privatelink.{%Location%}.kusto.windows.net", + "privatelink.{%Location%}.backup.windowsazure.com", + "privatelink.adf.azure.com", + "privatelink.afs.azure.net", + "privatelink.agentsvc.azure-automation.net", + "privatelink.analysis.windows.net", + "privatelink.api.azureml.ms", + "privatelink.azconfig.io", + "privatelink.azure-api.net", + "privatelink.azure-automation.net", + "privatelink.azurecr.io", + "privatelink.azure-devices.net", + "privatelink.azure-devices-provisioning.net", + "privatelink.azurehdinsight.net", + "privatelink.azurehealthcareapis.com", + "privatelink.azurestaticapps.net", + "privatelink.azuresynapse.net", + "privatelink.azurewebsites.net", + "privatelink.batch.azure.com", + "privatelink.blob.core.windows.net", + "privatelink.cassandra.cosmos.azure.com", + "privatelink.cognitiveservices.azure.com", + "privatelink.database.windows.net", + "privatelink.datafactory.azure.net", + "privatelink.dev.azuresynapse.net", + "privatelink.dfs.core.windows.net", + "privatelink.dicom.azurehealthcareapis.com", + "privatelink.digitaltwins.azure.net", + "privatelink.directline.botframework.com", + "privatelink.documents.azure.com", + "privatelink.eventgrid.azure.net", + "privatelink.file.core.windows.net", + "privatelink.gremlin.cosmos.azure.com", + "privatelink.guestconfiguration.azure.com", + "privatelink.his.arc.azure.com", + "privatelink.kubernetesconfiguration.azure.com", + "privatelink.managedhsm.azure.net", + "privatelink.mariadb.database.azure.com", + "privatelink.media.azure.net", + "privatelink.mongo.cosmos.azure.com", + "privatelink.monitor.azure.com", + "privatelink.mysql.database.azure.com", + "privatelink.notebooks.azure.net", + "privatelink.ods.opinsights.azure.com", + "privatelink.oms.opinsights.azure.com", + "privatelink.pbidedicated.windows.net", + "privatelink.postgres.database.azure.com", + "privatelink.prod.migration.windowsazure.com", + "privatelink.purview.azure.com", + "privatelink.purviewstudio.azure.com", + "privatelink.queue.core.windows.net", + "privatelink.redis.cache.windows.net", + "privatelink.redisenterprise.cache.azure.net", + "privatelink.search.windows.net", + "privatelink.service.signalr.net", + "privatelink.servicebus.windows.net", + "privatelink.siterecovery.windowsazure.com", + "privatelink.sql.azuresynapse.net", + "privatelink.table.core.windows.net", + "privatelink.table.cosmos.azure.com", + "privatelink.tip1.powerquery.microsoft.com", + "privatelink.token.botframework.com", + "privatelink.vaultcore.azure.net", + "privatelink.web.core.windows.net", + "privatelink.webpubsub.azure.com" + ], + "Targets": [ + { + "Name": "parPrivateDnsZones.value", + "Destination": "Parameters" + } + ] } } } \ No newline at end of file diff --git a/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 b/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 index 625cdd27..610f26ba 100644 --- a/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 +++ b/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 @@ -24,11 +24,42 @@ function Edit-ALZConfigurationFilesInPlace { $modified = $false foreach ($configKey in $configuration.PsObject.Properties) { foreach ($target in $configKey.Value.Targets) { - if ($target.Destination -eq "Parameters" -and $null -ne $bicepConfiguration.parameters[$target.Name]) { + + $propertyNames = $target.Name -split "\." + + $bicepConfig = $bicepConfiguration.parameters + + Write-Host $target.Name + + foreach($propertyName in $propertyNames) { + if ($propertyName -eq $propertyNames[-1]) { + continue + } + + Write-Host $propertyName + + if ($bicepConfig.ContainsKey($propertyName) -eq $false) { + $bicepConfig = $null + break + } else { + $bicepConfig = $bicepConfig[$propertyName] + } + } + + if ($target.Destination -eq "Parameters" -and $null -ne $bicepConfig) { if ($configKey.Value.Type -eq "Computed") { - $bicepConfiguration.parameters[$target.Name].value = Format-TokenizedConfigurationString $configKey.Value.Value $configuration + if ($configKey.Value.Value -is [array]) { + $formattedValues = @() + foreach($formatString in $configKey.Value.Value) { + $formattedValues += Format-TokenizedConfigurationString -tokenizedString $formatString -configuration $configuration + } + $bicepConfig[$propertyNames[-1]] = $formattedValues + } else { + $bicepConfig[$propertyNames[-1]] = Format-TokenizedConfigurationString -tokenizedString $configKey.Value.Value -configuration $configuration + } + } else { - $bicepConfiguration.parameters[$target.Name].value = $configKey.Value.Value + $bicepConfig[$propertyNames[-1]] = $configKey.Value.Value } $modified = $true } diff --git a/src/ALZ/Private/Format-TokenizedConfigurationString.ps1 b/src/ALZ/Private/Format-TokenizedConfigurationString.ps1 index bd905822..1755d8b8 100644 --- a/src/ALZ/Private/Format-TokenizedConfigurationString.ps1 +++ b/src/ALZ/Private/Format-TokenizedConfigurationString.ps1 @@ -1,5 +1,6 @@ function Format-TokenizedConfigurationString { param( + [AllowEmptyString()] [Parameter(Mandatory = $true)] [string] $tokenizedString, diff --git a/src/ALZ/Private/Write-InformationColored.ps1 b/src/ALZ/Private/Write-InformationColored.ps1 index eb72895f..3979d0e2 100644 --- a/src/ALZ/Private/Write-InformationColored.ps1 +++ b/src/ALZ/Private/Write-InformationColored.ps1 @@ -3,8 +3,8 @@ function Write-InformationColored { param( [Parameter(Mandatory)] [Object]$MessageData, - [ConsoleColor]$ForegroundColor = $Host.UI.RawUI.ForegroundColor, # Make sure we use the current colours by default - [ConsoleColor]$BackgroundColor = $Host.UI.RawUI.BackgroundColor, + [ConsoleColor]$ForegroundColor = $Host.UI.RawUI.ForegroundColor ?? "White", # Make sure we use the current colours by default + [ConsoleColor]$BackgroundColor = $Host.UI.RawUI.BackgroundColor ?? "Black", [Switch]$NoNewline ) diff --git a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 index 210dc34d..7da6dd54 100644 --- a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 +++ b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 @@ -18,11 +18,11 @@ InModuleScope 'ALZ' { Description = "The prefix that will be added to all resources created by this deployment." Targets = @( [pscustomobject]@{ - Name = "parTopLevelManagementGroupPrefix" + Name = "parTopLevelManagementGroupPrefix.value" Destination = "Parameters" }, [pscustomobject]@{ - Name = "parCompanyPrefix" + Name = "parCompanyPrefix.value" Destination = "Parameters" }) Value = "test" @@ -32,7 +32,7 @@ InModuleScope 'ALZ' { Description = "The suffix that will be added to all resources created by this deployment." Targets = @( [pscustomobject]@{ - Name = "parTopLevelManagementGroupSuffix" + Name = "parTopLevelManagementGroupSuffix.value" Destination = "Parameters" }) Value = "bla" @@ -42,7 +42,7 @@ InModuleScope 'ALZ' { Description = "Deployment location." Targets = @( [pscustomobject]@{ - Name = "parLocation" + Name = "parLocation.value" Destination = "Parameters" }) AllowedValues = @('ukwest', '') @@ -52,7 +52,7 @@ InModuleScope 'ALZ' { Description = "The type of environment that will be created . Example: dev, test, qa, staging, prod" Targets = @( [pscustomobject]@{ - Name = "parEnvironment" + Name = "parEnvironment.value" Destination = "Parameters" }) DefaultValue = 'prod' @@ -64,7 +64,17 @@ InModuleScope 'ALZ' { Value = "logs/{%Environment%}/{%Location%}" Targets = @( [pscustomobject]@{ - Name = "parLogging" + Name = "parLogging.value" + Destination = "Parameters" + }) + } + Nested = [pscustomobject]@{ + Type = "Computed" + Description = "A nested value" + Value = "nested" + Targets = @( + [pscustomobject]@{ + Name = "parNested.value.parChildValue" Destination = "Parameters" }) } @@ -79,7 +89,7 @@ InModuleScope 'ALZ' { }, "parLogging" : { "value": "" - } + }, } }' $secondFileContent = '{ From 8077ae15cbc369db9777c060cc02482592e0f861 Mon Sep 17 00:00:00 2001 From: Guy Pritchard Date: Thu, 23 Mar 2023 17:37:27 +0000 Subject: [PATCH 2/7] Supporting arrays of objects... --- .../alz-bicep-config/v0.14.0-pre.config.json | 10 +- .../Edit-ALZConfigurationFilesInPlace.ps1 | 28 ++-- src/ALZ/Public/New-ALZEnvironment.ps1 | 2 - ...dit-ALZConfigurationFilesInPlace.Tests.ps1 | 124 +++++++++++++++--- .../Unit/Public/New-ALZEnvironment.Tests.ps1 | 2 +- 5 files changed, 129 insertions(+), 37 deletions(-) diff --git a/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json b/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json index 3dab6102..0a6b0306 100644 --- a/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json +++ b/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json @@ -187,6 +187,14 @@ "Name": "parLogAnalyticsWorkspaceLocation.value", "Destination": "Parameters" }, + { + "Name": "parPolicyAssignmentParameters.value.ascExportResourceGroupLocation.value", + "Destination": "Parameters" + }, + { + "Name": "parVirtualWanHubs.value.0.parHubLocation", + "Destination": "Parameters" + }, { "Name": "LOCATION", "Destination": "Environment" @@ -317,7 +325,7 @@ "Value": "/subscriptions/{%ManagementSubscriptionId%}/resourcegroups/alz-logging/providers/microsoft.operationalinsights/workspaces/alz-log-analytics", "Targets": [ { - "Name": "parLogAnalyticsWorkspaceResourceId", + "Name": "parLogAnalyticsWorkspaceResourceId.value", "Destination": "Parameters" } ] diff --git a/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 b/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 index 610f26ba..52ed0921 100644 --- a/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 +++ b/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 @@ -11,12 +11,12 @@ function Edit-ALZConfigurationFilesInPlace { [object] $configuration ) - $locations = @("orchestration", "config", "customization") + $locations = @("config") $files = @() foreach ($location in $locations) { $bicepModules = Join-Path $alzEnvironmentDestination $location - $files += @(Get-ChildItem -Path $bicepModules -Recurse -Filter *.parameters.json) + $files += @(Get-ChildItem -Path $bicepModules -Recurse -Filter *.parameters.*.json) } foreach ($file in $files) { @@ -26,26 +26,25 @@ function Edit-ALZConfigurationFilesInPlace { foreach ($target in $configKey.Value.Targets) { $propertyNames = $target.Name -split "\." - $bicepConfig = $bicepConfiguration.parameters - Write-Host $target.Name - - foreach($propertyName in $propertyNames) { - if ($propertyName -eq $propertyNames[-1]) { - continue - } - - Write-Host $propertyName - - if ($bicepConfig.ContainsKey($propertyName) -eq $false) { + for ($index=0; $index -lt $propertyNames.Length - 1; $index++) { + if ($bicepConfig -is [array]) { + # If this is an array - use the property as an array index... + # This is probably a bit weird... + $bicepConfig = $bicepConfig[$propertyNames[$index]] + } elseif ($bicepConfig.ContainsKey($propertyNames[$index]) -eq $false) { + # This property doesn't exist at this level in the hierarchy, + # this isn't the property we're looking for, stop looking. $bicepConfig = $null break } else { - $bicepConfig = $bicepConfig[$propertyName] + # We found the item, keep indexing into the object. + $bicepConfig = $bicepConfig[$propertyNames[$index]] } } + # If we're here, we've got the object at the bottom of the hierarchy - and we can modify values on it. if ($target.Destination -eq "Parameters" -and $null -ne $bicepConfig) { if ($configKey.Value.Type -eq "Computed") { if ($configKey.Value.Value -is [array]) { @@ -57,7 +56,6 @@ function Edit-ALZConfigurationFilesInPlace { } else { $bicepConfig[$propertyNames[-1]] = Format-TokenizedConfigurationString -tokenizedString $configKey.Value.Value -configuration $configuration } - } else { $bicepConfig[$propertyNames[-1]] = $configKey.Value.Value } diff --git a/src/ALZ/Public/New-ALZEnvironment.ps1 b/src/ALZ/Public/New-ALZEnvironment.ps1 index 7baa9599..348e7e46 100644 --- a/src/ALZ/Public/New-ALZEnvironment.ps1 +++ b/src/ALZ/Public/New-ALZEnvironment.ps1 @@ -67,6 +67,4 @@ function New-ALZEnvironment { Write-InformationColored "The directory $alzEnvironmentDestination is not a git repository. Please make it is a git repo after initialization." -ForegroundColor Red -InformationAction Continue } } - - return $true } \ No newline at end of file diff --git a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 index 7da6dd54..26f141c4 100644 --- a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 +++ b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 @@ -74,7 +74,7 @@ InModuleScope 'ALZ' { Value = "nested" Targets = @( [pscustomobject]@{ - Name = "parNested.value.parChildValue" + Name = "parNested.value.parChildValue.value" Destination = "Parameters" }) } @@ -90,6 +90,13 @@ InModuleScope 'ALZ' { "parLogging" : { "value": "" }, + "parNested": { + "value": { + "parChildValue": { + "value": "replace_me" + } + } + } } }' $secondFileContent = '{ @@ -110,7 +117,7 @@ InModuleScope 'ALZ' { } Context 'Edit-ALZConfigurationFilesInPlace should replace the parameters correctly' { BeforeEach { - Mock -CommandName Get-ChildItem -ParameterFilter { $Path -match 'orchestration$' } -MockWith { + Mock -CommandName Get-ChildItem -ParameterFilter { $Path -match 'config$' } -MockWith { @( [PSCustomObject]@{ FullName = 'test1.parameters.json' @@ -131,27 +138,108 @@ InModuleScope 'ALZ' { Write-InformationColored "Out-File was called with $FilePath and $InputObject" -ForegroundColor Yellow -InformationAction Continue } } - It 'Files should be changed correctly' { + It "Should handle nested array objects correctly" { + $defaultConfig = [pscustomobject]@{ + Nested = [pscustomobject]@{ + Type = "Computed" + Description = "A nested value" + Value = "nested" + Targets = @( + [pscustomobject]@{ + Name = "parNested.value.0.parChildValue.value" + Destination = "Parameters" + }) + } + } + + $firstFileContent = '{ + "parameters": { + "parNested": { + "value": [{ + "parChildValue": { + "value": "replace_me" + } + }] + } + } + }' + + Mock -CommandName Get-ChildItem -ParameterFilter { $Path -match 'config$' } -MockWith { + @( + [PSCustomObject]@{ + FullName = 'test1.parameters.json' + } + ) + } + Mock -CommandName Get-Content -ParameterFilter { $Path -eq 'test1.parameters.json' } -MockWith { + $firstFileContent + } + Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $defaultConfig + } - Should -Invoke -CommandName Out-File -Scope It -Times 2 + It 'Should handle nested values correctly' { + $defaultConfig = [pscustomobject]@{ + Nested = [pscustomobject]@{ + Type = "Computed" + Description = "A nested value" + Value = "nested" + Targets = @( + [pscustomobject]@{ + Name = "parNested.value.parChildValue.value" + Destination = "Parameters" + }) + } + } + $firstFileContent = '{ + "parameters": { + "parNested": { + "value": { + "parChildValue": { + "value": "replace_me" + } + } + } + } + }' - # Assert that the file was written back with the new values - $contentAfterParsing = ConvertFrom-Json -InputObject $firstFileContent -AsHashtable - $contentAfterParsing.parameters.parTopLevelManagementGroupPrefix.value = 'test' - $contentAfterParsing.parameters.parCompanyPrefix.value = 'test' - $contentAfterParsing.parameters.parLogging.value = "logs/dev/eastus" - $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing - Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue - Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test1.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It + Mock -CommandName Get-ChildItem -ParameterFilter { $Path -match 'config$' } -MockWith { + @( + [PSCustomObject]@{ + FullName = 'test1.parameters.json' + } + ) + } + Mock -CommandName Get-Content -ParameterFilter { $Path -eq 'test1.parameters.json' } -MockWith { + $firstFileContent + } + + Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $defaultConfig - $contentAfterParsing = ConvertFrom-Json -InputObject $secondFileContent -AsHashtable - $contentAfterParsing.parameters.parTopLevelManagementGroupSuffix.value = 'bla' - $contentAfterParsing.parameters.parLocation.value = 'eastus' - $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing - Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue - Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test2.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It } + + # It 'Files should be changed correctly' { + # Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $defaultConfig + + # Should -Invoke -CommandName Out-File -Scope It -Times 2 + + # # Assert that the file was written back with the new values + # $contentAfterParsing = ConvertFrom-Json -InputObject $firstFileContent -AsHashtable + # $contentAfterParsing.parameters.parTopLevelManagementGroupPrefix.value = 'test' + # $contentAfterParsing.parameters.parCompanyPrefix.value = 'test' + # $contentAfterParsing.parameters.parLogging.value = "logs/dev/eastus" + # # $contentAfterParsing.parameters.parNested.value.parChildValue.value = "nested" + # $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing + # Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue + # Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test1.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It + + # $contentAfterParsing = ConvertFrom-Json -InputObject $secondFileContent -AsHashtable + # $contentAfterParsing.parameters.parTopLevelManagementGroupSuffix.value = 'bla' + # $contentAfterParsing.parameters.parLocation.value = 'eastus' + # $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing + # Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue + # Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test2.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It + # } } } } diff --git a/src/Tests/Unit/Public/New-ALZEnvironment.Tests.ps1 b/src/Tests/Unit/Public/New-ALZEnvironment.Tests.ps1 index e2a49601..d568f268 100644 --- a/src/Tests/Unit/Public/New-ALZEnvironment.Tests.ps1 +++ b/src/Tests/Unit/Public/New-ALZEnvironment.Tests.ps1 @@ -75,7 +75,7 @@ InModuleScope 'ALZ' { It 'should return the output directory on completion' { $result = New-ALZEnvironment - $result | Should -Be $true + $result | Should -Be $null Assert-MockCalled -CommandName Edit-ALZConfigurationFilesInPlace -Exactly 1 } From 339d22ed25de4201fa4120b3a6950c1b1eaf6296 Mon Sep 17 00:00:00 2001 From: Guy Pritchard Date: Thu, 23 Mar 2023 17:50:46 +0000 Subject: [PATCH 3/7] Fixing environment tags --- .../alz-bicep-config/v0.14.0-pre.config.json | 4 ++ ...dit-ALZConfigurationFilesInPlace.Tests.ps1 | 41 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json b/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json index 0a6b0306..83d5b082 100644 --- a/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json +++ b/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json @@ -262,6 +262,10 @@ { "Name": "parEnvironment.value", "Destination": "Parameters" + }, + { + "Name": "parTags.value.Environment", + "Destination": "Parameters" } ], "Value": "", diff --git a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 index 26f141c4..d5a84f00 100644 --- a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 +++ b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 @@ -178,7 +178,46 @@ InModuleScope 'ALZ' { Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $defaultConfig } - It 'Should handle nested values correctly' { + + It 'Should handle nested values correctly 1' { + $defaultConfig = [pscustomobject]@{ + Nested = [pscustomobject]@{ + Type = "Computed" + Description = "A nested value" + Value = "nested" + Targets = @( + [pscustomobject]@{ + Name = "parNested.value.parChildValue" + Destination = "Parameters" + }) + } + } + $firstFileContent = '{ + "parameters": { + "parNested": { + "value": { + "parChildValue": "replace_me" + } + } + } + }' + + Mock -CommandName Get-ChildItem -ParameterFilter { $Path -match 'config$' } -MockWith { + @( + [PSCustomObject]@{ + FullName = 'test1.parameters.json' + } + ) + } + Mock -CommandName Get-Content -ParameterFilter { $Path -eq 'test1.parameters.json' } -MockWith { + $firstFileContent + } + + Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $defaultConfig + + } + + It 'Should handle nested values correctly 2' { $defaultConfig = [pscustomobject]@{ Nested = [pscustomobject]@{ Type = "Computed" From f799875a2bf84cdaa1764ceb41a5acea76d1d3f7 Mon Sep 17 00:00:00 2001 From: Guy Pritchard Date: Fri, 24 Mar 2023 14:37:59 +0000 Subject: [PATCH 4/7] Adding tests and refactoring --- .../alz-bicep-config/v0.14.0-pre.config.json | 12 +- .../Edit-ALZConfigurationFilesInPlace.ps1 | 9 +- ...dit-ALZConfigurationFilesInPlace.Tests.ps1 | 467 +++++++++++------- 3 files changed, 288 insertions(+), 200 deletions(-) diff --git a/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json b/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json index 83d5b082..73c2a975 100644 --- a/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json +++ b/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json @@ -192,7 +192,7 @@ "Destination": "Parameters" }, { - "Name": "parVirtualWanHubs.value.0.parHubLocation", + "Name": "parVirtualWanHubs.value.[0].parHubLocation", "Destination": "Parameters" }, { @@ -277,8 +277,8 @@ "Description": "The identifier of the Identity Subscription. (e.g '00000000-0000-0000-0000-000000000000')", "Valid": "^( {){0,1}[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(}){0,1}$", "Targets": [ - { - "Name": "IdentitySubscriptionId", + { + "Name": "IDENTITY_SUBSCRIPTION_ID", "Destination": "Environment" } ], @@ -289,10 +289,6 @@ "Description": "The identifier of the Connectivity Subscription. (e.g '00000000-0000-0000-0000-000000000000')", "Valid": "^( {){0,1}[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(}){0,1}$", "Targets": [ - { - "Name": "ConnectivitySubscriptionId", - "Destination": "Environment" - }, { "Name": "CONNECTIVITY_SUBSCRIPTION_ID", "Destination": "Environment" @@ -306,7 +302,7 @@ "Valid": "^( {){0,1}[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(}){0,1}$", "Targets": [ { - "Name": "ManagementSubscriptionId", + "Name": "MANAGEMENT_SUBSCRIPTION_ID", "Destination": "Environment" } ], diff --git a/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 b/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 index 52ed0921..fcb1a9af 100644 --- a/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 +++ b/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 @@ -25,14 +25,19 @@ function Edit-ALZConfigurationFilesInPlace { foreach ($configKey in $configuration.PsObject.Properties) { foreach ($target in $configKey.Value.Targets) { + # Find the appropriate item which will be changed in the Bicep file. $propertyNames = $target.Name -split "\." $bicepConfig = $bicepConfiguration.parameters for ($index=0; $index -lt $propertyNames.Length - 1; $index++) { if ($bicepConfig -is [array]) { # If this is an array - use the property as an array index... - # This is probably a bit weird... - $bicepConfig = $bicepConfig[$propertyNames[$index]] + $arrayIndex = $propertyNames[$index] + if ($arrayIndex -match "\[[0-9]+\]") { + $arrayIndex = $arrayIndex -replace "\[|\]","" + } + + $bicepConfig = $bicepConfig[$arrayIndex] } elseif ($bicepConfig.ContainsKey($propertyNames[$index]) -eq $false) { # This property doesn't exist at this level in the hierarchy, # this isn't the property we're looking for, stop looking. diff --git a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 index d5a84f00..2d722708 100644 --- a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 +++ b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 @@ -13,102 +13,52 @@ Import-Module $PathToManifest -Force InModuleScope 'ALZ' { BeforeAll { - $defaultConfig = [pscustomobject]@{ - Prefix = [pscustomobject]@{ - Description = "The prefix that will be added to all resources created by this deployment." - Targets = @( - [pscustomobject]@{ - Name = "parTopLevelManagementGroupPrefix.value" - Destination = "Parameters" - }, - [pscustomobject]@{ - Name = "parCompanyPrefix.value" - Destination = "Parameters" - }) - Value = "test" - DefaultValue = "alz" - } - Suffix = [pscustomobject]@{ - Description = "The suffix that will be added to all resources created by this deployment." - Targets = @( - [pscustomobject]@{ - Name = "parTopLevelManagementGroupSuffix.value" - Destination = "Parameters" - }) - Value = "bla" - DefaultValue = "" - } - Location = [pscustomobject]@{ - Description = "Deployment location." - Targets = @( - [pscustomobject]@{ - Name = "parLocation.value" - Destination = "Parameters" - }) - AllowedValues = @('ukwest', '') - Value = "eastus" - } - Environment = [pscustomobject]@{ - Description = "The type of environment that will be created . Example: dev, test, qa, staging, prod" - Targets = @( - [pscustomobject]@{ - Name = "parEnvironment.value" - Destination = "Parameters" - }) - DefaultValue = 'prod' - Value = "dev" - } - Logging = [pscustomobject]@{ - Type = "Computed" - Description = "The type of environment that will be created . Example: dev, test, qa, staging, prod" - Value = "logs/{%Environment%}/{%Location%}" - Targets = @( - [pscustomobject]@{ - Name = "parLogging.value" - Destination = "Parameters" - }) - } - Nested = [pscustomobject]@{ - Type = "Computed" - Description = "A nested value" - Value = "nested" - Targets = @( - [pscustomobject]@{ - Name = "parNested.value.parChildValue.value" - Destination = "Parameters" - }) - } + $testFile1Name = "test.parameters.all.json" + + Mock -CommandName Out-File -MockWith { + Write-InformationColored "Out-File was called with $FilePath and $InputObject" -ForegroundColor Yellow -InformationAction Continue } - $firstFileContent = '{ - "parameters": { - "parCompanyPrefix": { - "value": "" - }, - "parTopLevelManagementGroupPrefix": { - "value": "" - }, - "parLogging" : { - "value": "" - }, - "parNested": { - "value": { - "parChildValue": { - "value": "replace_me" - } - } + + Mock -CommandName Get-ChildItem -ParameterFilter { $Path -match 'config$' } -MockWith { + @( + [PSCustomObject]@{ + FullName = $testFile1Name } - } - }' - $secondFileContent = '{ - "parameters": { - "parTopLevelManagementGroupSuffix": { - "value": "" - }, - "parLocation": { - "value": "" + ) + } + + function Initialize-TestConfiguration { + param( + [Parameter(Mandatory = $true)] + [string]$configTarget, + + [Parameter(Mandatory = $true)] + [string]$withValue + ) + + return [pscustomobject]@{ + Nested = [pscustomobject]@{ + Type = "Computed" + Description = "A Test Value" + Value = $withValue + Targets = @( + [pscustomobject]@{ + Name = $configTarget + Destination = "Parameters" + }) + } } - } - }' + } + + function Format-ExpectedResult { + param( + [Parameter(Mandatory = $true)] + [string]$expectedJson + ) + + # Get the formatting correct by using the same JSON formatter. + return ConvertFrom-Json -InputObject $expectedJson -AsHashtable | ConvertTo-Json -Depth 10 + } } Describe 'Edit-ALZConfigurationFilesInPlace Function Tests' -Tag Unit { BeforeAll { @@ -116,43 +66,75 @@ InModuleScope 'ALZ' { $ErrorActionPreference = 'SilentlyContinue' } Context 'Edit-ALZConfigurationFilesInPlace should replace the parameters correctly' { - BeforeEach { - Mock -CommandName Get-ChildItem -ParameterFilter { $Path -match 'config$' } -MockWith { - @( - [PSCustomObject]@{ - FullName = 'test1.parameters.json' - }, - [PSCustomObject]@{ - FullName = 'test2.parameters.json' + + # It 'Should replace array values correctly (JSON Object)' { + # $config = Initialize-TestConfiguration -configTarget "parValue.value.[0]" -withValue "value" + + # $fileContent = '{ + # "parameters": { + # "parValue": { + # "value": ["replace_me", "dont_replace_me"] + # } + # } + # }' + + # $expectedContent = '{ + # "parameters": { + # "parValue": { + # "value": ["value", , "dont_replace_me"] + # } + # } + # }' + + # Mock -CommandName Get-Content -ParameterFilter { $Path -eq $testFile1Name } -MockWith { + # $fileContent + # } + + # $expectedContent = Format-ExpectedResult -expectedJson $expectedContent + + # Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $config + + # Should -Invoke -CommandName Out-File ` + # -ParameterFilter { $FilePath -eq $testFile1Name -and $InputObject -eq $expectedContent } ` + # -Scope It + # } + + It 'Should replace simple values correctly (Bicep Object)' { + $config = Initialize-TestConfiguration -configTarget "parValue.value" -withValue "value" + + $fileContent = '{ + "parameters": { + "parValue": { + "value": "replace_me" } - ) - } - Mock -CommandName Get-Content -ParameterFilter { $Path -eq 'test1.parameters.json' } -MockWith { - $firstFileContent - } - Mock -CommandName Get-Content -ParameterFilter { $Path -eq 'test2.parameters.json' } -MockWith { - $secondFileContent - } + } + }' - Mock -CommandName Out-File -MockWith { - Write-InformationColored "Out-File was called with $FilePath and $InputObject" -ForegroundColor Yellow -InformationAction Continue - } - } - It "Should handle nested array objects correctly" { - $defaultConfig = [pscustomobject]@{ - Nested = [pscustomobject]@{ - Type = "Computed" - Description = "A nested value" - Value = "nested" - Targets = @( - [pscustomobject]@{ - Name = "parNested.value.0.parChildValue.value" - Destination = "Parameters" - }) + $expectedContent = '{ + "parameters": { + "parValue": { + "value": "value" + } } + }' + + Mock -CommandName Get-Content -ParameterFilter { $Path -eq $testFile1Name } -MockWith { + $fileContent } - $firstFileContent = '{ + $expectedContent = Format-ExpectedResult -expectedJson $expectedContent + + Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $config + + Should -Invoke -CommandName Out-File ` + -ParameterFilter { $FilePath -eq $testFile1Name -and $InputObject -eq $expectedContent } ` + -Scope It + } + + It "Should replace 'Parameter' destinations to nested array objects correctly" { + $config = Initialize-TestConfiguration -configTarget "parNested.value.[0].parChildValue.value" -withValue "nested" + + $fileContent = '{ "parameters": { "parNested": { "value": [{ @@ -164,35 +146,35 @@ InModuleScope 'ALZ' { } }' - Mock -CommandName Get-ChildItem -ParameterFilter { $Path -match 'config$' } -MockWith { - @( - [PSCustomObject]@{ - FullName = 'test1.parameters.json' + $expectedContent = '{ + "parameters": { + "parNested": { + "value": [{ + "parChildValue": { + "value": "nested" + } + }] } - ) - } - Mock -CommandName Get-Content -ParameterFilter { $Path -eq 'test1.parameters.json' } -MockWith { - $firstFileContent + } + }' + + Mock -CommandName Get-Content -ParameterFilter { $Path -eq $testFile1Name } -MockWith { + $fileContent } - Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $defaultConfig + $expectedContent = Format-ExpectedResult -expectedJson $expectedContent + + Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $config + + Should -Invoke -CommandName Out-File ` + -ParameterFilter { $FilePath -eq $testFile1Name -and $InputObject -eq $expectedContent } ` + -Scope It } + It 'Should replace nested values correctly (Plain JSON Object)' { + $config = Initialize-TestConfiguration -configTarget "parNested.value.parChildValue" -withValue "nested" - It 'Should handle nested values correctly 1' { - $defaultConfig = [pscustomobject]@{ - Nested = [pscustomobject]@{ - Type = "Computed" - Description = "A nested value" - Value = "nested" - Targets = @( - [pscustomobject]@{ - Name = "parNested.value.parChildValue" - Destination = "Parameters" - }) - } - } - $firstFileContent = '{ + $fileContent = '{ "parameters": { "parNested": { "value": { @@ -202,42 +184,146 @@ InModuleScope 'ALZ' { } }' - Mock -CommandName Get-ChildItem -ParameterFilter { $Path -match 'config$' } -MockWith { - @( - [PSCustomObject]@{ - FullName = 'test1.parameters.json' + $expectedContent = '{ + "parameters": { + "parNested": { + "value": { + "parChildValue": "nested" + } } - ) + } + }' + + Mock -CommandName Get-Content -ParameterFilter { $Path -eq $testFile1Name } -MockWith { + $fileContent } - Mock -CommandName Get-Content -ParameterFilter { $Path -eq 'test1.parameters.json' } -MockWith { - $firstFileContent + + $expectedContent = Format-ExpectedResult -expectedJson $expectedContent + + Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $config + + Should -Invoke -CommandName Out-File ` + -ParameterFilter { $FilePath -eq $testFile1Name -and $InputObject -eq $expectedContent } ` + -Scope It + } + + It 'Should replace nested values correctly (Bicep Object)' { + $config = Initialize-TestConfiguration -configTarget "parNested.value.parChildValue.value" -withValue "nested" + + $fileContent = '{ + "parameters": { + "parNested": { + "value": { + "parChildValue": { + "value": "replace_me" + } + } + } + } + }' + + $expectedContent = '{ + "parameters": { + "parNested": { + "value": { + "parChildValue": { + "value": "nested" + } + } + } + } + }' + + Mock -CommandName Get-Content -ParameterFilter { $Path -eq $testFile1Name } -MockWith { + $fileContent } - Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $defaultConfig + $expectedContent = Format-ExpectedResult -expectedJson $expectedContent + + Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $config + Should -Invoke -CommandName Out-File ` + -ParameterFilter { $FilePath -eq $testFile1Name -and $InputObject -eq $expectedContent } ` + -Scope It } - It 'Should handle nested values correctly 2' { + It 'Multiple files with multiple values should be changed correctly' { $defaultConfig = [pscustomobject]@{ - Nested = [pscustomobject]@{ + Prefix = [pscustomobject]@{ + Description = "The prefix that will be added to all resources created by this deployment." + Targets = @( + [pscustomobject]@{ + Name = "parTopLevelManagementGroupPrefix.value" + Destination = "Parameters" + }, + [pscustomobject]@{ + Name = "parCompanyPrefix.value" + Destination = "Parameters" + }) + Value = "test" + DefaultValue = "alz" + } + Suffix = [pscustomobject]@{ + Description = "The suffix that will be added to all resources created by this deployment." + Targets = @( + [pscustomobject]@{ + Name = "parTopLevelManagementGroupSuffix.value" + Destination = "Parameters" + }) + Value = "bla" + DefaultValue = "" + } + Location = [pscustomobject]@{ + Description = "Deployment location." + Targets = @( + [pscustomobject]@{ + Name = "parLocation.value" + Destination = "Parameters" + }) + AllowedValues = @('ukwest', '') + Value = "eastus" + } + Environment = [pscustomobject]@{ + Description = "The type of environment that will be created . Example: dev, test, qa, staging, prod" + Targets = @( + [pscustomobject]@{ + Name = "parEnvironment.value" + Destination = "Parameters" + }) + DefaultValue = 'prod' + Value = "dev" + } + Logging = [pscustomobject]@{ Type = "Computed" - Description = "A nested value" - Value = "nested" + Description = "The type of environment that will be created . Example: dev, test, qa, staging, prod" + Value = "logs/{%Environment%}/{%Location%}" Targets = @( [pscustomobject]@{ - Name = "parNested.value.parChildValue.value" + Name = "parLogging.value" Destination = "Parameters" }) } } $firstFileContent = '{ "parameters": { - "parNested": { - "value": { - "parChildValue": { - "value": "replace_me" - } - } + "parCompanyPrefix": { + "value": "" + }, + "parTopLevelManagementGroupPrefix": { + "value": "" + }, + "parLogging" : { + "value": "" + } + } + }' + $secondFileContent = '{ + "parameters": { + "parTopLevelManagementGroupSuffix": { + "value": "" + }, + "parLocation": { + "value": "" } } }' @@ -246,39 +332,40 @@ InModuleScope 'ALZ' { @( [PSCustomObject]@{ FullName = 'test1.parameters.json' + }, + [PSCustomObject]@{ + FullName = 'test2.parameters.json' } ) } Mock -CommandName Get-Content -ParameterFilter { $Path -eq 'test1.parameters.json' } -MockWith { $firstFileContent } + Mock -CommandName Get-Content -ParameterFilter { $Path -eq 'test2.parameters.json' } -MockWith { + $secondFileContent + } Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $defaultConfig - } + Should -Invoke -CommandName Out-File -Scope It -Times 2 - # It 'Files should be changed correctly' { - # Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $defaultConfig - - # Should -Invoke -CommandName Out-File -Scope It -Times 2 - - # # Assert that the file was written back with the new values - # $contentAfterParsing = ConvertFrom-Json -InputObject $firstFileContent -AsHashtable - # $contentAfterParsing.parameters.parTopLevelManagementGroupPrefix.value = 'test' - # $contentAfterParsing.parameters.parCompanyPrefix.value = 'test' - # $contentAfterParsing.parameters.parLogging.value = "logs/dev/eastus" - # # $contentAfterParsing.parameters.parNested.value.parChildValue.value = "nested" - # $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing - # Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue - # Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test1.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It - - # $contentAfterParsing = ConvertFrom-Json -InputObject $secondFileContent -AsHashtable - # $contentAfterParsing.parameters.parTopLevelManagementGroupSuffix.value = 'bla' - # $contentAfterParsing.parameters.parLocation.value = 'eastus' - # $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing - # Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue - # Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test2.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It - # } + # Assert that the file was written back with the new values + $contentAfterParsing = ConvertFrom-Json -InputObject $firstFileContent -AsHashtable + $contentAfterParsing.parameters.parTopLevelManagementGroupPrefix.value = 'test' + $contentAfterParsing.parameters.parCompanyPrefix.value = 'test' + $contentAfterParsing.parameters.parLogging.value = "logs/dev/eastus" + # $contentAfterParsing.parameters.parNested.value.parChildValue.value = "nested" + $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing + Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue + Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test1.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It + + $contentAfterParsing = ConvertFrom-Json -InputObject $secondFileContent -AsHashtable + $contentAfterParsing.parameters.parTopLevelManagementGroupSuffix.value = 'bla' + $contentAfterParsing.parameters.parLocation.value = 'eastus' + $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing + Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue + Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test2.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It + } } } } From e8955dc79b082f2a5239170f136238b7c172469d Mon Sep 17 00:00:00 2001 From: Guy Pritchard Date: Fri, 24 Mar 2023 17:16:00 +0000 Subject: [PATCH 5/7] Adding more test cases and reducing the amount of configuration by using array indexes --- .../alz-bicep-config/v0.14.0-pre.config.json | 104 ++++------- .../Edit-ALZConfigurationFilesInPlace.ps1 | 49 +++-- ...dit-ALZConfigurationFilesInPlace.Tests.ps1 | 176 +++++++++++++++--- 3 files changed, 208 insertions(+), 121 deletions(-) diff --git a/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json b/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json index 73c2a975..17f18cbc 100644 --- a/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json +++ b/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json @@ -374,80 +374,42 @@ } ] }, - "PrivateDnsZones": { + "AK8sPrivateLink": { "Type": "Computed", - "Value": [ - "privatelink.{%Location%}.azmk8s.io", - "privatelink.{%Location%}.batch.azure.com", - "privatelink.{%Location%}.kusto.windows.net", - "privatelink.{%Location%}.backup.windowsazure.com", - "privatelink.adf.azure.com", - "privatelink.afs.azure.net", - "privatelink.agentsvc.azure-automation.net", - "privatelink.analysis.windows.net", - "privatelink.api.azureml.ms", - "privatelink.azconfig.io", - "privatelink.azure-api.net", - "privatelink.azure-automation.net", - "privatelink.azurecr.io", - "privatelink.azure-devices.net", - "privatelink.azure-devices-provisioning.net", - "privatelink.azurehdinsight.net", - "privatelink.azurehealthcareapis.com", - "privatelink.azurestaticapps.net", - "privatelink.azuresynapse.net", - "privatelink.azurewebsites.net", - "privatelink.batch.azure.com", - "privatelink.blob.core.windows.net", - "privatelink.cassandra.cosmos.azure.com", - "privatelink.cognitiveservices.azure.com", - "privatelink.database.windows.net", - "privatelink.datafactory.azure.net", - "privatelink.dev.azuresynapse.net", - "privatelink.dfs.core.windows.net", - "privatelink.dicom.azurehealthcareapis.com", - "privatelink.digitaltwins.azure.net", - "privatelink.directline.botframework.com", - "privatelink.documents.azure.com", - "privatelink.eventgrid.azure.net", - "privatelink.file.core.windows.net", - "privatelink.gremlin.cosmos.azure.com", - "privatelink.guestconfiguration.azure.com", - "privatelink.his.arc.azure.com", - "privatelink.kubernetesconfiguration.azure.com", - "privatelink.managedhsm.azure.net", - "privatelink.mariadb.database.azure.com", - "privatelink.media.azure.net", - "privatelink.mongo.cosmos.azure.com", - "privatelink.monitor.azure.com", - "privatelink.mysql.database.azure.com", - "privatelink.notebooks.azure.net", - "privatelink.ods.opinsights.azure.com", - "privatelink.oms.opinsights.azure.com", - "privatelink.pbidedicated.windows.net", - "privatelink.postgres.database.azure.com", - "privatelink.prod.migration.windowsazure.com", - "privatelink.purview.azure.com", - "privatelink.purviewstudio.azure.com", - "privatelink.queue.core.windows.net", - "privatelink.redis.cache.windows.net", - "privatelink.redisenterprise.cache.azure.net", - "privatelink.search.windows.net", - "privatelink.service.signalr.net", - "privatelink.servicebus.windows.net", - "privatelink.siterecovery.windowsazure.com", - "privatelink.sql.azuresynapse.net", - "privatelink.table.core.windows.net", - "privatelink.table.cosmos.azure.com", - "privatelink.tip1.powerquery.microsoft.com", - "privatelink.token.botframework.com", - "privatelink.vaultcore.azure.net", - "privatelink.web.core.windows.net", - "privatelink.webpubsub.azure.com" - ], + "Value": "privatelink.{%Location%}.azmk8s.io", + "Targets": [ + { + "Name": "parPrivateDnsZones.value.[0]", + "Destination": "Parameters" + } + ] + }, + "BatchPrivateLink": { + "Type": "Computed", + "Value": "privatelink.{%Location%}.batch.azure.com", + "Targets": [ + { + "Name": "parPrivateDnsZones.value.[1]", + "Destination": "Parameters" + } + ] + }, + "KustoPrivateLink": { + "Type": "Computed", + "Value": "privatelink.{%Location%}.kusto.windows.net", + "Targets": [ + { + "Name": "parPrivateDnsZones.value.[2]", + "Destination": "Parameters" + } + ] + }, + "BackupPrivateLink": { + "Type": "Computed", + "Value": "privatelink.{%Location%}.backup.windowsazure.com", "Targets": [ { - "Name": "parPrivateDnsZones.value", + "Name": "parPrivateDnsZones.value.[3]", "Destination": "Parameters" } ] diff --git a/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 b/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 index fcb1a9af..5f64ca6e 100644 --- a/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 +++ b/src/ALZ/Private/Edit-ALZConfigurationFilesInPlace.ps1 @@ -22,48 +22,59 @@ function Edit-ALZConfigurationFilesInPlace { foreach ($file in $files) { $bicepConfiguration = Get-Content $file.FullName | ConvertFrom-Json -AsHashtable $modified = $false + foreach ($configKey in $configuration.PsObject.Properties) { foreach ($target in $configKey.Value.Targets) { # Find the appropriate item which will be changed in the Bicep file. - $propertyNames = $target.Name -split "\." - $bicepConfig = $bicepConfiguration.parameters + # Remove array '[' ']' characters so we can use the index value direct. + $propertyNames = $target.Name -replace "\[|\]","" -split "\." + $bicepConfigNode = $bicepConfiguration.parameters + $index = 0 - for ($index=0; $index -lt $propertyNames.Length - 1; $index++) { - if ($bicepConfig -is [array]) { + # Keep navigating into properties which the configuration specifies until we reach the bottom most object, + # e.g. not a value type - but the object reference so the value is persisted. + do { + if ($bicepConfigNode -is [array]) { # If this is an array - use the property as an array index... - $arrayIndex = $propertyNames[$index] - if ($arrayIndex -match "\[[0-9]+\]") { - $arrayIndex = $arrayIndex -replace "\[|\]","" + if ($propertyNames[$index] -match "[0-9]+" -eq $false) { + throw "Configuration specifies an array, but the index value '${$propertyNames[$index]}' is not a number" } - $bicepConfig = $bicepConfig[$arrayIndex] - } elseif ($bicepConfig.ContainsKey($propertyNames[$index]) -eq $false) { + $bicepConfigNode = $bicepConfigNode[$propertyNames[$index]] + + } elseif ($bicepConfigNode.ContainsKey($propertyNames[$index]) -eq $true) { + # We found the item, keep indexing into the object. + $bicepConfigNode = $bicepConfigNode[$propertyNames[$index]] + } else { # This property doesn't exist at this level in the hierarchy, # this isn't the property we're looking for, stop looking. - $bicepConfig = $null - break - } else { - # We found the item, keep indexing into the object. - $bicepConfig = $bicepConfig[$propertyNames[$index]] + $bicepConfigNode = $null } - } + + ++$index + + } while (($null -ne $bicepConfigNode) -and ($index -lt $propertyNames.Length - 1)) # If we're here, we've got the object at the bottom of the hierarchy - and we can modify values on it. - if ($target.Destination -eq "Parameters" -and $null -ne $bicepConfig) { + if ($target.Destination -eq "Parameters" -and $null -ne $bicepConfigNode) { + $leafPropertyName = $propertyNames[-1] + if ($configKey.Value.Type -eq "Computed") { + # If the value type is computed we replace the value with another which already exists in the configuration hierarchy. if ($configKey.Value.Value -is [array]) { $formattedValues = @() foreach($formatString in $configKey.Value.Value) { $formattedValues += Format-TokenizedConfigurationString -tokenizedString $formatString -configuration $configuration } - $bicepConfig[$propertyNames[-1]] = $formattedValues + $bicepConfigNode[$leafPropertyName] = $formattedValues } else { - $bicepConfig[$propertyNames[-1]] = Format-TokenizedConfigurationString -tokenizedString $configKey.Value.Value -configuration $configuration + $bicepConfigNode[$leafPropertyName] = Format-TokenizedConfigurationString -tokenizedString $configKey.Value.Value -configuration $configuration } } else { - $bicepConfig[$propertyNames[-1]] = $configKey.Value.Value + $bicepConfigNode[$leafPropertyName] = $configKey.Value.Value } + $modified = $true } } diff --git a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 index 2d722708..46e107f2 100644 --- a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 +++ b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 @@ -67,37 +67,151 @@ InModuleScope 'ALZ' { } Context 'Edit-ALZConfigurationFilesInPlace should replace the parameters correctly' { - # It 'Should replace array values correctly (JSON Object)' { - # $config = Initialize-TestConfiguration -configTarget "parValue.value.[0]" -withValue "value" - - # $fileContent = '{ - # "parameters": { - # "parValue": { - # "value": ["replace_me", "dont_replace_me"] - # } - # } - # }' - - # $expectedContent = '{ - # "parameters": { - # "parValue": { - # "value": ["value", , "dont_replace_me"] - # } - # } - # }' - - # Mock -CommandName Get-Content -ParameterFilter { $Path -eq $testFile1Name } -MockWith { - # $fileContent - # } - - # $expectedContent = Format-ExpectedResult -expectedJson $expectedContent - - # Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $config - - # Should -Invoke -CommandName Out-File ` - # -ParameterFilter { $FilePath -eq $testFile1Name -and $InputObject -eq $expectedContent } ` - # -Scope It - # } + It 'Should replace array values correctly (JSON Object) - first' { + $config = Initialize-TestConfiguration -configTarget "parValue.value.[0]" -withValue "value" + + $fileContent = '{ + "parameters": { + "parValue": { + "value": [ + "replace_me", + "dont_replace_me" + ] + } + } + }' + + $expectedContent = '{ + "parameters": { + "parValue": { + "value": [ + "value", + "dont_replace_me" + ] + } + } + }' + + Mock -CommandName Get-Content -ParameterFilter { $Path -eq $testFile1Name } -MockWith { + $fileContent + } + + $expectedContent = Format-ExpectedResult -expectedJson $expectedContent + + Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $config + + Should -Invoke -CommandName Out-File ` + -ParameterFilter { $FilePath -eq $testFile1Name -and $InputObject -eq $expectedContent } ` + -Scope It + } + + It 'Should replace array an entire array correctly (JSON Object)' { + $config = Initialize-TestConfiguration -configTarget "parValue.value.[1]" -withValue "value" + + $fileContent = '{ + "parameters": { + "parValue": { + "value": [ + "dont_replace_me", + "replace_me" + ] + } + } + }' + + $expectedContent = '{ + "parameters": { + "parValue": { + "value": [ + "dont_replace_me", + "value" + ] + } + } + }' + + Mock -CommandName Get-Content -ParameterFilter { $Path -eq $testFile1Name } -MockWith { + $fileContent + } + + $expectedContent = Format-ExpectedResult -expectedJson $expectedContent + + Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $config + + Should -Invoke -CommandName Out-File ` + -ParameterFilter { $FilePath -eq $testFile1Name -and $InputObject -eq $expectedContent } ` + -Scope It + } + + It 'Should replace array values correctly (JSON Object) - second' { + + $config = [pscustomobject]@{ + Nested = [pscustomobject]@{ + Type = "Computed" + Description = "A Test Value" + Value = @( + "1", + "2", + "3" + ) + Targets = @( + [pscustomobject]@{ + Name = "parValue.value" + Destination = "Parameters" + }) + } + } + + $fileContent = '{ + "parameters": { + "parValue": { + "value": ["replace_me"] + } + } + }' + + $expectedContent = '{ + "parameters": { + "parValue": { + "value": ["1", "2", "3"] + } + } + }' + + Mock -CommandName Get-Content -ParameterFilter { $Path -eq $testFile1Name } -MockWith { + $fileContent + } + + $expectedContent = Format-ExpectedResult -expectedJson $expectedContent + + Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $config + + Should -Invoke -CommandName Out-File ` + -ParameterFilter { $FilePath -eq $testFile1Name -and $InputObject -eq $expectedContent } ` + -Scope It + } + + It 'Should not write to files that havent been changed.' { + $config = Initialize-TestConfiguration -configTarget "DoesnotExist.value" -withValue "value" + + $fileContent = '{ + "parameters": { + "parValue": { + "value": "replace_me" + } + } + }' + + Mock -CommandName Get-Content -ParameterFilter { $Path -eq $testFile1Name } -MockWith { + $fileContent + } + + Edit-ALZConfigurationFilesInPlace -alzEnvironmentDestination '.' -configuration $config + + Should -Invoke -CommandName Out-File ` + -Scope It ` + -Times 0 -Exactly + } It 'Should replace simple values correctly (Bicep Object)' { $config = Initialize-TestConfiguration -configTarget "parValue.value" -withValue "value" From 6669d8344ad70ec9cb86661017e7b6a6f4df9e1d Mon Sep 17 00:00:00 2001 From: Guy Pritchard Date: Mon, 27 Mar 2023 10:36:41 +0100 Subject: [PATCH 6/7] Fixing environment examples --- src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json b/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json index 17f18cbc..074e3b97 100644 --- a/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json +++ b/src/ALZ/Assets/alz-bicep-config/v0.14.0-pre.config.json @@ -257,7 +257,7 @@ }, "Environment": { "Type": "UserInput", - "Description": "The Type of environment that will be created. (e.g. 'dev', 'test', 'qa', 'staging', 'prod')", + "Description": "The Type of environment that will be created. (e.g. 'live', 'canary')", "Targets": [ { "Name": "parEnvironment.value", @@ -269,7 +269,7 @@ } ], "Value": "", - "DefaultValue": "prod", + "DefaultValue": "live", "Valid": "^[a-zA-Z0-9]{2,10}$" }, "IdentitySubscriptionId": { From 2087642af489168fb5cbec1677628748382096ee Mon Sep 17 00:00:00 2001 From: Guy Pritchard Date: Mon, 27 Mar 2023 11:39:20 +0100 Subject: [PATCH 7/7] Code review comments --- actions_bootstrap.ps1 | 6 ------ src/ALZ/ALZ.psd1 | 7 +------ .../Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 | 3 ++- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/actions_bootstrap.ps1 b/actions_bootstrap.ps1 index 896242f4..71665a48 100644 --- a/actions_bootstrap.ps1 +++ b/actions_bootstrap.ps1 @@ -30,12 +30,6 @@ $null = $modulesToInstall.Add(([PSCustomObject]@{ ModuleVersion = '0.12.0' })) -# Required dependency of the ALZ module itself. -$null = $modulesToInstall.Add(([PSCustomObject]@{ - ModuleName = 'Az.Resources' - ModuleVersion = '6.5.2' - })) - 'Installing PowerShell Modules' foreach ($module in $modulesToInstall) { $installSplat = @{ diff --git a/src/ALZ/ALZ.psd1 b/src/ALZ/ALZ.psd1 index 926df639..83cc0f16 100644 --- a/src/ALZ/ALZ.psd1 +++ b/src/ALZ/ALZ.psd1 @@ -51,12 +51,7 @@ # ProcessorArchitecture = '' # Modules that must be imported into the global environment prior to importing this module - RequiredModules = @( - @{ - ModuleName = 'Az.Resources' - ModuleVersion = '6.5.2' - } - ) + RequiredModules = @() # Assemblies that must be loaded prior to importing this module # RequiredAssemblies = @() diff --git a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 index 46e107f2..f1234264 100644 --- a/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 +++ b/src/Tests/Unit/Private/Edit-ALZConfigurationFilesInPlace.Tests.ps1 @@ -468,7 +468,7 @@ InModuleScope 'ALZ' { $contentAfterParsing.parameters.parTopLevelManagementGroupPrefix.value = 'test' $contentAfterParsing.parameters.parCompanyPrefix.value = 'test' $contentAfterParsing.parameters.parLogging.value = "logs/dev/eastus" - # $contentAfterParsing.parameters.parNested.value.parChildValue.value = "nested" + $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test1.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It @@ -476,6 +476,7 @@ InModuleScope 'ALZ' { $contentAfterParsing = ConvertFrom-Json -InputObject $secondFileContent -AsHashtable $contentAfterParsing.parameters.parTopLevelManagementGroupSuffix.value = 'bla' $contentAfterParsing.parameters.parLocation.value = 'eastus' + $contentStringAfterParsing = ConvertTo-Json -InputObject $contentAfterParsing Write-InformationColored $contentStringAfterParsing -ForegroundColor Yellow -InformationAction Continue Should -Invoke -CommandName Out-File -ParameterFilter { $FilePath -eq "test2.parameters.json" -and $InputObject -eq $contentStringAfterParsing } -Scope It