diff --git a/.github/tests/README.md b/.github/tests/README.md index 5a447e1e..626c3553 100644 --- a/.github/tests/README.md +++ b/.github/tests/README.md @@ -32,3 +32,17 @@ The tests use a set of environemnts to managed by the ALZ team. These are: - Organisation: microsoft-azure-landing-zones-cd-tests - GiHub - Organisation: microsoft-azure-landingzones-cd-tests + +## Validation Script + +Use the script below to validate deterministic deployment stack naming behavior for Bug #4062: + +- Script: `.github/tests/scripts/validate-deployment-stack-name-hash.ps1` +- Run from repo root: `pwsh ./.github/tests/scripts/validate-deployment-stack-name-hash.ps1` + +This validation confirms: + +1. Legacy names are preserved when `prefix-base` length is within Azure's 64-character limit. +2. Different long names (for example `stage-three` and `stage-four`) produce different final stack names. +3. Final stack names remain within Azure's 64-character limit. +4. Very long prefixes are trimmed safely while preserving the hash suffix when hash mode is required. diff --git a/.github/tests/scripts/validate-deployment-stack-name-hash.ps1 b/.github/tests/scripts/validate-deployment-stack-name-hash.ps1 new file mode 100644 index 00000000..38dc57d2 --- /dev/null +++ b/.github/tests/scripts/validate-deployment-stack-name-hash.ps1 @@ -0,0 +1,167 @@ +param() + +function Get-Sha256HashPrefix { + param( + [Parameter(Mandatory = $true)] + [string]$InputString, + [Parameter(Mandatory = $true)] + [int]$Length + ) + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($InputString)) + } finally { + $sha256.Dispose() + } + + return [System.BitConverter]::ToString($hashBytes).Replace("-", "").Substring(0, $Length).ToLower() +} + +function New-RemoteDeterministicDeploymentStackName { + param( + [Parameter(Mandatory = $true)] + [string]$DeploymentPrefix, + [Parameter(Mandatory = $true)] + [string]$OriginalName + ) + + $normalizedBase = $OriginalName + if ([string]::IsNullOrWhiteSpace($normalizedBase)) { + $normalizedBase = "deployment" + } + $normalizedBase = $normalizedBase.Replace(" ", "-") + + $hashLength = 10 + $hash = Get-Sha256HashPrefix -InputString "$DeploymentPrefix|$normalizedBase" -Length $hashLength + + $legacyName = "$DeploymentPrefix-$normalizedBase" + if ($legacyName.Length -le 64) { + return $legacyName + } + + $safePrefix = $DeploymentPrefix + $prefixMaxLength = [Math]::Max(1, 64 - $hashLength - 3) + if ($safePrefix.Length -gt $prefixMaxLength) { + $safePrefix = $safePrefix.Substring(0, $prefixMaxLength) + } + + $baseMaxLength = [Math]::Max(1, 64 - $safePrefix.Length - $hashLength - 2) + $safeBase = $normalizedBase + if ($safeBase.Length -gt $baseMaxLength) { + $safeBase = $safeBase.Substring(0, $baseMaxLength) + } + + return "$safePrefix-$safeBase-$hash" +} + +$localPrefix = "alzrishabh2705-long-management-group-id" +function New-LocalDeterministicDeploymentStackName { + param( + [Parameter(Mandatory = $true)] + [string]$DeploymentPrefix, + [Parameter(Mandatory = $true)] + [string]$OriginalName + ) + + $normalizedBase = $OriginalName + if ([string]::IsNullOrWhiteSpace($normalizedBase)) { + $normalizedBase = "deployment" + } + $normalizedBase = $normalizedBase.Replace(" ", "-") + + if ($normalizedBase.Length -le 64) { + return $normalizedBase + } + + $hashLength = 10 + $hash = Get-Sha256HashPrefix -InputString "$DeploymentPrefix|$normalizedBase" -Length $hashLength + + $safePrefix = $DeploymentPrefix + $prefixMaxLength = [Math]::Max(1, 64 - $hashLength - 3) + if ($safePrefix.Length -gt $prefixMaxLength) { + $safePrefix = $safePrefix.Substring(0, $prefixMaxLength) + } + + $baseMaxLength = [Math]::Max(1, 64 - $safePrefix.Length - $hashLength - 2) + $safeBase = $normalizedBase + if ($safeBase.Length -gt $baseMaxLength) { + $safeBase = $safeBase.Substring(0, $baseMaxLength) + } + + return "$safePrefix-$safeBase-$hash" +} + +$shortPrefix = "alz-short" +$shortBase = "deploy-stage-one" +$legacyExpected = "$shortPrefix-$shortBase" +$shortNameResult = New-RemoteDeterministicDeploymentStackName -DeploymentPrefix $shortPrefix -OriginalName $shortBase +Write-Host "Legacy-compatible Name: $shortNameResult" + +if ($shortNameResult -ne $legacyExpected) { + throw "Validation failed: expected legacy name '$legacyExpected' when length is <= 64." +} + +if ($shortNameResult -match "-[a-f0-9]{10}$") { + throw "Validation failed: hash suffix should not be added when legacy name length is <= 64." +} + +$prefix = "alzrishabh2705-long-management-group-id" +$nameStageThree = "governance-landingzones-platform-deployment-stage-three" +$nameStageFour = "governance-landingzones-platform-deployment-stage-four" + +$stackNameStageThree = New-RemoteDeterministicDeploymentStackName -DeploymentPrefix $prefix -OriginalName $nameStageThree +$stackNameStageFour = New-RemoteDeterministicDeploymentStackName -DeploymentPrefix $prefix -OriginalName $nameStageFour + +Write-Host "Stage Three Name: $stackNameStageThree" +Write-Host "Stage Four Name: $stackNameStageFour" + +if ($stackNameStageThree -eq $stackNameStageFour) { + throw "Validation failed: stage-three and stage-four produced the same deployment stack name." +} + +if ($stackNameStageThree.Length -gt 64 -or $stackNameStageFour.Length -gt 64) { + throw "Validation failed: one or more deployment stack names exceeded the 64 character limit." +} + +if ($stackNameStageThree -notmatch "-[a-f0-9]{10}$" -or $stackNameStageFour -notmatch "-[a-f0-9]{10}$") { + throw "Validation failed: long names should use the deterministic hash suffix." +} + +$veryLongPrefix = "alzrishabh2705-super-long-management-group-prefix-with-extra-segments-over-limit" +$longPrefixName = New-RemoteDeterministicDeploymentStackName -DeploymentPrefix $veryLongPrefix -OriginalName $nameStageThree +Write-Host "Long Prefix Name: $longPrefixName" + +if ($longPrefixName.Length -gt 64) { + throw "Validation failed: long-prefix deployment stack name exceeded 64 characters." +} + +if ($longPrefixName -notmatch "-[a-f0-9]{10}$") { + throw "Validation failed: deployment stack name does not end with expected lowercase hash suffix." +} + +$localShortName = "platform-stage-one" +$localShortNameResult = New-LocalDeterministicDeploymentStackName -DeploymentPrefix $localPrefix -OriginalName $localShortName +Write-Host "Local Legacy-compatible Name: $localShortNameResult" + +if ($localShortNameResult -ne $localShortName) { + throw "Validation failed: local short deployment stack names should remain unchanged." +} + +if ($localShortNameResult -match "-[a-f0-9]{10}$") { + throw "Validation failed: local short deployment stack names should not receive a hash suffix." +} + +$localLongName = "governance-landingzones-platform-deployment-stage-three-with-extra-segments-over-the-azure-name-limit" +$localLongNameResult = New-LocalDeterministicDeploymentStackName -DeploymentPrefix $localPrefix -OriginalName $localLongName +Write-Host "Local Long Name: $localLongNameResult" + +if ($localLongNameResult.Length -gt 64) { + throw "Validation failed: local long deployment stack name exceeded 64 characters." +} + +if ($localLongNameResult -notmatch "-[a-f0-9]{10}$") { + throw "Validation failed: local long deployment stack names should use the deterministic hash suffix." +} + +Write-Host "Validation passed: legacy-compatible naming is preserved and hash-suffix naming is unique and length-safe when required." diff --git a/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml b/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml index 087c85e1..8cd8fd5a 100644 --- a/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml +++ b/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml @@ -70,12 +70,42 @@ steps: # Generate deployment stack name $intRootMgId = $env:MANAGEMENT_GROUP_ID_PREFIX + $env:INTERMEDIATE_ROOT_MANAGEMENT_GROUP_ID + $env:MANAGEMENT_GROUP_ID_POSTFIX $deploymentPrefix = $intRootMgId - $deploymentNameBase = "$${{ parameters.name }}".Replace(" ", "-") - $deploymentNameMaxLength = 64 - $deploymentPrefix.Length - 1 - if ($deploymentNameBase.Length -gt $deploymentNameMaxLength) { - $deploymentNameBase = $deploymentNameBase.Substring(0, $deploymentNameMaxLength) + $deploymentNameOriginalBase = "$${{ parameters.name }}" + if ([string]::IsNullOrWhiteSpace($deploymentNameOriginalBase)) { + $deploymentNameOriginalBase = "deployment" + } + $deploymentNameOriginalBase = $deploymentNameOriginalBase.Replace(" ", "-") + + $deploymentNameHashLength = 10 + $hashInput = "$deploymentPrefix|$deploymentNameOriginalBase" + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($hashInput)) + } finally { + $sha256.Dispose() + } + $deploymentNameSuffix = [System.BitConverter]::ToString($hashBytes).Replace("-", "").Substring(0, $deploymentNameHashLength).ToLower() + + $deploymentNameLegacy = "$deploymentPrefix-$deploymentNameOriginalBase" + + if ($deploymentNameLegacy.Length -le 64) { + # Preserve legacy naming to keep backward compatibility when length already fits. + $deploymentName = $deploymentNameLegacy + } else { + # Keep room for "--" and trim an oversized prefix safely. + $deploymentPrefixMaxLength = [Math]::Max(1, 64 - $deploymentNameHashLength - 3) + if ($deploymentPrefix.Length -gt $deploymentPrefixMaxLength) { + $deploymentPrefix = $deploymentPrefix.Substring(0, $deploymentPrefixMaxLength) + } + + $deploymentNameBaseMaxLength = [Math]::Max(1, 64 - $deploymentPrefix.Length - $deploymentNameHashLength - 2) + $deploymentNameBase = $deploymentNameOriginalBase + if ($deploymentNameBase.Length -gt $deploymentNameBaseMaxLength) { + $deploymentNameBase = $deploymentNameBase.Substring(0, $deploymentNameBaseMaxLength) + } + + $deploymentName = "$deploymentPrefix-$deploymentNameBase-$deploymentNameSuffix" } - $deploymentName = "$deploymentPrefix-$deploymentNameBase" $modeText = if ($whatIfEnabled) { "What-If" } else { "Deployment Stack" } diff --git a/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml b/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml index 22b85b58..aa31bbec 100644 --- a/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml +++ b/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml @@ -65,12 +65,42 @@ runs: # Generate deployment stack name $intRootMgId = $env:MANAGEMENT_GROUP_ID_PREFIX + $env:INTERMEDIATE_ROOT_MANAGEMENT_GROUP_ID + $env:MANAGEMENT_GROUP_ID_POSTFIX $deploymentPrefix = $intRootMgId - $deploymentNameBase = ($env:NAME).Replace(" ", "-") - $deploymentNameMaxLength = 64 - $deploymentPrefix.Length - 1 - if ($deploymentNameBase.Length -gt $deploymentNameMaxLength) { - $deploymentNameBase = $deploymentNameBase.Substring(0, $deploymentNameMaxLength) + $deploymentNameOriginalBase = $env:NAME + if ([string]::IsNullOrWhiteSpace($deploymentNameOriginalBase)) { + $deploymentNameOriginalBase = "deployment" + } + $deploymentNameOriginalBase = $deploymentNameOriginalBase.Replace(" ", "-") + + $deploymentNameHashLength = 10 + $hashInput = "$deploymentPrefix|$deploymentNameOriginalBase" + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($hashInput)) + } finally { + $sha256.Dispose() + } + $deploymentNameSuffix = [System.BitConverter]::ToString($hashBytes).Replace("-", "").Substring(0, $deploymentNameHashLength).ToLower() + + $deploymentNameLegacy = "$deploymentPrefix-$deploymentNameOriginalBase" + + if ($deploymentNameLegacy.Length -le 64) { + # Preserve legacy naming to keep backward compatibility when length already fits. + $deploymentName = $deploymentNameLegacy + } else { + # Keep room for "--" and trim an oversized prefix safely. + $deploymentPrefixMaxLength = [Math]::Max(1, 64 - $deploymentNameHashLength - 3) + if ($deploymentPrefix.Length -gt $deploymentPrefixMaxLength) { + $deploymentPrefix = $deploymentPrefix.Substring(0, $deploymentPrefixMaxLength) + } + + $deploymentNameBaseMaxLength = [Math]::Max(1, 64 - $deploymentPrefix.Length - $deploymentNameHashLength - 2) + $deploymentNameBase = $deploymentNameOriginalBase + if ($deploymentNameBase.Length -gt $deploymentNameBaseMaxLength) { + $deploymentNameBase = $deploymentNameBase.Substring(0, $deploymentNameBaseMaxLength) + } + + $deploymentName = "$deploymentPrefix-$deploymentNameBase-$deploymentNameSuffix" } - $deploymentName = "$deploymentPrefix-$deploymentNameBase" $modeText = if ($whatIfEnabled) { "What-If" } else { "Deployment Stack" } diff --git a/alz/local/scripts-bicep/bicep-deploy.ps1 b/alz/local/scripts-bicep/bicep-deploy.ps1 index b36c9e38..6619ff90 100644 --- a/alz/local/scripts-bicep/bicep-deploy.ps1 +++ b/alz/local/scripts-bicep/bicep-deploy.ps1 @@ -31,7 +31,42 @@ Write-Host "Resource Group Name: $resourceGroupName" -ForegroundColor DarkGray Write-Host "Location: $location" -ForegroundColor DarkGray Write-Host "Deployment Type: $deploymentType" -ForegroundColor DarkGray -$deploymentName = $name +$deploymentPrefix = $intRootMgId +$deploymentNameOriginalBase = $name +if ([string]::IsNullOrWhiteSpace($deploymentNameOriginalBase)) { + $deploymentNameOriginalBase = "deployment" +} +$deploymentNameOriginalBase = $deploymentNameOriginalBase.Replace(" ", "-") +$deploymentNameHashLength = 10 +$hashInput = "$deploymentPrefix|$deploymentNameOriginalBase" +$sha256 = [System.Security.Cryptography.SHA256]::Create() +try { + $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($hashInput)) +} finally { + $sha256.Dispose() +} +$deploymentNameSuffix = [System.BitConverter]::ToString($hashBytes).Replace("-", "").Substring(0, $deploymentNameHashLength).ToLower() + +$deploymentNameLegacy = $deploymentNameOriginalBase + +if ($deploymentNameLegacy.Length -le 64) { + # Preserve legacy local naming to keep backward compatibility when length already fits. + $deploymentName = $deploymentNameLegacy +} else { + # Keep room for "--" and trim an oversized prefix safely. + $deploymentPrefixMaxLength = [Math]::Max(1, 64 - $deploymentNameHashLength - 3) + if ($deploymentPrefix.Length -gt $deploymentPrefixMaxLength) { + $deploymentPrefix = $deploymentPrefix.Substring(0, $deploymentPrefixMaxLength) + } + + $deploymentNameBaseMaxLength = [Math]::Max(1, 64 - $deploymentPrefix.Length - $deploymentNameHashLength - 2) + $deploymentNameBase = $deploymentNameOriginalBase + if ($deploymentNameBase.Length -gt $deploymentNameBaseMaxLength) { + $deploymentNameBase = $deploymentNameBase.Substring(0, $deploymentNameBaseMaxLength) + } + + $deploymentName = "$deploymentPrefix-$deploymentNameBase-$deploymentNameSuffix" +} Write-Host "Deployment Stack Name: $deploymentName" Write-Host ""