diff --git a/src/ALZ/Private/Config-Helpers/ConvertTo-DnsSafeTags.ps1 b/src/ALZ/Private/Config-Helpers/ConvertTo-DnsSafeTags.ps1 new file mode 100644 index 0000000..beb134a --- /dev/null +++ b/src/ALZ/Private/Config-Helpers/ConvertTo-DnsSafeTags.ps1 @@ -0,0 +1,69 @@ +function ConvertTo-DnsSafeTags { + <# + .SYNOPSIS + Converts tags to DNS-safe format by removing spaces and parentheses from tag keys. + + .DESCRIPTION + Azure DNS zones don't support the use of spaces or parentheses in tag keys, or tag keys that start with a number. + This function sanitizes tag keys to make them DNS-safe. + Reference: https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources + + .PARAMETER tags + The hashtable or PSCustomObject containing tags to sanitize. + + .EXAMPLE + ConvertTo-DnsSafeTags -tags @{"Business Application" = "ALZ"; "Owner" = "Platform"} + Returns: @{"BusinessApplication" = "ALZ"; "Owner" = "Platform"} + + .EXAMPLE + ConvertTo-DnsSafeTags -tags @{"Business Unit (Primary)" = "IT"; "1stTag" = "value"} + Returns: @{"BusinessUnitPrimary" = "IT"; "_1stTag" = "value"} + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [object] $tags + ) + + if ($null -eq $tags) { + return $null + } + + $dnsSafeTags = @{} + + # Handle both hashtables and PSCustomObjects + if ($tags -is [hashtable]) { + foreach ($key in $tags.Keys) { + $safeKey = $key -replace '\s+', '' # Remove all whitespace + $safeKey = $safeKey -replace '[()]', '' # Remove parentheses + # Ensure key doesn't start with a number by prepending underscore if needed + if ($safeKey -match '^\d') { + $safeKey = "_$safeKey" + } + if ($safeKey -ne "") { + $dnsSafeTags[$safeKey] = $tags[$key] + } else { + Write-Warning "Tag key '$key' resulted in empty string after sanitization and was skipped" + } + } + } elseif ($tags -is [PSCustomObject]) { + foreach ($property in $tags.PSObject.Properties) { + $safeKey = $property.Name -replace '\s+', '' # Remove all whitespace + $safeKey = $safeKey -replace '[()]', '' # Remove parentheses + # Ensure key doesn't start with a number by prepending underscore if needed + if ($safeKey -match '^\d') { + $safeKey = "_$safeKey" + } + if ($safeKey -ne "") { + $dnsSafeTags[$safeKey] = $property.Value + } else { + Write-Warning "Tag key '$($property.Name)' resulted in empty string after sanitization and was skipped" + } + } + } else { + Write-Verbose "Tag format is neither hashtable nor PSCustomObject, returning as-is" + return $tags + } + + return $dnsSafeTags +} diff --git a/src/ALZ/Private/Config-Helpers/Set-DnsSafeTagsForVirtualHubs.ps1 b/src/ALZ/Private/Config-Helpers/Set-DnsSafeTagsForVirtualHubs.ps1 new file mode 100644 index 0000000..1663227 --- /dev/null +++ b/src/ALZ/Private/Config-Helpers/Set-DnsSafeTagsForVirtualHubs.ps1 @@ -0,0 +1,96 @@ +function Set-DnsSafeTagsForVirtualHubs { + <# + .SYNOPSIS + Processes virtual_hubs configuration to ensure DNS zone tags are DNS-safe. + + .DESCRIPTION + This function processes the virtual_hubs configuration object and sanitizes tags for private_dns_zones + to ensure they don't contain spaces or other characters not supported by Azure DNS. + Implements fallback logic: private_dns_zones.tags -> connectivity_tags -> overall tags (all sanitized) + + .PARAMETER virtualHubs + The virtual_hubs configuration object to process. + + .PARAMETER connectivityTags + Optional connectivity-level tags to use as fallback. + + .PARAMETER overallTags + Optional overall/global tags to use as final fallback. + + .EXAMPLE + Set-DnsSafeTagsForVirtualHubs -virtualHubs $config -connectivityTags @{"Business Unit" = "IT"} + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [object] $virtualHubs, + + [Parameter(Mandatory = $false)] + [object] $connectivityTags = $null, + + [Parameter(Mandatory = $false)] + [object] $overallTags = $null + ) + + if ($null -eq $virtualHubs) { + return $virtualHubs + } + + # Process each virtual hub + foreach ($hubProperty in $virtualHubs.PSObject.Properties) { + $hub = $hubProperty.Value + + if ($null -eq $hub) { + continue + } + + # Check if this hub has private_dns_zones configuration + $privateDnsZonesProperty = $hub.PSObject.Properties | Where-Object { $_.Name -eq "private_dns_zones" } + + if ($null -ne $privateDnsZonesProperty) { + $privateDnsZones = $privateDnsZonesProperty.Value + + if ($null -ne $privateDnsZones) { + # Check if DNS zone has its own tags + $dnsTagsProperty = $privateDnsZones.PSObject.Properties | Where-Object { $_.Name -eq "tags" } + + if ($null -ne $dnsTagsProperty -and $null -ne $dnsTagsProperty.Value) { + # DNS zone has its own tags - sanitize them + Write-Verbose "Sanitizing DNS zone tags for hub: $($hubProperty.Name)" + $sanitizedTags = ConvertTo-DnsSafeTags -tags $dnsTagsProperty.Value + $privateDnsZones.tags = $sanitizedTags + } else { + # No DNS-specific tags, implement fallback logic + Write-Verbose "No DNS-specific tags found for hub: $($hubProperty.Name), applying fallback logic" + + $tagsToUse = $null + + # Try connectivity tags first + if ($null -ne $connectivityTags) { + Write-Verbose "Using connectivity tags as fallback" + $tagsToUse = $connectivityTags + } + # Fall back to overall tags if connectivity tags not available + elseif ($null -ne $overallTags) { + Write-Verbose "Using overall tags as fallback" + $tagsToUse = $overallTags + } + + # Sanitize and apply the fallback tags + if ($null -ne $tagsToUse) { + $sanitizedTags = ConvertTo-DnsSafeTags -tags $tagsToUse + + # Add tags property if it doesn't exist + if ($null -eq $dnsTagsProperty) { + $privateDnsZones | Add-Member -NotePropertyName "tags" -NotePropertyValue $sanitizedTags -Force + } else { + $privateDnsZones.tags = $sanitizedTags + } + } + } + } + } + } + + return $virtualHubs +} diff --git a/src/ALZ/Private/Config-Helpers/Write-TfvarsJsonFile.ps1 b/src/ALZ/Private/Config-Helpers/Write-TfvarsJsonFile.ps1 index dac277a..5cf5502 100644 --- a/src/ALZ/Private/Config-Helpers/Write-TfvarsJsonFile.ps1 +++ b/src/ALZ/Private/Config-Helpers/Write-TfvarsJsonFile.ps1 @@ -19,6 +19,20 @@ function Write-TfvarsJsonFile { $jsonObject = [ordered]@{} + # Extract connectivity and overall tags for DNS fallback logic + $connectivityTags = $null + $overallTags = $null + + $connectivityTagsProperty = $configuration.PSObject.Properties | Where-Object { $_.Name -eq "connectivity_tags" } + if ($null -ne $connectivityTagsProperty) { + $connectivityTags = $connectivityTagsProperty.Value.Value + } + + $tagsProperty = $configuration.PSObject.Properties | Where-Object { $_.Name -eq "tags" } + if ($null -ne $tagsProperty) { + $overallTags = $tagsProperty.Value.Value + } + foreach ($configurationProperty in $configuration.PSObject.Properties | Sort-Object Name) { if ($skipItems -contains $configurationProperty.Name) { Write-Verbose "Skipping configuration property: $($configurationProperty.Name)" @@ -36,6 +50,12 @@ function Write-TfvarsJsonFile { $configurationValue = [System.IO.Path]::GetFileName($configurationValue) } + # Process virtual_hubs to sanitize DNS zone tags + if ($configurationProperty.Name -eq "virtual_hubs" -and $null -ne $configurationValue) { + Write-Verbose "Processing virtual_hubs configuration to apply DNS-safe tags" + $configurationValue = Set-DnsSafeTagsForVirtualHubs -virtualHubs $configurationValue -connectivityTags $connectivityTags -overallTags $overallTags + } + Write-Verbose "Writing to tfvars.json - Configuration Property: $($configurationProperty.Name) - Configuration Value: $configurationValue" $jsonObject.Add("$($configurationProperty.Name)", $configurationValue) } diff --git a/src/Tests/Unit/Private/ConvertTo-DnsSafeTags.Tests.ps1 b/src/Tests/Unit/Private/ConvertTo-DnsSafeTags.Tests.ps1 new file mode 100644 index 0000000..49f585f --- /dev/null +++ b/src/Tests/Unit/Private/ConvertTo-DnsSafeTags.Tests.ps1 @@ -0,0 +1,135 @@ +Describe "ConvertTo-DnsSafeTags" { + BeforeAll { + # Directly source the function file + . "$PSScriptRoot/../../../ALZ/Private/Config-Helpers/ConvertTo-DnsSafeTags.ps1" + } + + Context "When converting hashtable tags" { + It "Should remove spaces from tag keys" { + $tags = @{ + "Business Application" = "ALZ" + "Owner" = "Platform" + } + + $result = ConvertTo-DnsSafeTags -tags $tags + + $result.Keys | Should -Contain "BusinessApplication" + $result.Keys | Should -Contain "Owner" + $result.Keys | Should -Not -Contain "Business Application" + $result["BusinessApplication"] | Should -Be "ALZ" + $result["Owner"] | Should -Be "Platform" + } + + It "Should remove parentheses from tag keys" { + $tags = @{ + "Business Unit (Primary)" = "IT" + "Cost Center (Backup)" = "12345" + } + + $result = ConvertTo-DnsSafeTags -tags $tags + + $result.Keys | Should -Contain "BusinessUnitPrimary" + $result.Keys | Should -Contain "CostCenterBackup" + $result["BusinessUnitPrimary"] | Should -Be "IT" + $result["CostCenterBackup"] | Should -Be "12345" + } + + It "Should prefix tag keys that start with a number" { + $tags = @{ + "1stTag" = "value1" + "2ndTag" = "value2" + } + + $result = ConvertTo-DnsSafeTags -tags $tags + + $result.Keys | Should -Contain "_1stTag" + $result.Keys | Should -Contain "_2ndTag" + $result["_1stTag"] | Should -Be "value1" + $result["_2ndTag"] | Should -Be "value2" + } + + It "Should handle tags with multiple spaces and special characters" { + $tags = @{ + "Business Application (Main)" = "ALZ" + " Owner " = "Platform" + } + + $result = ConvertTo-DnsSafeTags -tags $tags + + $result.Keys | Should -Contain "BusinessApplicationMain" + $result.Keys | Should -Contain "Owner" + $result["BusinessApplicationMain"] | Should -Be "ALZ" + $result["Owner"] | Should -Be "Platform" + } + + It "Should return null for null input" { + $result = ConvertTo-DnsSafeTags -tags $null + $result | Should -Be $null + } + + It "Should handle empty hashtable" { + $tags = @{} + $result = ConvertTo-DnsSafeTags -tags $tags + $result.Count | Should -Be 0 + } + } + + Context "When converting PSCustomObject tags" { + It "Should remove spaces from tag keys in PSCustomObject" { + $tags = [PSCustomObject]@{ + "Business Application" = "ALZ" + "Owner" = "Platform" + } + + $result = ConvertTo-DnsSafeTags -tags $tags + + $result.Keys | Should -Contain "BusinessApplication" + $result.Keys | Should -Contain "Owner" + $result["BusinessApplication"] | Should -Be "ALZ" + $result["Owner"] | Should -Be "Platform" + } + + It "Should handle PSCustomObject with special characters" { + $tags = [PSCustomObject]@{ + "Business Unit (Test)" = "IT" + "1stTag" = "value" + } + + $result = ConvertTo-DnsSafeTags -tags $tags + + $result.Keys | Should -Contain "BusinessUnitTest" + $result.Keys | Should -Contain "_1stTag" + $result["BusinessUnitTest"] | Should -Be "IT" + $result["_1stTag"] | Should -Be "value" + } + } + + Context "When handling edge cases" { + It "Should skip tags that result in empty keys" { + $tags = @{ + " " = "value" + "Owner" = "Platform" + } + + # Should issue a warning but not fail + $result = ConvertTo-DnsSafeTags -tags $tags -WarningAction SilentlyContinue + + $result.Keys | Should -Contain "Owner" + $result.Keys | Should -Not -Contain " " + $result.Keys | Should -Not -Contain "" + $result.Count | Should -Be 1 + } + + It "Should handle tags with only spaces and parentheses" { + $tags = @{ + "( )" = "value" + "Owner" = "Platform" + } + + $result = ConvertTo-DnsSafeTags -tags $tags -WarningAction SilentlyContinue + + $result.Keys | Should -Contain "Owner" + $result.Count | Should -Be 1 + } + } +} diff --git a/src/Tests/Unit/Private/Set-DnsSafeTagsForVirtualHubs.Tests.ps1 b/src/Tests/Unit/Private/Set-DnsSafeTagsForVirtualHubs.Tests.ps1 new file mode 100644 index 0000000..beab1e8 --- /dev/null +++ b/src/Tests/Unit/Private/Set-DnsSafeTagsForVirtualHubs.Tests.ps1 @@ -0,0 +1,200 @@ +Describe "Set-DnsSafeTagsForVirtualHubs" { + BeforeAll { + # Directly source the function files + . "$PSScriptRoot/../../../ALZ/Private/Config-Helpers/ConvertTo-DnsSafeTags.ps1" + . "$PSScriptRoot/../../../ALZ/Private/Config-Helpers/Set-DnsSafeTagsForVirtualHubs.ps1" + } + + Context "When processing virtual_hubs with DNS zone tags" { + It "Should sanitize existing DNS zone tags" { + $virtualHubs = [PSCustomObject]@{ + primary = [PSCustomObject]@{ + private_dns_zones = [PSCustomObject]@{ + tags = @{ + "Business Application" = "ALZ" + "Owner" = "Platform" + } + } + } + } + + $result = Set-DnsSafeTagsForVirtualHubs -virtualHubs $virtualHubs + + $result.primary.private_dns_zones.tags.Keys | Should -Contain "BusinessApplication" + $result.primary.private_dns_zones.tags.Keys | Should -Contain "Owner" + $result.primary.private_dns_zones.tags.Keys | Should -Not -Contain "Business Application" + } + + It "Should apply connectivity tags as fallback when DNS zone has no tags" { + $virtualHubs = [PSCustomObject]@{ + primary = [PSCustomObject]@{ + private_dns_zones = [PSCustomObject]@{ + resource_group_name = "dns-rg" + } + } + } + + $connectivityTags = @{ + "Business Application" = "ALZ" + "Business Unit" = "IT" + } + + $result = Set-DnsSafeTagsForVirtualHubs -virtualHubs $virtualHubs -connectivityTags $connectivityTags + + $result.primary.private_dns_zones.tags | Should -Not -BeNullOrEmpty + $result.primary.private_dns_zones.tags.Keys | Should -Contain "BusinessApplication" + $result.primary.private_dns_zones.tags.Keys | Should -Contain "BusinessUnit" + $result.primary.private_dns_zones.tags["BusinessApplication"] | Should -Be "ALZ" + $result.primary.private_dns_zones.tags["BusinessUnit"] | Should -Be "IT" + } + + It "Should apply overall tags as fallback when no DNS or connectivity tags exist" { + $virtualHubs = [PSCustomObject]@{ + primary = [PSCustomObject]@{ + private_dns_zones = [PSCustomObject]@{ + resource_group_name = "dns-rg" + } + } + } + + $overallTags = @{ + "Deployment Type" = "Terraform" + "Environment" = "Production" + } + + $result = Set-DnsSafeTagsForVirtualHubs -virtualHubs $virtualHubs -overallTags $overallTags + + $result.primary.private_dns_zones.tags | Should -Not -BeNullOrEmpty + $result.primary.private_dns_zones.tags.Keys | Should -Contain "DeploymentType" + $result.primary.private_dns_zones.tags.Keys | Should -Contain "Environment" + $result.primary.private_dns_zones.tags["DeploymentType"] | Should -Be "Terraform" + } + + It "Should prefer DNS zone tags over connectivity tags" { + $virtualHubs = [PSCustomObject]@{ + primary = [PSCustomObject]@{ + private_dns_zones = [PSCustomObject]@{ + tags = @{ + "Owner" = "DNS Team" + } + } + } + } + + $connectivityTags = @{ + "Owner" = "Connectivity Team" + "Business Unit" = "IT" + } + + $result = Set-DnsSafeTagsForVirtualHubs -virtualHubs $virtualHubs -connectivityTags $connectivityTags + + $result.primary.private_dns_zones.tags["Owner"] | Should -Be "DNS Team" + $result.primary.private_dns_zones.tags.Keys | Should -Not -Contain "BusinessUnit" + } + + It "Should prefer connectivity tags over overall tags" { + $virtualHubs = [PSCustomObject]@{ + primary = [PSCustomObject]@{ + private_dns_zones = [PSCustomObject]@{ + resource_group_name = "dns-rg" + } + } + } + + $connectivityTags = @{ + "Business Unit" = "Connectivity" + } + + $overallTags = @{ + "Business Unit" = "Overall" + } + + $result = Set-DnsSafeTagsForVirtualHubs -virtualHubs $virtualHubs -connectivityTags $connectivityTags -overallTags $overallTags + + $result.primary.private_dns_zones.tags["BusinessUnit"] | Should -Be "Connectivity" + } + + It "Should handle multiple virtual hubs" { + $virtualHubs = [PSCustomObject]@{ + primary = [PSCustomObject]@{ + private_dns_zones = [PSCustomObject]@{ + tags = @{ + "Environment" = "Primary" + } + } + } + secondary = [PSCustomObject]@{ + private_dns_zones = [PSCustomObject]@{ + tags = @{ + "Environment" = "Secondary" + } + } + } + } + + $result = Set-DnsSafeTagsForVirtualHubs -virtualHubs $virtualHubs + + $result.primary.private_dns_zones.tags["Environment"] | Should -Be "Primary" + $result.secondary.private_dns_zones.tags["Environment"] | Should -Be "Secondary" + } + + It "Should handle virtual hubs without private_dns_zones" { + $virtualHubs = [PSCustomObject]@{ + primary = [PSCustomObject]@{ + location = "eastus" + } + } + + $result = Set-DnsSafeTagsForVirtualHubs -virtualHubs $virtualHubs + + $result.primary.location | Should -Be "eastus" + $result.primary.PSObject.Properties.Name | Should -Not -Contain "private_dns_zones" + } + + It "Should return null for null input" { + $result = Set-DnsSafeTagsForVirtualHubs -virtualHubs $null + $result | Should -Be $null + } + + It "Should not apply fallback tags if no fallback tags are provided" { + $virtualHubs = [PSCustomObject]@{ + primary = [PSCustomObject]@{ + private_dns_zones = [PSCustomObject]@{ + resource_group_name = "dns-rg" + } + } + } + + $result = Set-DnsSafeTagsForVirtualHubs -virtualHubs $virtualHubs + + $result.primary.private_dns_zones.PSObject.Properties.Name | Should -Not -Contain "tags" + } + } + + Context "When handling complex tag sanitization scenarios" { + It "Should sanitize connectivity fallback tags with spaces and parentheses" { + $virtualHubs = [PSCustomObject]@{ + primary = [PSCustomObject]@{ + private_dns_zones = [PSCustomObject]@{ + resource_group_name = "dns-rg" + } + } + } + + $connectivityTags = @{ + "Business Application (Main)" = "ALZ" + "Business Criticality" = "High" + "1stPriority" = "DNS" + } + + $result = Set-DnsSafeTagsForVirtualHubs -virtualHubs $virtualHubs -connectivityTags $connectivityTags + + $result.primary.private_dns_zones.tags.Keys | Should -Contain "BusinessApplicationMain" + $result.primary.private_dns_zones.tags.Keys | Should -Contain "BusinessCriticality" + $result.primary.private_dns_zones.tags.Keys | Should -Contain "_1stPriority" + $result.primary.private_dns_zones.tags["BusinessApplicationMain"] | Should -Be "ALZ" + $result.primary.private_dns_zones.tags["BusinessCriticality"] | Should -Be "High" + $result.primary.private_dns_zones.tags["_1stPriority"] | Should -Be "DNS" + } + } +}