Engines/Bicep/BicepWorkflow.ps1
|
function Resolve-AvmBicepWorkflowPath { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $RelativePath, [switch] $AllowMissing ) $relative = $RelativePath.Replace('\', '/') if ([IO.Path]::IsPathRooted($relative) -or $relative -match '[:\x00-\x1f*?\[\]]' -or $relative.Split('/') -contains '..' -or [string]::IsNullOrWhiteSpace($relative)) { throw [ArgumentException]::new('A workflow path must be a literal, repository-relative path without parent segments.') } $rootPath = [IO.Path]::GetFullPath($Root) $path = [IO.Path]::GetFullPath((Join-Path $rootPath $relative)) $prefix = $rootPath.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar if (-not $path.StartsWith($prefix, [StringComparison]::Ordinal)) { throw [ArgumentException]::new('The workflow path must remain inside its checkout.') } $current = $rootPath foreach ($segment in $relative.Split('/', [StringSplitOptions]::RemoveEmptyEntries)) { $current = Join-Path $current $segment if ((Test-Path -LiteralPath $current) -and ((Get-Item -LiteralPath $current -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw [ArgumentException]::new('Workflow inputs must not traverse linked files or directories.') } } if (-not $AllowMissing -and -not (Test-Path -LiteralPath $path)) { throw [IO.FileNotFoundException]::new("Workflow input does not exist: $relative") } return $path } function ConvertTo-AvmBicepWorkflowBooleanInternal { [CmdletBinding()] [OutputType([bool])] param([Parameter(Mandatory)] [object] $Value) if ($Value -is [bool]) { return $Value } if ($Value -is [string] -and $Value -match '^(true|false)$') { return $Value -ieq 'true' } throw [ArgumentException]::new('A workflow boolean must be true or false.') } function Test-AvmBicepWorkflowLegacyModuleLockInternal { [CmdletBinding()] [OutputType([bool])] param([Parameter(Mandatory)] [Collections.IDictionary] $Template) $resources = @(if ($Template['resources'] -is [Collections.IDictionary]) { $Template.resources.Values } else { $Template['resources'] | Where-Object { $null -ne $_ } }) if ($resources.Count -gt 0 -and $Template['$schema'] -match '/(?:managementGroup|tenant)DeploymentTemplate\.json#?$') { return $true } foreach ($resource in $resources) { if ($resource -isnot [Collections.IDictionary]) { throw [ArgumentException]::new('Compiled deployment resources must be objects.') } if ($resource['type'] -ieq 'Microsoft.Resources/deployments') { $properties = $resource['properties'] if ($properties -isnot [Collections.IDictionary]) { throw [ArgumentException]::new('Compiled nested deployments must have properties.') } if ($properties['templateLink']) { return $true } $nested = $properties['template'] if ($nested -is [string] -and $nested -cmatch "^\[variables\('([^']+)'\)\]$" -and $Template['variables'] -is [Collections.IDictionary]) { $nested = $Template['variables'][$Matches[1]] } if ($nested -isnot [Collections.IDictionary]) { return $true } if (Test-AvmBicepWorkflowLegacyModuleLockInternal -Template $nested) { return $true } } if ($resource['resources'] -and (Test-AvmBicepWorkflowLegacyModuleLockInternal -Template @{ resources = $resource.resources })) { return $true } } return $false } function Get-AvmBicepWorkflowNamePrefixInternal { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [ValidateSet('isolated', 'shared', 'fallback')] [string] $IsolationMode, [AllowEmptyString()] [string] $Prefix ) if ($IsolationMode -eq 'fallback') { return '' } if ([string]::IsNullOrEmpty($Prefix)) { $Prefix = 'gci' } if ($Prefix -cnotmatch '^[a-z][a-z0-9]{0,2}$') { throw [ArgumentException]::new('Reviewed Bicep tests require a lowercase alphanumeric naming prefix of one to three characters.') } return $Prefix + '#_avmE2eSuffix_#' } function Assert-AvmBicepWorkflowIsolationInputInternal { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet('isolated', 'shared', 'fallback')] [string] $IsolationMode, [Parameter(Mandatory)] [string] $CompiledTemplate, [bool] $RemoveDeployment = $true ) if ($IsolationMode -eq 'fallback') { return } if ($IsolationMode -eq 'shared' -and -not $RemoveDeployment) { throw [ArgumentException]::new('Shared-target execution requires cleanup before releasing the lifecycle lock; removeDeployment must be true.') } $template = ConvertFrom-Json -InputObject $CompiledTemplate -AsHashtable $ci = Get-AvmBicepWorkflowEnvironment -Enabled if (@($ci.Tokens.Keys | Where-Object { $_ -ine 'namePrefix' }).Count -gt 0) { throw [ArgumentException]::new('Reviewed isolation does not allow custom localToken overrides.') } if (-not [string]::IsNullOrEmpty($ci.KeyVaultName)) { throw [ArgumentException]::new('Reviewed isolation does not allow implicit CI Key Vault parameter overrides.') } $parameters = @{} if ($template['parameters'] -is [Collections.IDictionary]) { foreach ($name in $template['parameters'].Keys) { $parameters[$name] = @{ type = 'secureString' } } } $overrides = Get-AvmBicepCiParameter -TemplateParameters $parameters -Variables $ci.Variables -Secrets $ci.Secrets if ($overrides.Count -gt 0) { throw [ArgumentException]::new('Reviewed isolation does not allow CI parameter overrides of compiled test defaults.') } } function Invoke-AvmBicepWorkflowGit { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string[]] $Arguments, [switch] $Remote, [switch] $AllowFailure ) $maximum = if ($Remote) { 3 } else { 1 } $git = (Get-Command -Name git -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source for ($attempt = 1; $attempt -le $maximum; $attempt++) { try { $result = Invoke-AvmProcess -FilePath $git -ArgumentList $Arguments -WorkingDirectory $Root ` -EnvVars @{ GIT_TERMINAL_PROMPT = '0' } -TimeoutSec 120 -IgnoreExitCode } catch [TimeoutException] { if ($attempt -eq $maximum) { throw } Write-Warning "Git $($Arguments[0]) timed out on attempt $attempt of $maximum; retrying the read." Start-Sleep -Seconds (2 * $attempt) continue } if ($result.ExitCode -eq 0) { return $result } if ($attempt -lt $maximum) { Start-Sleep -Seconds (2 * $attempt) } } if ($AllowFailure) { return $result } throw [InvalidOperationException]::new("Git $($Arguments[0]) failed after $maximum attempt(s): $($result.StdErr)") } function Get-AvmBicepWorkflowTestFilesInternal { [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAlignAssignmentStatement', '', Justification = 'Compact workflow matrix fields intentionally use their public JSON names.')] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $ModulePath, [string] $PsRuleFilterRegex = '(defaults|waf-aligned)' ) $module = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath $ModulePath $filter = [regex]::new($PsRuleFilterRegex, [Text.RegularExpressions.RegexOptions]::None, [TimeSpan]::FromSeconds(1)) $files = @(Get-ChildItem -LiteralPath $module -Recurse -File -Filter 'main.test.bicep' | Sort-Object FullName | ForEach-Object { $relative = [IO.Path]::GetRelativePath($module, $_.FullName).Replace('\', '/') $null = Resolve-AvmBicepWorkflowPath -Root $module -RelativePath $relative [ordered]@{ path = $relative name = $_.Directory.Name e2eIgnore = Test-Path -LiteralPath (Join-Path $_.Directory.FullName '.e2eignore') } }) $policyFiles = @($files | Where-Object { $filter.IsMatch($_.path) } | ForEach-Object { [ordered]@{ path = $_.path; name = $_.name } }) return @{ moduleTestFilePaths = ConvertTo-Json -InputObject $files -Compress psRuleModuleTestFilePaths = ConvertTo-Json -InputObject $policyFiles -Compress } } function Get-AvmBicepWorkflowModuleMatrixInternal { [CmdletBinding(DefaultParameterSetName = 'Changed')] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory, ParameterSetName = 'Requested')] [string] $ModulePaths, [Parameter(Mandatory, ParameterSetName = 'Changed')] [AllowEmptyCollection()] [string[]] $ChangedFiles, [switch] $ExcludeMetadataChanges ) $requested = $PSCmdlet.ParameterSetName -eq 'Requested' $paths = if ($requested) { $ModulePaths.Split(',') } else { $ChangedFiles } $modules = [Collections.Generic.SortedSet[string]]::new([StringComparer]::Ordinal) foreach ($path in $paths) { if ([string]::IsNullOrWhiteSpace($path)) { if ($requested) { throw [ArgumentException]::new('Every requested module path must be nonempty.') } continue } $relative = $path.Trim().Replace('\', '/').TrimEnd('/') if ($relative.StartsWith('./', [StringComparison]::Ordinal)) { $relative = $relative.Substring(2) } if ($relative.Split('/') -contains '..' -or $relative -match '[*?\[\]\x00-\x1f]') { throw [ArgumentException]::new('Module paths must not contain parent segments, wildcards or control characters.') } if (-not $requested -and ($relative -imatch '/README\.md$' -or ($ExcludeMetadataChanges -and $relative -imatch '/metadata\.json$'))) { continue } $match = [regex]::Match($relative, '^(avm/(?:res|ptn|utl)/[a-z0-9-]+/[a-z0-9-]+)(?:/|$)') if (-not $match.Success) { if ($requested) { throw [ArgumentException]::new("Not a top-level AVM module: $relative") } continue } $modulePath = $match.Groups[1].Value $source = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath "$modulePath/main.bicep" -AllowMissing if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { if ($requested) { throw [ArgumentException]::new("No top-level AVM module exists at $modulePath.") } continue } $null = $modules.Add($modulePath) } if ($modules.Count -gt 256) { throw [ArgumentException]::new('The selection exceeds the 256-job matrix limit. Use smaller manual module batches.') } $entries = @(foreach ($modulePath in $modules) { $entry = Get-AvmBicepWorkflowTestFilesInternal -Root $Root -ModulePath $modulePath $entry.modulePath = $modulePath $entry }) return @{ include = $entries } } function Get-AvmBicepWorkflowRemoteTags { [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'The function returns the complete remote tag collection.')] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $Pattern ) $result = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('ls-remote', '--tags', 'origin', $Pattern) -Remote $tags = @{} $peeled = @{} foreach ($line in $result.StdOut -split '\r?\n') { if ($line -ceq '') { continue } $match = [regex]::Match($line, '^([0-9a-f]{40})\s+refs/tags/(.+?)(\^\{\})?$') if (-not $match.Success) { throw [InvalidOperationException]::new('Git returned an invalid remote tag record.') } $name = $match.Groups[2].Value if ($match.Groups[3].Success) { $peeled[$name] = $match.Groups[1].Value } else { $tags[$name] = $match.Groups[1].Value } } foreach ($name in $peeled.Keys) { $tags[$name] = $peeled[$name] } return $tags } function Get-AvmBicepWorkflowPublishableChangesInternal { [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [string] $TargetCommit, [string] $BaseCommit, [switch] $InitialRelease, [switch] $ExactModule ) if ($TargetCommit -cnotmatch '^[0-9a-f]{40}$' -or (-not $InitialRelease -and $BaseCommit -cnotmatch '^[0-9a-f]{40}$')) { throw [ArgumentException]::new('Publishable change discovery requires full commit identifiers or an initial release.') } $arguments = if ($InitialRelease) { @('ls-tree', '-r', '--name-only', $TargetCommit, '--', $ModulePath) } else { @('diff', '--no-renames', '--name-only', '--diff-filter=ACMRT', $BaseCommit, $TargetCommit, '--', $ModulePath) } $files = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments $arguments $candidates = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) foreach ($changed in $files.StdOut -split '\r?\n') { if ($changed -cnotmatch '/(?:main|version)\.json$') { continue } $relative = $changed.Substring(0, $changed.LastIndexOf('/')) if ($ExactModule -and $relative -cne $ModulePath) { continue } $path = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath $relative if ((Test-Path -LiteralPath (Join-Path $path 'main.json') -PathType Leaf) -and (Test-Path -LiteralPath (Join-Path $path 'version.json') -PathType Leaf)) { $null = $candidates.Add($relative) } } return @($candidates | Sort-Object @{ Expression = { $_.Length }; Descending = $true }, @{ Expression = { $_ } }) } function Get-AvmBicepWorkflowPendingReleasePlanInternal { [CmdletBinding()] [OutputType([pscustomobject[]])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [string] $TargetCommit ) if ($TargetCommit -cnotmatch '^[0-9a-f]{40}$') { throw [ArgumentException]::new('Pending release discovery requires a full target commit identifier.') } $null = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath $ModulePath $shallow = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('rev-parse', '--is-shallow-repository') if ($shallow.StdOut.Trim() -cne 'false') { throw [InvalidOperationException]::new('Pending release discovery requires complete Git history.') } $files = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('ls-tree', '-r', '--name-only', $TargetCommit, '--', $ModulePath) $paths = @($files.StdOut -split '\r?\n' | Where-Object { $_ -cmatch '/main\.json$' } | ForEach-Object { $_.Substring(0, $_.LastIndexOf('/')) } | Where-Object { Test-Path -LiteralPath (Join-Path -Path $Root -ChildPath $_ -AdditionalChildPath 'version.json') -PathType Leaf } | Sort-Object @{ Expression = { $_.Length }; Descending = $true }, @{ Expression = { $_ } }) $tags = Get-AvmBicepWorkflowRemoteTags -Root $Root -Pattern "$ModulePath/*" $plan = [Collections.Generic.List[object]]::new() foreach ($relative in $paths) { $pattern = "^$([regex]::Escape($relative))/\d+\.\d+\.\d+$" $latest = @($tags.Keys | Where-Object { $_ -cmatch $pattern } | Sort-Object { [version]($_.Substring($_.LastIndexOf('/') + 1)) } -Descending | Select-Object -First 1) $base = if ($latest.Count -eq 0) { '' } else { $tags[$latest[0]] } if ($base) { $available = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('cat-file', '-e', "${base}^{commit}") -AllowFailure if ($available.ExitCode -ne 0) { $null = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('fetch', '--no-tags', 'origin', $base) -Remote $null = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('cat-file', '-e', "${base}^{commit}") } $ancestor = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('merge-base', '--is-ancestor', $base, $TargetCommit) -AllowFailure if ($ancestor.ExitCode -ne 0) { throw [InvalidOperationException]::new("The latest release tag for $relative is not an ancestor of the release target.") } foreach ($file in @('main.json', 'version.json')) { $null = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('cat-file', '-e', "${base}:$relative/$file") } } $changes = @(Get-AvmBicepWorkflowPublishableChangesInternal -Root $Root -ModulePath $relative ` -TargetCommit $TargetCommit -BaseCommit $base -InitialRelease:($latest.Count -eq 0) -ExactModule) if ($changes.Count -gt 0) { $plan.Add([pscustomobject]@{ ModulePath = $relative; BaseCommit = $base; InitialRelease = $latest.Count -eq 0 }) } } return $plan.ToArray() } function Assert-AvmBicepWorkflowReleaseContext { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $TargetCommit ) if ($TargetCommit -cnotmatch '^[0-9a-f]{40}$') { throw [ArgumentException]::new('Release target commits must be full SHA-1 identifiers.') } if ($env:GITHUB_REPOSITORY -cne 'Azure/bicep-registry-modules' -or $env:GITHUB_REF -cne 'refs/heads/main') { throw [InvalidOperationException]::new('Module release tags are restricted to the upstream registry main branch.') } $head = (Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('rev-parse', 'HEAD')).StdOut.Trim() if ($head -cne $TargetCommit) { throw [InvalidOperationException]::new('Release inputs must be inspected from the exact target commit checkout.') } $remote = (Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('remote', 'get-url', '--push', 'origin')).StdOut.Trim() if ($remote -cnotmatch '^https://github\.com/Azure/bicep-registry-modules(?:\.git)?$') { throw [InvalidOperationException]::new('The release remote must be the upstream registry HTTPS URL.') } } function Assert-AvmBicepWorkflowReleaseChangelog { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [string] $Version ) $context = "CHANGELOG.md for $ModulePath release $Version" $path = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath $ModulePath try { $data = Get-AvmBicepVersionInput -Scope @{ Path = $path; ModuleRelativePath = $ModulePath } } catch [IO.IOException], [UnauthorizedAccessException], [Text.DecoderFallbackException], [Management.Automation.CommandNotFoundException], [Management.Automation.ItemNotFoundException] { throw [InvalidOperationException]::new("${context}: cannot read changelog data: $($_.Exception.Message)", $_.Exception) } if ($null -eq $data -or $data.VersionError) { throw [ArgumentException]::new("${context}: cannot inspect valid version.json data.") } if ($data.ChangelogFiles.Count -ne 1 -or $data.ChangelogFiles[0].Name -cne 'CHANGELOG.md' -or $data.ChangelogFiles[0].PSIsContainer -or ($data.ChangelogFiles[0].Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw [ArgumentException]::new("${context}: a regular exact-case changelog file is required.") } if ($data.ChangelogError) { throw [ArgumentException]::new("${context}: changelog is unreadable as UTF-8: $($data.ChangelogError)") } if (@($data.Headings | Where-Object { $null -eq $_.Version }).Count -gt 0) { throw [ArgumentException]::new("${context}: release headings must use '## MAJOR.MINOR.PATCH'.") } $heading = "^## $([regex]::Escape($Version))\s*$" $releases = @($data.Versions | Where-Object { $_.Text -cmatch $heading }) if ($releases.Count -ne 1) { throw [ArgumentException]::new("${context}: exactly one expected-version section is required.") } $sections = $releases[0].Sections foreach ($section in $sections) { if ($section.Positions.Count -ne 1 -or @($section.Content | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -eq 0) { throw [ArgumentException]::new("${context}: exactly one nonempty '$($section.Name)' section is required.") } } if ($sections[0].Positions[0] -ge $sections[1].Positions[0]) { throw [ArgumentException]::new("${context}: Changes must precede Breaking Changes.") } } function Initialize-AvmBicepWorkflowMatrixInternal { [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAlignAssignmentStatement', '', Justification = 'The output fields preserve the established workflow-output contract.')] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [ValidateSet('push', 'pull_request', 'workflow_dispatch')] [string] $EventName, [Parameter(Mandatory)] [string] $TargetCommit, [string] $BaseCommit, [string] $PullRequestHeadCommit, [string] $ModulePaths, [bool] $CreateReleaseTag = $false, [bool] $PublishOnly = $false, [bool] $StaticValidation = $true, [bool] $DeploymentValidation = $true, [bool] $RemoveDeployment = $true, [string] $CustomLocation ) if ($TargetCommit -cnotmatch '^[0-9a-f]{40}$' -or ($EventName -eq 'push' -and -not [string]::IsNullOrEmpty($BaseCommit) -and $BaseCommit -cnotmatch '^[0-9a-f]{40}$')) { throw [ArgumentException]::new('Workflow commits must be full SHA-1 commit identifiers.') } if ($EventName -eq 'pull_request' -and $PullRequestHeadCommit -cnotmatch '^[0-9a-f]{40}$') { throw [ArgumentException]::new('The pull request head must be a full SHA-1 commit identifier.') } if ($PublishOnly -and -not $CreateReleaseTag) { throw [ArgumentException]::new('publishOnly requires createReleaseTag.') } if ($CreateReleaseTag -and -not $PublishOnly -and (-not $StaticValidation -or -not $DeploymentValidation)) { throw [ArgumentException]::new('createReleaseTag requires staticValidation and deploymentValidation unless publishOnly is selected.') } $head = (Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('rev-parse', 'HEAD')).StdOut.Trim() if ($head -cne $TargetCommit) { throw [InvalidOperationException]::new('Module discovery requires the checkout to match targetCommit exactly.') } $includeAll = $false if ($EventName -eq 'workflow_dispatch') { $matrix = Get-AvmBicepWorkflowModuleMatrixInternal -Root $Root -ModulePaths $ModulePaths $BaseCommit = '' } else { if ($EventName -eq 'pull_request') { $history = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('rev-list', '--parents', '-n', '1', $TargetCommit, '--') -AllowFailure $records = @($history.StdOut -split '\r?\n' | Where-Object { $_ -cne '' }) if ($history.ExitCode -ne 0 -or $records.Count -ne 1) { throw [InvalidOperationException]::new('The tested pull request merge commit is unavailable.') } $parents = $records[0].Split(' ', [StringSplitOptions]::RemoveEmptyEntries) if ($parents.Count -ne 3) { throw [InvalidOperationException]::new('The tested pull request merge commit must have exactly two parents.') } if ($parents[0] -cne $TargetCommit -or $parents[1] -cnotmatch '^[0-9a-f]{40}$' -or $parents[2] -cnotmatch '^[0-9a-f]{40}$') { throw [InvalidOperationException]::new('The tested pull request merge history is malformed.') } if ($parents[2] -cne $PullRequestHeadCommit) { throw [InvalidOperationException]::new('The tested merge second parent does not match the pull request head.') } $BaseCommit = $parents[1] } $available = -not [string]::IsNullOrEmpty($BaseCommit) -and $BaseCommit -notmatch '^0+$' if ($available) { $available = (Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('cat-file', '-e', "${BaseCommit}^{commit}") -AllowFailure).ExitCode -eq 0 if (-not $available) { $fetch = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('fetch', '--no-tags', 'origin', $BaseCommit) -Remote -AllowFailure if ($fetch.ExitCode -eq 0) { $available = (Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('cat-file', '-e', "${BaseCommit}^{commit}") -AllowFailure).ExitCode -eq 0 } } } if (-not $available -and $EventName -eq 'pull_request') { throw [InvalidOperationException]::new('The pull request base commit is unavailable.') } if ($available) { $git = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @( 'diff', '--no-renames', '--name-only', '--diff-filter=ACMRTD', $BaseCommit, $TargetCommit, '--', 'avm') } else { Write-Warning 'The push base is unavailable. Selecting all current modules for static validation and release preview only.' $git = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('ls-tree', '-r', '--name-only', $TargetCommit, '--', 'avm') $BaseCommit = '' $includeAll = $true $DeploymentValidation = $false } $changedFiles = @($git.StdOut -split '\r?\n' | Where-Object { $_ -cne '' }) $matrix = Get-AvmBicepWorkflowModuleMatrixInternal -Root $Root -ChangedFiles $changedFiles ` -ExcludeMetadataChanges:($EventName -ne 'pull_request') } return @{ baseCommit = $BaseCommit targetCommit = $TargetCommit hasModules = ($matrix.include.Count -gt 0).ToString().ToLowerInvariant() includeAllVersionedModules = $includeAll.ToString().ToLowerInvariant() moduleMatrix = ConvertTo-Json -InputObject $matrix -Depth 10 -Compress workflowInput = ConvertTo-Json -InputObject @{ customLocation = $CustomLocation staticValidation = $StaticValidation.ToString().ToLowerInvariant() deploymentValidation = $DeploymentValidation.ToString().ToLowerInvariant() removeDeployment = $RemoveDeployment.ToString().ToLowerInvariant() } -Compress } } function Initialize-AvmBicepReleaseMatrixInternal { [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [ValidateSet('workflow_dispatch', 'push')] [string] $EventName, [Parameter(Mandatory)] [string] $TargetCommit, [string] $BaseCommit ) Assert-AvmBicepWorkflowReleaseContext -Root $Root -TargetCommit $TargetCommit $dirty = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('status', '--porcelain', '--untracked-files=all', '--', 'avm') if (-not [string]::IsNullOrWhiteSpace($dirty.StdOut)) { throw [InvalidOperationException]::new('Release discovery requires module files unchanged from the target commit.') } if ($EventName -ceq 'push') { $paths = @(Get-AvmBicepWorkflowPublishableChangesInternal -Root $Root -ModulePath avm ` -TargetCommit $TargetCommit -BaseCommit $BaseCommit) } else { $paths = @(Get-AvmBicepWorkflowPendingReleasePlanInternal -Root $Root -ModulePath avm -TargetCommit $TargetCommit | ForEach-Object { $_.ModulePath }) } $modules = [Collections.Generic.SortedSet[string]]::new([StringComparer]::Ordinal) foreach ($path in $paths) { $match = [regex]::Match($path, '^(avm/(?:res|ptn|utl)/[a-z0-9-]+/[a-z0-9-]+)(?:/|$)') if ($match.Success) { $null = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath "$($match.Groups[1].Value)/main.bicep" $null = $modules.Add($match.Groups[1].Value) } } if ($modules.Count -gt 256) { throw [InvalidOperationException]::new('The release selection exceeds the 256-job matrix limit.') } return @{ include = @(foreach ($path in $modules) { @{ modulePath = $path } }) } } function Set-AvmBicepReleaseTagsInternal { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAlignAssignmentStatement', '', Justification = 'Proposal fields preserve the established release result contract.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseConsistentIndentation', '', Justification = 'The nested process environment hashtable is indented for readable argument grouping.')] [OutputType([pscustomobject[]])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [string] $TargetCommit, [string] $BaseCommit, [switch] $Force, [switch] $IncludeAllVersionedModules, [switch] $PendingRelease, [switch] $RequireChangelog ) if ($TargetCommit -cnotmatch '^[0-9a-f]{40}$' -or (-not [string]::IsNullOrEmpty($BaseCommit) -and $BaseCommit -cnotmatch '^[0-9a-f]{40}$')) { throw [ArgumentException]::new('Release target and base commits must be full SHA-1 identifiers.') } if ($IncludeAllVersionedModules -and -not $WhatIfPreference) { throw [ArgumentException]::new('The missing-base all-module fallback is preview-only.') } if ($PendingRelease -and ($Force -or $IncludeAllVersionedModules -or $BaseCommit)) { throw [ArgumentException]::new('Pending releases cannot override per-module baselines or force unchanged modules.') } Assert-AvmBicepWorkflowReleaseContext -Root $Root -TargetCommit $TargetCommit $module = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath $ModulePath $dirty = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('status', '--porcelain', '--untracked-files=all', '--', $ModulePath) if (-not [string]::IsNullOrWhiteSpace($dirty.StdOut)) { throw [InvalidOperationException]::new('Release module files must be unchanged from the target commit.') } if (-not $PendingRelease -and [string]::IsNullOrEmpty($BaseCommit)) { $parent = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('rev-parse', '--verify', "${TargetCommit}^") -AllowFailure if ($parent.ExitCode -eq 0) { $BaseCommit = $parent.StdOut.Trim() } } elseif ($BaseCommit) { $available = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('cat-file', '-e', "${BaseCommit}^{commit}") -AllowFailure if ($available.ExitCode -ne 0) { $null = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('fetch', '--no-tags', 'origin', $BaseCommit) -Remote $null = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('cat-file', '-e', "${BaseCommit}^{commit}") } } $candidates = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) if ($IncludeAllVersionedModules) { foreach ($file in Get-ChildItem -LiteralPath $module -Recurse -File -Filter main.json) { if (Test-Path -LiteralPath (Join-Path $file.DirectoryName 'version.json') -PathType Leaf) { $null = $candidates.Add([IO.Path]::GetRelativePath($Root, $file.DirectoryName).Replace('\', '/')) } } } elseif (-not [string]::IsNullOrEmpty($BaseCommit)) { foreach ($relative in Get-AvmBicepWorkflowPublishableChangesInternal -Root $Root -ModulePath $ModulePath ` -BaseCommit $BaseCommit -TargetCommit $TargetCommit) { $null = $candidates.Add($relative) } } if ($Force) { foreach ($file in @('main.json', 'version.json')) { $null = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath "$ModulePath/$file" } $null = $candidates.Add($ModulePath) } $plan = if ($PendingRelease) { @(Get-AvmBicepWorkflowPendingReleasePlanInternal -Root $Root -ModulePath $ModulePath -TargetCommit $TargetCommit) } else { @(foreach ($relative in @($candidates | Sort-Object @{ Expression = { $_.Length }; Descending = $true }, @{ Expression = { $_ } })) { [pscustomobject]@{ ModulePath = $relative; BaseCommit = $BaseCommit } }) } $proposals = [Collections.Generic.List[object]]::new() foreach ($candidate in $plan) { $relative = $candidate.ModulePath $candidateBase = $candidate.BaseCommit $versionPath = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath "$relative/version.json" $version = (ConvertFrom-Json -InputObject (Get-Content -LiteralPath $versionPath -Raw)).version if ($version -isnot [string] -or $version -cnotmatch '^\d+\.\d+$') { throw [ArgumentException]::new("The version file for $relative must contain MAJOR.MINOR.") } $remoteTags = Get-AvmBicepWorkflowRemoteTags -Root $Root -Pattern "$relative/$version.*" $valid = @($remoteTags.Keys | Where-Object { $_ -cmatch "^$([regex]::Escape("$relative/$version."))\d+$" }) $existing = @($valid | Where-Object { $remoteTags[$_] -ceq $TargetCommit } | Sort-Object { [version]($_.Substring($_.LastIndexOf('/') + 1)) } -Descending) $reused = $existing.Count -gt 0 if ($reused) { $tag = $existing[0] } else { $changedVersion = $false if (-not [string]::IsNullOrEmpty($candidateBase)) { $previous = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('show', "${candidateBase}:$relative/version.json") -AllowFailure $changedVersion = $previous.ExitCode -ne 0 -or (ConvertFrom-Json -InputObject $previous.StdOut).version -cne $version } $patch = 0 if (-not $changedVersion -and $valid.Count -gt 0) { $patch = 1 + ($valid | ForEach-Object { [int]$_.Substring($_.LastIndexOf('.') + 1) } | Measure-Object -Maximum).Maximum } $tag = "$relative/$version.$patch" } $null = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('check-ref-format', "refs/tags/$tag") if ($remoteTags.ContainsKey($tag) -and $remoteTags[$tag] -cne $TargetCommit) { throw [InvalidOperationException]::new("Release tag $tag already points to a different commit.") } $proposals.Add([pscustomobject]@{ Module = $relative; Tag = $tag; Commit = $TargetCommit; Reused = $reused; Created = $false }) } if ($RequireChangelog) { foreach ($proposal in $proposals) { Assert-AvmBicepWorkflowReleaseChangelog -Root $Root -ModulePath $proposal.Module ` -Version $proposal.Tag.Substring($proposal.Module.Length + 1) } } foreach ($proposal in $proposals) { if ($proposal.Reused -or -not $PSCmdlet.ShouldProcess("$($proposal.Tag) at $TargetCommit", 'Create and push module release tag')) { continue } if ([string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { throw [InvalidOperationException]::new('The release-tag job requires its scoped GitHub token.') } $local = Invoke-AvmBicepWorkflowGit -Root $Root -Arguments @('rev-parse', '-q', '--verify', "refs/tags/$($proposal.Tag)^{}") -AllowFailure if ($local.ExitCode -eq 0 -and $local.StdOut.Trim() -cne $TargetCommit) { throw [InvalidOperationException]::new("Local tag $($proposal.Tag) points to a different commit.") } $git = (Get-Command -Name git -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source if ($local.ExitCode -ne 0) { $null = Invoke-AvmProcess -FilePath $git -WorkingDirectory $Root -ArgumentList @('tag', $proposal.Tag, $TargetCommit) } $authorization = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("x-access-token:$env:GITHUB_TOKEN")) $confirmed = $false $pushEnvironment = @{ GIT_TERMINAL_PROMPT = '0' GIT_CONFIG_COUNT = '1' GIT_CONFIG_KEY_0 = 'http.https://github.com/.extraheader' GIT_CONFIG_VALUE_0 = "AUTHORIZATION: basic $authorization" } try { $push = Invoke-AvmProcess -FilePath $git -WorkingDirectory $Root ` -ArgumentList @('push', 'origin', "refs/tags/$($proposal.Tag):refs/tags/$($proposal.Tag)") -IgnoreExitCode ` -EnvVars $pushEnvironment $confirmed = $push.ExitCode -eq 0 } catch [TimeoutException] { Write-Warning "Publishing $($proposal.Tag) timed out; checking the remote result without replaying the push." } if (-not $confirmed) { $reconciled = Get-AvmBicepWorkflowRemoteTags -Root $Root -Pattern "$($proposal.Tag)*" if (-not $reconciled.ContainsKey($proposal.Tag) -or $reconciled[$proposal.Tag] -cne $TargetCommit) { throw [InvalidOperationException]::new("Publishing $($proposal.Tag) failed; no mutation was retried.") } } $proposal.Created = $true } return $proposals.ToArray() } function Get-AvmBicepWorkflowModuleIdentityInternal { [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAlignAssignmentStatement', '', Justification = 'Identity field names preserve the public E2E context contract.')] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [AllowEmptyString()] [string] $ModuleClientIds, [Parameter(Mandatory)] [AllowEmptyString()] [string] $TenantId ) if ([string]::IsNullOrWhiteSpace($ModuleClientIds)) { throw [ArgumentException]::new('ModuleClientIds must contain the published module client-ID mapping; no shared identity fallback is supported.') } try { $mapping = ConvertFrom-AvmStrictJson -Json $ModuleClientIds -RejectCaseInsensitiveDuplicates } catch [System.Exception] { throw [ArgumentException]::new('ModuleClientIds is invalid; require unique canonical root paths and distinct nonempty client GUIDs.', $_.Exception) } if ($mapping -isnot [Collections.IDictionary]) { throw [ArgumentException]::new('ModuleClientIds must be a JSON object.') } $clients = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($key in $mapping.Keys) { $id = [guid]::Empty if ($key -cnotmatch '^avm/(?:res|ptn|utl)/[a-z0-9-]+/[a-z0-9-]+$' -or $mapping[$key] -isnot [string] -or -not [guid]::TryParseExact($mapping[$key], 'D', [ref]$id) -or $id -eq [guid]::Empty -or -not $clients.Add($id.ToString('D'))) { throw [ArgumentException]::new('ModuleClientIds is invalid; require unique canonical root paths and distinct nonempty client GUIDs.') } } $path = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath $ModulePath $relative = $ModulePath.Replace('\', '/') if ([IO.Path]::GetRelativePath([IO.Path]::GetFullPath($Root), $path).Replace('\', '/') -cne $relative) { throw [ArgumentException]::new('The module path must be canonical without empty or current-directory segments.') } $roots = @($mapping.Keys | Where-Object { $relative -ceq $_ -or $relative.StartsWith("$_/", [StringComparison]::Ordinal) }) if ($roots.Count -ne 1) { throw [ArgumentException]::new("ModuleClientIds must contain exactly one canonical root for '$relative'.") } $tenant = [guid]::Empty if (-not [guid]::TryParseExact($TenantId, 'D', [ref]$tenant) -or $tenant -eq [guid]::Empty) { throw [ArgumentException]::new('TenantId must be the nonempty GUID published with the module identity mapping.') } $client = ([guid]$mapping[$roots[0]]).ToString('D') return [pscustomobject]@{ ModuleRoot = $roots[0] ClientId = $client TenantId = $tenant.ToString('D') IdentityKey = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData( [Text.Encoding]::UTF8.GetBytes("$($roots[0])|$client|$($tenant.ToString('D'))"))).ToLowerInvariant() } } function Select-AvmBicepWorkflowDeploymentSubscriptionInternal { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [string] $Example, [AllowEmptyString()] [string] $PoolJson, [AllowEmptyString()] [string] $FallbackSubscriptionId, [ValidateRange(0, [int]::MaxValue)] [int] $RandomSeed = 0, [ValidateRange(0, [int]::MaxValue)] [int] $CaseIndex = 0 ) if ([string]::IsNullOrEmpty($PoolJson)) { $PoolJson = ConvertTo-Json -InputObject @(@{ id = $FallbackSubscriptionId; name = 'Configured subscription 1' }) -Compress } $selected = @(Select-AvmTestSubscription -Path $Root -ModulePath $ModulePath -Example $Example ` -TestSubscriptionIds $PoolJson -RandomSeed $RandomSeed -CaseIndex $CaseIndex -SkipModuleVersionCheck) if ($selected.Count -ne 1) { throw [InvalidOperationException]::new('Native subscription selection must return exactly one eligible target.') } if ($selected[0].Tag -cne 'General') { throw [InvalidOperationException]::new('Reserved test subscriptions require the registry-local prepared environment lifecycle; shared native deployment cannot authenticate or execute this case.') } $ownerPath = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath $selected[0].ModulePath $null = Get-AvmModuleContext -Path $ownerPath -Ecosystem bicep -SkipModuleVersionCheck return $selected[0] } function Get-AvmBicepWorkflowCompiledTestInternal { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [string] $TestPath, [scriptblock] $ToolResolver, [scriptblock] $ToolInstaller, [scriptblock] $ProcessInvoker ) $module = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath $ModulePath $tool = if ($null -ne $ToolResolver) { & $ToolResolver -Name bicep -Path $module } else { Get-AvmTool -Name bicep -Path $module -SkipModuleVersionCheck } if ($tool.Status -ceq 'not-installed') { if ($null -ne $ToolInstaller) { $null = & $ToolInstaller -Name bicep -Path $module } else { $null = Install-AvmTool -Name bicep -Path $module -SkipModuleVersionCheck -Confirm:$false } $tool = if ($null -ne $ToolResolver) { & $ToolResolver -Name bicep -Path $module } else { Get-AvmTool -Name bicep -Path $module -SkipModuleVersionCheck } } if ($tool.Status -cne 'installed') { throw [InvalidOperationException]::new('The managed Bicep compiler is not installed.') } $path = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath "$ModulePath/$TestPath" $result = if ($null -ne $ProcessInvoker) { & $ProcessInvoker -FilePath $tool.Path -ArgumentList @('build', $path, '--stdout') -WorkingDirectory $Root } else { Invoke-AvmProcess -FilePath $tool.Path -ArgumentList @('build', $path, '--stdout') -WorkingDirectory $Root } $template = ConvertFrom-Json -InputObject $result.StdOut -AsHashtable if ($template -isnot [Collections.IDictionary]) { throw [ArgumentException]::new('The compiled test must be an ARM template object.') } return [string]$result.StdOut } function Get-AvmBicepWorkflowIsolationInternal { [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAlignAssignmentStatement', '', Justification = 'Isolation fields preserve the public E2E context contract.')] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [string] $TestPath, [Parameter(Mandatory)] [string] $TenantId, [Parameter(Mandatory)] [string] $SubscriptionId, [Parameter(Mandatory)] [string] $CompiledTemplate, [Parameter(Mandatory)] [string] $FallbackGroup ) $casePath = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath "$ModulePath/$TestPath" $directory = Split-Path -Path $casePath -Parent $fallback = [pscustomobject]@{ IsolationMode = 'fallback' IsolationBoundary = '' ConcurrencyGroup = $FallbackGroup FallbackModuleLock = Test-AvmBicepWorkflowLegacyModuleLockInternal -Template (ConvertFrom-Json -InputObject $CompiledTemplate -AsHashtable) IsolationDiagnostic = 'Isolation is not established: no case-local isolation.json. Retaining legacy fallback serialization, not claiming ownership.' } $manifestPath = Join-Path $directory 'isolation.json' if (-not (Test-Path -LiteralPath $manifestPath)) { return $fallback } try { $contract = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($manifestPath, [Text.UTF8Encoding]::new($false, $true))) -AsHashtable } catch [System.Exception] { throw [ArgumentException]::new('Isolation evidence must be valid UTF-8 JSON.', $_.Exception) } if ($contract -isnot [Collections.IDictionary] -or @($contract.Keys | Where-Object { $_ -cnotin @('version', 'evidence', 'sharedTarget') }).Count -gt 0 -or ($contract['version'] -isnot [long] -and $contract['version'] -isnot [int]) -or $contract['version'] -ne 1 -or $contract['evidence'] -isnot [Collections.IDictionary] -or @($contract['evidence'].Keys | Where-Object { $_ -notin @('resources', 'cleanup', 'sharedState', 'identity') }).Count -gt 0 -or $contract['evidence'].Count -ne 4) { throw [ArgumentException]::new('Isolation requires version 1 and four reviewed ownership evidence fields.') } foreach ($name in @('resources', 'cleanup', 'sharedState', 'identity')) { if ($contract['evidence'][$name] -isnot [string] -or [string]::IsNullOrWhiteSpace($contract['evidence'][$name])) { throw [ArgumentException]::new("Isolation evidence must explain '$name'.") } } $template = ConvertFrom-Json -InputObject $CompiledTemplate -AsHashtable try { $boundary = Assert-AvmBicepResourceIsolationTemplate -Template $template } catch [AvmConfigurationException] { $fallback.IsolationDiagnostic = "Isolation ownership is not established: $($_.Exception.Message) Retaining legacy fallback serialization." return $fallback } if (-not $boundary -or (Test-Path -LiteralPath (Join-Path $directory 'post.ps1'))) { $fallback.IsolationDiagnostic = 'Isolation ownership is not established. Retaining legacy fallback serialization.' return $fallback } $target = if ($contract.Contains('sharedTarget')) { $contract['sharedTarget'] } else { $null } if ($null -ne $target -and ($target -isnot [string] -or $target -notmatch '^/(?:subscriptions/|providers/)[A-Za-z0-9_()./#-]+$' -or $target.Contains('//'))) { throw [ArgumentException]::new('sharedTarget must identify one absolute ARM resource or singleton setting.') } if ($null -ne $target) { $target = $target.Replace('#_subscriptionId_#', ([guid]$SubscriptionId).ToString('D')) if ($target.Contains('#')) { throw [ArgumentException]::new('sharedTarget contains an unsupported token.') } $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData( [Text.Encoding]::UTF8.GetBytes(("azurecloud|$TenantId|$target").ToLowerInvariant()))).ToLowerInvariant() return [pscustomobject]@{ IsolationMode = 'shared'; IsolationBoundary = $boundary; ConcurrencyGroup = "avm-deploy-target-$hash" FallbackModuleLock = $false IsolationDiagnostic = 'Reviewed isolation with an explicit shared-target lifecycle lock.' } } return [pscustomobject]@{ IsolationMode = 'isolated'; IsolationBoundary = $boundary ConcurrencyGroup = 'avm-deploy-isolated-' + [guid]::NewGuid().ToString('N') FallbackModuleLock = $false IsolationDiagnostic = 'Reviewed resource and cleanup isolation: no serialization lock required.' } } function Get-AvmBicepE2eDeploymentMatrixInternal { [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAlignAssignmentStatement', '', Justification = 'Matrix field names preserve the public E2E context contract.')] [OutputType([pscustomobject[]])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $TestFiles, [AllowEmptyString()] [string] $PoolJson, [AllowEmptyString()] [string] $FallbackSubscriptionId, [AllowEmptyString()] [string] $ModuleClientIds, [AllowEmptyString()] [string] $TenantId, [ValidateRange(0, [int]::MaxValue)] [int] $RandomSeed = 0, [switch] $DisplaySubscriptionNames, [scriptblock] $ToolResolver, [scriptblock] $ToolInstaller, [scriptblock] $ProcessInvoker ) $active = @($TestFiles | Where-Object { -not (ConvertTo-AvmBicepWorkflowBooleanInternal -Value $_.e2eIgnore) }) $identity = if ($active.Count -gt 0) { Get-AvmBicepWorkflowModuleIdentityInternal -Root $Root -ModulePath $ModulePath -ModuleClientIds $ModuleClientIds -TenantId $TenantId } $moduleKey = $ModulePath.Replace('\', '/').Trim('/').ToLowerInvariant() $selections = @{} for ($index = 0; $index -lt $TestFiles.Count; $index++) { $test = $TestFiles[$index] if (-not (ConvertTo-AvmBicepWorkflowBooleanInternal -Value $test.e2eIgnore)) { $selections[$index] = Select-AvmBicepWorkflowDeploymentSubscriptionInternal -Root $Root -ModulePath $ModulePath ` -Example $test.path -PoolJson $PoolJson -FallbackSubscriptionId $FallbackSubscriptionId ` -RandomSeed $RandomSeed -CaseIndex $index } } $result = [Collections.Generic.List[object]]::new() for ($index = 0; $index -lt $TestFiles.Count; $index++) { $test = $TestFiles[$index] if ($test.path -isnot [string] -or $test.name -isnot [string] -or [string]::IsNullOrWhiteSpace($test.name)) { throw [ArgumentException]::new('Every deployment test must contain a path and a single-line name.') } $ignored = ConvertTo-AvmBicepWorkflowBooleanInternal -Value $test.e2eIgnore if ($ignored) { $result.Add([pscustomobject]@{ Path = $test.path; Name = $test.name; E2eIgnore = $true; SubscriptionIndex = $null; SubscriptionKey = '' SelectionSeed = $null; CaseIndex = $index; SubscriptionModulePath = ''; IdentityKey = '' SubscriptionName = 'Deployment disabled'; ConcurrencyGroup = "avm-deploy-$moduleKey-ignored-$([guid]::NewGuid().ToString('N'))" IsolationMode = 'ignored'; IsolationBoundary = ''; IsolationDiagnostic = 'Deployment disabled.' FallbackModuleLock = $false }) continue } $selected = $selections[$index] $compiled = Get-AvmBicepWorkflowCompiledTestInternal -Root $Root -ModulePath $ModulePath -TestPath $test.path ` -ToolResolver $ToolResolver -ToolInstaller $ToolInstaller -ProcessInvoker $ProcessInvoker $isolation = Get-AvmBicepWorkflowIsolationInternal -Root $Root -ModulePath $ModulePath -TestPath $test.path ` -TenantId $identity.TenantId -SubscriptionId $selected.SubscriptionId -CompiledTemplate $compiled ` -FallbackGroup "avm-deploy-$moduleKey-$($selected.Key)" $result.Add([pscustomobject]@{ Path = $test.path; Name = $test.name; E2eIgnore = $false; SubscriptionIndex = $selected.Index; SubscriptionKey = $selected.Key SelectionSeed = $RandomSeed; CaseIndex = $index; SubscriptionModulePath = $selected.ModulePath; IdentityKey = $identity.IdentityKey SubscriptionName = if ($DisplaySubscriptionNames) { $selected.Name } else { "Configured subscription $($selected.Index + 1)" } IsolationMode = $isolation.IsolationMode; ConcurrencyGroup = $isolation.ConcurrencyGroup IsolationBoundary = $isolation.IsolationBoundary; IsolationDiagnostic = $isolation.IsolationDiagnostic FallbackModuleLock = $isolation.FallbackModuleLock }) } $legacyModuleLock = @($result | Where-Object { $_.IsolationMode -eq 'fallback' -and $_.FallbackModuleLock }).Count -gt 0 foreach ($entry in $result) { if ($entry.IsolationMode -eq 'fallback') { $entry.FallbackModuleLock = $legacyModuleLock } } return $result.ToArray() } function Get-AvmBicepWorkflowTestCaseValueInternal { [CmdletBinding()] param( [Parameter(Mandatory)] [object] $TestCase, [Parameter(Mandatory)] [string] $Name, [switch] $Optional ) if ($TestCase -is [Collections.IDictionary]) { $key = @($TestCase.Keys | Where-Object { $_ -ieq $Name } | Select-Object -First 1) if ($key.Count -eq 1) { return $TestCase[$key[0]] } if ($Optional) { return $null } throw [ArgumentException]::new("The selected test case does not contain '$Name'. Rerun selection.") } $property = $TestCase.PSObject.Properties[$Name] if ($null -eq $property -and $Optional) { return $null } if ($null -eq $property) { throw [ArgumentException]::new("The selected test case does not contain '$Name'. Rerun selection.") } return $property.Value } function Get-AvmBicepWorkflowSubscriptionCleanupManagementGroupInternal { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $ModuleRoot, [AllowEmptyString()] [string] $ModuleSubscriptionCleanupManagementGroupIds ) if ([string]::IsNullOrWhiteSpace($ModuleSubscriptionCleanupManagementGroupIds)) { return '' } try { $mapping = ConvertFrom-AvmStrictJson -Json $ModuleSubscriptionCleanupManagementGroupIds -RejectCaseInsensitiveDuplicates } catch [System.Exception] { throw [ArgumentException]::new('Module subscription cleanup management groups must be valid JSON.', $_.Exception) } $root = 'avm/ptn/lz/sub-vending' if ($mapping -isnot [Collections.IDictionary] -or $mapping.Count -gt 1 -or ($mapping.Count -eq 1 -and -not $mapping.Contains($root)) -or ($mapping.Contains($root) -and ( $mapping[$root] -isnot [string] -or $mapping[$root] -cnotmatch '^[A-Za-z0-9_().-]{1,90}$' -or $mapping[$root] -in @('.', '..') -or $mapping[$root].EndsWith('.')))) { throw [ArgumentException]::new('Module subscription cleanup management groups must contain only one valid canonical target.') } if ($ModuleRoot -ceq $root) { return [string]$mapping[$root] } return '' } function Get-AvmBicepE2eTestContextInternal { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $Root, [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [object] $TestCase, [AllowEmptyString()] [string] $PoolJson, [AllowEmptyString()] [string] $FallbackSubscriptionId, [Parameter(Mandatory)] [AllowEmptyString()] [string] $ModuleClientIds, [Parameter(Mandatory)] [AllowEmptyString()] [string] $TenantId, [AllowEmptyString()] [string] $ModuleSubscriptionCleanupManagementGroupIds, [AllowEmptyString()] [string] $NamePrefix, [scriptblock] $ToolResolver, [scriptblock] $ToolInstaller, [scriptblock] $ProcessInvoker ) $identity = Get-AvmBicepWorkflowModuleIdentityInternal -Root $Root -ModulePath $ModulePath -ModuleClientIds $ModuleClientIds -TenantId $TenantId $subscriptionCleanupManagementGroupId = Get-AvmBicepWorkflowSubscriptionCleanupManagementGroupInternal ` -ModuleRoot $identity.ModuleRoot -ModuleSubscriptionCleanupManagementGroupIds $ModuleSubscriptionCleanupManagementGroupIds $caseIdentityKey = Get-AvmBicepWorkflowTestCaseValueInternal -TestCase $TestCase -Name IdentityKey -Optional if ($caseIdentityKey -cne $identity.IdentityKey) { throw [ArgumentException]::new('The selected module identity no longer matches its root, client and tenant. Rerun selection.') } $seed = [int](Get-AvmBicepWorkflowTestCaseValueInternal -TestCase $TestCase -Name SelectionSeed) $index = [int](Get-AvmBicepWorkflowTestCaseValueInternal -TestCase $TestCase -Name CaseIndex) $path = Get-AvmBicepWorkflowTestCaseValueInternal -TestCase $TestCase -Name Path $selected = Select-AvmBicepWorkflowDeploymentSubscriptionInternal -Root $Root -ModulePath $ModulePath -Example $path ` -PoolJson $PoolJson -FallbackSubscriptionId $FallbackSubscriptionId -RandomSeed $seed -CaseIndex $index $caseSubscriptionIndex = Get-AvmBicepWorkflowTestCaseValueInternal -TestCase $TestCase -Name SubscriptionIndex -Optional $caseSubscriptionKey = Get-AvmBicepWorkflowTestCaseValueInternal -TestCase $TestCase -Name SubscriptionKey $caseSubscriptionModulePath = Get-AvmBicepWorkflowTestCaseValueInternal -TestCase $TestCase -Name SubscriptionModulePath if ($null -eq $caseSubscriptionIndex -or $selected.Index -ne [int]$caseSubscriptionIndex -or $selected.Key -cne $caseSubscriptionKey -or $selected.ModulePath -cne $caseSubscriptionModulePath) { throw [ArgumentException]::new('The selected subscription no longer matches the original pool index and digest. Rerun selection.') } $testPath = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath "$ModulePath/$path" $ownerPath = Resolve-AvmBicepWorkflowPath -Root $Root -RelativePath $selected.ModulePath $example = [IO.Path]::GetRelativePath($ownerPath, $testPath).Replace('\', '/') $compiled = Get-AvmBicepWorkflowCompiledTestInternal -Root $Root -ModulePath $ModulePath -TestPath $path ` -ToolResolver $ToolResolver -ToolInstaller $ToolInstaller -ProcessInvoker $ProcessInvoker $caseConcurrencyGroup = Get-AvmBicepWorkflowTestCaseValueInternal -TestCase $TestCase -Name ConcurrencyGroup $caseIsolationMode = Get-AvmBicepWorkflowTestCaseValueInternal -TestCase $TestCase -Name IsolationMode $caseFallbackModuleLock = Get-AvmBicepWorkflowTestCaseValueInternal -TestCase $TestCase -Name FallbackModuleLock -Optional $moduleKey = $ModulePath.Replace('\', '/').Trim('/').ToLowerInvariant() $fallbackGroup = "avm-deploy-$moduleKey-$($selected.Key)" $isolation = Get-AvmBicepWorkflowIsolationInternal -Root $Root -ModulePath $ModulePath -TestPath $path ` -TenantId $identity.TenantId -SubscriptionId $selected.SubscriptionId -CompiledTemplate $compiled ` -FallbackGroup $fallbackGroup if ($isolation.IsolationMode -cne $caseIsolationMode -or ($isolation.IsolationMode -ne 'isolated' -and $isolation.ConcurrencyGroup -cne $caseConcurrencyGroup) -or ($isolation.IsolationMode -eq 'fallback' -and $isolation.FallbackModuleLock -and $caseFallbackModuleLock -ne $true) -or ($isolation.IsolationMode -eq 'isolated' -and $caseConcurrencyGroup -cnotmatch '^avm-deploy-isolated-[0-9a-f]{32}$')) { throw [ArgumentException]::new('Isolation evidence or lifecycle lock changed after selection. Rerun selection before authentication.') } $prefix = Get-AvmBicepWorkflowNamePrefixInternal -IsolationMode $isolation.IsolationMode -Prefix $NamePrefix return [pscustomobject]@{ SubscriptionId = $selected.SubscriptionId; TenantId = $identity.TenantId; ClientId = $identity.ClientId ModulePath = $selected.ModulePath; Example = $example; NamePrefix = $prefix ArtifactKey = "bicep-e2e-$($selected.Key)-$index"; IsolationMode = $isolation.IsolationMode ConcurrencyGroup = $isolation.ConcurrencyGroup; RequireResourceIsolation = $isolation.IsolationMode -ne 'fallback' SubscriptionCleanupManagementGroupId = $subscriptionCleanupManagementGroupId } } # SIG # Begin signature block # MIInQQYJKoZIhvcNAQcCoIInMjCCJy4CAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCATI8afLy/sXCkl # FOp0tFthQ8sBTRC6pvidnc4WP+L22qCCDLowggX1MIID3aADAgECAhMzAAACHU0Z # yE7XD1dIAAAAAAIdMA0GCSqGSIb3DQEBCwUAMFcxCzAJBgNVBAYTAlVTMR4wHAYD # VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBD # b2RlIFNpZ25pbmcgUENBIDIwMjQwHhcNMjYwNDE2MTg1OTQzWhcNMjcwNDE1MTg1 # OTQzWjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE # BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYD # VQQDExVNaWNyb3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IB # DwAwggEKAoIBAQDQvewXxx9gZZFC6Ys1WBay8BJ8kGA4JQnH5CMafqOASlTpK9H8 # o5ZXTXt0caVQTNMUPt445wXYD+dFtaKWTwDn1I52oUSrC9vJin1Gsqt+zyKJL5Dg # 3eQXbQNR61DmMy20GLTIO3SFed9Rfi/ophgCLGFLDR3r0KvHjwMb/jYWS0celV/4 # Lz27LfAekm8v9E5IXaeiXbAUYZKK090n4CVl3JBtbN+9DtI9SNu/yjvozW52/u7R # X/Ttpa/KDlpuokZ+Zcbvmtd9ur9gFLvZzh41o9MsE/clQtdaFWGvuo6Jua/ntpgk # ey3E5/vBFe+MJPG6phdnuo6r57ZudCudiI1bAgMBAAGjggGbMIIBlzAOBgNVHQ8B # Af8EBAMCB4AwHwYDVR0lBBgwFgYKKwYBBAGCN0wIAQYIKwYBBQUHAwMwHQYDVR0O # BBYEFH6QuMwqcPG0hQlQ6c5jCtTTLrVeMEUGA1UdEQQ+MDykOjA4MR4wHAYDVQQL # ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xFjAUBgNVBAUTDTIzMDAxMis1MDc1NTkw # HwYDVR0jBBgwFoAUf1k/VCHarU/vBeXmo9ctBpQSCDEwYAYDVR0fBFkwVzBVoFOg # UYZPaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvTWljcm9zb2Z0 # JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNybDBtBggrBgEFBQcBAQRh # MF8wXQYIKwYBBQUHMAKGUWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv # Y2VydHMvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNy # dDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQBKTbYOjzwTG/DXGaz9 # s6+fQeaTtDcFmMY+5UyVFCyj7Pv+5i37qfX8lSL/tBIfYQfWsMuBQlfZurJD6r4H # VJ2CeH+1fgiq8dcHdVKoZ3Sa2qXoX3cq9iS8cVb06B7+5/XJ7I0OxHH9fDsvJ3T3 # w5V/ZtAIFmLrl+P0CtG+92uzRsn0nTbdFjOkLMLWPLAU3THohKRlSEMgFJpPkm5n # 5UAZ35xX6FWCrDLsSKb555bTifwa8mJBwdlof0bmfYidH+dxZ1FdDxvLnNl9zeKs # A4kejaaIqqIPguhwAti5Ql7BlTNoJNwxCvBmqW2MQLnCkYN/VVUsR3V2x/rcTNzo # Bf/Z/SpROvdaA2ZOOd1uioXJt3tdLQ7vHpqpib0KfWr/FWXW10q38VxfCnRQBqzb # SuztR7nEMuzX7Ck+B/XaPDXd1qh72+QYyB0Z2VzWmO9zsnb9Uq/dwu8LGeQqnyu6 # 7SDGACvnXii2fb9+US492VTnXSnFKyqwgzUyFMtZK1/sHYTv6bG4TtQUygQxTN+Z # V+aJIlKO2MqZ7bKrAnOzS9m6NgoTdWOq11bTOZwKlIEV/EhV9SWkDmdpR/hPPT2v # 6TEj4F8PT/zHjRezIU5c/DGlt/VhY/pK0XkJtEyMmmS1BMtjU/rqBZVMIm3dnxQs # /TBByr+Cf8Z1r7aifQVQ+WSqzjCCBr0wggSloAMCAQICEzMAAAA5O7Y3Gb8GHWcA # AAAAADkwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpX # YXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQg # Q29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRl # IEF1dGhvcml0eSAyMDExMB4XDTI0MDgwODIwNTQxOFoXDTM2MDMyMjIyMTMwNFow # VzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEo # MCYGA1UEAxMfTWljcm9zb2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAyNDCCAiIwDQYJ # KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgBnB7jOMeqlRYHNa265v4IY9fH8TKh # emHfPINe1gpLaV3dhg324WwH06LcHbpnsBukCDNitryo0dtS/EW6I/yEL/bLSY8h # KpbfQuWusBPr9qazYcDxCW/qnjb5JsI1s8bNOg3bVATvQVL4tcf03aTycsz8QeCd # M0l/yHRObJ9QqazM1r6VPEOJ7LL+uEEb73w6QCuhs89a1uv1zerOYMnsneRRwCbp # yW11IcggU0cRKDDq1pjVJzIbIF6+oiXXbReOsgeI8zu1FyQfK0fVkaya8SmVHQ/t # Of23mZ4W9k0Ri22QW9p3UgSC5OUDktKxxcCmGL6tXLfOGSWHIIV4YrTJTT6PNty5 # REojHJuZHArkF9VnHTERWoTjAzfI3kP+5b4alUdhgAZ7ttOu1bVnXfHaqPYl2rPs # 20ji03LOVWsh/radgE17es5hL+t6lV0eVHrVhsssROWJuz2MXMCt7iw7lFPG9LXK # Gjsmonn2gotGdHIuEg5JnJMJVmixd5LRlkmgYRZKzhxSCwyoGIq0PhaA7Y+VPct5 # pCHkijcIIDm0nlkK+0KyepolcqGm0T/GYQRMhHJlGOOmVQop36wUVUYklUy++vDW # eEgEo4s7hxN6mIbf2MSIQ/iIfMZgJxC69oukMUXCrOC3SkE/xIkgpfl22MM1itkZ # 35nNXkMolU1lAgMBAAGjggFOMIIBSjAOBgNVHQ8BAf8EBAMCAYYwEAYJKwYBBAGC # NxUBBAMCAQAwHQYDVR0OBBYEFH9ZP1Qh2q1P7wXl5qPXLQaUEggxMBkGCSsGAQQB # gjcUAgQMHgoAUwB1AGIAQwBBMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU # ci06AjGQQ7kUBU7h6qfHMdEjiTQwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDovL2Ny # bC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0MjAx # MV8yMDExXzAzXzIyLmNybDBeBggrBgEFBQcBAQRSMFAwTgYIKwYBBQUHMAKGQmh0 # dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljUm9vQ2VyQXV0MjAx # MV8yMDExXzAzXzIyLmNydDANBgkqhkiG9w0BAQwFAAOCAgEAFJQfOChP7onn6fLI # MKrSlN1WYKwDFgAddymOUO3FrM8d7B/W/iQ6DxXsDn7D5W4wMwYeLystcEqfkjz4 # NURRgazyMu5yRzQh4LqjA4tStTcJh1opExo7nn5PuPBYnbu0+THSuVHTe0VTTPVh # ily/piFrDo3axQ9P4C+Ol5yet+2gTfekICS5xS+cYfSIvgn0JksVBVMYVI5QFu/q # hnLhsEFEUzG8fvv0hjgkO+lkpV9ty6GkN4vdnd7ya6Q6aR9y34aiM1qmxaxBi6OU # nyNl6fkuun/diTFnYDLTppOkr/mg5WSfCiDVMNCxtj4wPKC5OmHm1DQIt/MNokbb # H3UGsFP1QbzsLocuSqLCvH09Io3fDPTmscR9Y75G4qX7RTX8AdBPo0I6OEojf39z # uFZt0qOHm65YWQE69cZM2ueE1MB05dNNgHK9gTE7zKvK/fg8B2qjW88MT/WF5V5u # vZGtqa9FSL2RazArA+rDPuf6JGYz4HpgMZHB4S6szWSKYBv0VisCzfxgeU+dquXW # 9bd0auYlOB58DPcOYKdc3Se94g+xL4pcEhbB54JOgAkwYTu/9dLeH2pDqeJZAABV # DWRQCaXfO5LgyKwKCLYXpigrZYCjUSBcr+Ve8PFWMhVTQl0v4q8J/AUmQN5W4n10 # 1cY2L4A7GTQG1h32HHAvfQESWP0xghndMIIZ2QIBATBuMFcxCzAJBgNVBAYTAlVT # MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jv # c29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMjQCEzMAAAIdTRnITtcPV0gAAAAAAh0w # DQYJYIZIAWUDBAIBBQCggZAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwLwYJ # KoZIhvcNAQkEMSIEIPpy4K/UOXDeUmz2JUneD2XGYEm0EbpVrTHt/6emIyu+MEIG # CisGAQQBgjcCAQwxNDAyoBSAEgBNAGkAYwByAG8AcwBvAGYAdKEagBhodHRwOi8v # d3d3Lm1pY3Jvc29mdC5jb20wDQYJKoZIhvcNAQEBBQAEggEAVVCbKgRQH0O2F5N/ # xLYiJJCfMpqHcRRQEOSDOjOWzO6rU2WBwPSiQTJe9PFSJupnNgvvaHuHFJVr8AJw # lUomMUwalnX3iM3ox2H//SkURwgUjiYRv2RoBkTzD2qwPwj2ulyfvtBNvnSBOl2U # 9klA0bvypJInKlJfoxwTlSX0bQ8cyWiJyH/+XJ2aFTqR1Yz3UBiTwLKc+lxoM87G # bJgGA5vSH72fNIV9pzlwz4fCO1pt81CSIf6ZJUzeSWAdOuM2VVZVNKaA1dCNfRyw # O3ACp2JT/ZB6GZ1FIF56+0U3OKtdY/qflm0h2QVXtO7R0Gl4wUWFVjYX8ojBPALy # e0uQUaGCF60wghepBgorBgEEAYI3AwMBMYIXmTCCF5UGCSqGSIb3DQEHAqCCF4Yw # gheCAgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFaBgsqhkiG9w0BCRABBKCCAUkEggFF # MIIBQQIBAQYKKwYBBAGEWQoDATAxMA0GCWCGSAFlAwQCAQUABCAVcT/PJqVIPTLj # rKoXzPE4E3eRCN3enwlicXTqAcXxygIGatx0qO/sGBMyMDI2MTEwMjIwMjUzOS4w # MTlaMASAAgH0oIHZpIHWMIHTMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu # Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv # cmF0aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQgSXJlbGFuZCBPcGVyYXRpb25zIExp # bWl0ZWQxJzAlBgNVBAsTHm5TaGllbGQgVFNTIEVTTjo0MDFBLTA1RTAtRDk0NzEl # MCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2VydmljZaCCEfswggcoMIIF # EKADAgECAhMzAAACGV6y2FR19LGNAAEAAAIZMA0GCSqGSIb3DQEBCwUAMHwxCzAJ # BgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25k # MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jv # c29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMB4XDTI1MDgxNDE4NDgyNloXDTI2MTEx # MzE4NDgyNlowgdMxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw # DgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x # LTArBgNVBAsTJE1pY3Jvc29mdCBJcmVsYW5kIE9wZXJhdGlvbnMgTGltaXRlZDEn # MCUGA1UECxMeblNoaWVsZCBUU1MgRVNOOjQwMUEtMDVFMC1EOTQ3MSUwIwYDVQQD # ExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNlMIICIjANBgkqhkiG9w0BAQEF # AAOCAg8AMIICCgKCAgEApqFIyUkzIyxpL3Q03WmLuy4G9YIUScznhKr+cHOT+/u7 # ParxI96gxxb1WrWuAxB8qjGLfsbImx8V3ouK1nUcf+R/nsnXas5/iTgV/Tl3QTRG # T0DeuXBNbpHqc+wC1NiTyA76gLnirvSBEoBzlrpNQFEnuwdbPLCLpTS3KWSCu5J0 # 2b+RFWR/kcFzVxnhoE3gIaeURtrGKGBZGKLBXvqggkDENtKkvtvRT32xLvAvL/Rp # Reu5z18ZojCs72ZSoa74Dy8YbaWsDm3OZOpJRZxZsPKCHZ6xNqgFKf0xNHj0t9v0 # Q3W+2z5gAVaasJJCvR52Sl0XJ2AOf3l0LSetXgUA5gD5IQ1RvEslTmNnSouTrGID # 3D1njY7mBu0puiIdPK2jK/1Weef2+YR4cQpWQkeBZmXidh9AuWdlwxKQL15LJ6K2 # dw8y/t/PBhmLyt6QAf0CepWRdgZnMytVAUuWHwlZRV9JLY7aX8D55eL9+cOLpX3b # GNOmN24UpIW8qtZaqXaesFvIOW23JNLhaaQVvObr1eu7GE/5Mn43e+/DbtdYl/bL # P2IQ1xYEJdSbcUkDFfW3KlZEh+nBKDtaRnNRkbgIgxIbKdT38OKQwZ/aA4uSsiAg # 6nEPiWBHGuytIo5wU75M5VdjhEqqTHfXYu8BJi6GTzvWT+9ekfMXezqCkksxaG8C # AwEAAaOCAUkwggFFMB0GA1UdDgQWBBSAaOo5HWatNzqZn1IF1fcD6nr3ITAfBgNV # HSMEGDAWgBSfpxVdAF5iXYP05dJlpxtTNRnpcjBfBgNVHR8EWDBWMFSgUqBQhk5o # dHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNyb3NvZnQlMjBU # aW1lLVN0YW1wJTIwUENBJTIwMjAxMCgxKS5jcmwwbAYIKwYBBQUHAQEEYDBeMFwG # CCsGAQUFBzAChlBodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRz # L01pY3Jvc29mdCUyMFRpbWUtU3RhbXAlMjBQQ0ElMjAyMDEwKDEpLmNydDAMBgNV # HRMBAf8EAjAAMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMA4GA1UdDwEB/wQEAwIH # gDANBgkqhkiG9w0BAQsFAAOCAgEAXxzVZLLXBFfoCCCTiY7MHXdb7civJSTfrHYJ # C5Ok2NN75NpzTMT9V2TcIQjfQ3AFUbh1NBAYtMUuwxC6D4ceEXG5lXAnbvkC9Yje # LVDRyImXYYmft7z+Qpl9t3C/8a0tiqnOz8Ue8/DYLtMTgvWMnsqLNjILDaImOfnH # I36TLCjGFe8RYLXGdCUdOLlfAdMGePxSTA3TAAOc+GQbmPWjrguLWbxvnl3NVjRv # rBZVkxFMoVZH0f7qGwDOShjpnv5nYnQ48ufL0uBz52RbPGdX4Fv9+UGOrBprmcHz # mIutFtJec2Y4kujNtTK2wBGgWscEOVhFiaVdje8VLJ7MVNKE5TmsuGM3jTLr1nuR # 5AFGs3UKkP7g3cQD4cHK7XdLiTm7e606QJ+WqeQsADYE9dvU9wIUbI9Dl4UcIErF # w+FHaWSTrkfJ4SvLmhKnl5khhpJ1sF3z6e1BxepUliXHqzRLiHWihWIWESF8IHEl # F3POxbP4VJqHBiYvaXMV0SyRgwoD6zXddbUnX9WR6JL2BlqAjjHxINwelsp/VhxA # WThzuMA58LxvE/VAzjfFF4Wm7a1ZALmJVw3oL/s/uxo1Op4tcT+hfZ9uN1htC1JN # 4DuRqFfLttjuoAmUQobO5zUFRzvCn8Ck/hiO+bzR15sqkjlxLMyMjpkc/ef4SUUi # kD468vUwggdxMIIFWaADAgECAhMzAAAAFcXna54Cm0mZAAAAAAAVMA0GCSqGSIb3 # DQEBCwUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4G # A1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTIw # MAYDVQQDEylNaWNyb3NvZnQgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAx # MDAeFw0yMTA5MzAxODIyMjVaFw0zMDA5MzAxODMyMjVaMHwxCzAJBgNVBAYTAlVT # MRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQK # ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1l # LVN0YW1wIFBDQSAyMDEwMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA # 5OGmTOe0ciELeaLL1yR5vQ7VgtP97pwHB9KpbE51yMo1V/YBf2xK4OK9uT4XYDP/ # XE/HZveVU3Fa4n5KWv64NmeFRiMMtY0Tz3cywBAY6GB9alKDRLemjkZrBxTzxXb1 # hlDcwUTIcVxRMTegCjhuje3XD9gmU3w5YQJ6xKr9cmmvHaus9ja+NSZk2pg7uhp7 # M62AW36MEBydUv626GIl3GoPz130/o5Tz9bshVZN7928jaTjkY+yOSxRnOlwaQ3K # Ni1wjjHINSi947SHJMPgyY9+tVSP3PoFVZhtaDuaRr3tpK56KTesy+uDRedGbsoy # 1cCGMFxPLOJiss254o2I5JasAUq7vnGpF1tnYN74kpEeHT39IM9zfUGaRnXNxF80 # 3RKJ1v2lIH1+/NmeRd+2ci/bfV+AutuqfjbsNkz2K26oElHovwUDo9Fzpk03dJQc # NIIP8BDyt0cY7afomXw/TNuvXsLz1dhzPUNOwTM5TI4CvEJoLhDqhFFG4tG9ahha # YQFzymeiXtcodgLiMxhy16cg8ML6EgrXY28MyTZki1ugpoMhXV8wdJGUlNi5UPkL # iWHzNgY1GIRH29wb0f2y1BzFa/ZcUlFdEtsluq9QBXpsxREdcu+N+VLEhReTwDwV # 2xo3xwgVGD94q0W29R6HXtqPnhZyacaue7e3PmriLq0CAwEAAaOCAd0wggHZMBIG # CSsGAQQBgjcVAQQFAgMBAAEwIwYJKwYBBAGCNxUCBBYEFCqnUv5kxJq+gpE8RjUp # zxD/LwTuMB0GA1UdDgQWBBSfpxVdAF5iXYP05dJlpxtTNRnpcjBcBgNVHSAEVTBT # MFEGDCsGAQQBgjdMg30BATBBMD8GCCsGAQUFBwIBFjNodHRwOi8vd3d3Lm1pY3Jv # c29mdC5jb20vcGtpb3BzL0RvY3MvUmVwb3NpdG9yeS5odG0wEwYDVR0lBAwwCgYI # KwYBBQUHAwgwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwCwYDVR0PBAQDAgGG # MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU1fZWy4/oolxiaNE9lJBb186a # GMQwVgYDVR0fBE8wTTBLoEmgR4ZFaHR0cDovL2NybC5taWNyb3NvZnQuY29tL3Br # aS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0XzIwMTAtMDYtMjMuY3JsMFoGCCsG # AQUFBwEBBE4wTDBKBggrBgEFBQcwAoY+aHR0cDovL3d3dy5taWNyb3NvZnQuY29t # L3BraS9jZXJ0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcnQwDQYJKoZIhvcN # AQELBQADggIBAJ1VffwqreEsH2cBMSRb4Z5yS/ypb+pcFLY+TkdkeLEGk5c9MTO1 # OdfCcTY/2mRsfNB1OW27DzHkwo/7bNGhlBgi7ulmZzpTTd2YurYeeNg2LpypglYA # A7AFvonoaeC6Ce5732pvvinLbtg/SHUB2RjebYIM9W0jVOR4U3UkV7ndn/OOPcbz # aN9l9qRWqveVtihVJ9AkvUCgvxm2EhIRXT0n4ECWOKz3+SmJw7wXsFSFQrP8DJ6L # GYnn8AtqgcKBGUIZUnWKNsIdw2FzLixre24/LAl4FOmRsqlb30mjdAy87JGA0j3m # Sj5mO0+7hvoyGtmW9I/2kQH2zsZ0/fZMcm8Qq3UwxTSwethQ/gpY3UA8x1RtnWN0 # SCyxTkctwRQEcb9k+SS+c23Kjgm9swFXSVRk2XPXfx5bRAGOWhmRaw2fpCjcZxko # JLo4S5pu+yFUa2pFEUep8beuyOiJXk+d0tBMdrVXVAmxaQFEfnyhYWxz/gq77EFm # PWn9y8FBSX5+k77L+DvktxW/tM4+pTFRhLy/AsGConsXHRWJjXD+57XQKBqJC482 # 2rpM+Zv/Cuk0+CQ1ZyvgDbjmjJnW4SLq8CdCPSWU5nR0W2rRnj7tfqAxM328y+l7 # vzhwRNGQ8cirOoo6CGJ/2XBjU02N7oJtpQUQwXEGahC0HVUzWLOhcGbyoYIDVjCC # Aj4CAQEwggEBoYHZpIHWMIHTMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu # Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv # cmF0aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQgSXJlbGFuZCBPcGVyYXRpb25zIExp # bWl0ZWQxJzAlBgNVBAsTHm5TaGllbGQgVFNTIEVTTjo0MDFBLTA1RTAtRDk0NzEl # MCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2VydmljZaIjCgEBMAcGBSsO # AwIaAxUAMXYp/Wqqdyb0enigrLfxl0InAz6ggYMwgYCkfjB8MQswCQYDVQQGEwJV # UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE # ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGlt # ZS1TdGFtcCBQQ0EgMjAxMDANBgkqhkiG9w0BAQsFAAIFAO6Sz6kwIhgPMjAyNjEx # MDIwOTAwMjVaGA8yMDI2MTEwMzA5MDAyNVowdDA6BgorBgEEAYRZCgQBMSwwKjAK # AgUA7pLPqQIBADAHAgEAAgIRiDAHAgEAAgITNTAKAgUA7pQhKQIBADA2BgorBgEE # AYRZCgQCMSgwJjAMBgorBgEEAYRZCgMCoAowCAIBAAIDB6EgoQowCAIBAAIDAYag # MA0GCSqGSIb3DQEBCwUAA4IBAQC0u1Lkq4X1FfzrLEWsQnZEhdAOw0blVlO2kocq # 4/V4RYlvzvwePrx/7QNtDGvHxwlo0WcS98OM8HerO2Be0Gf2W/O956CRdgsLMueC # 3lcpkh3PrA5C1W8Gk0914isEDXJoBZksB53sPC8IfkvmpqHp8LthDcDJO2GbI+JH # jFkwXZTHvFuPoLVR7zaKOxV/SykoPQ9UMdTmMp9DDfq8vBwhRTqRKemJ5qReT9Zr # 8I8xjoGwXBL6S5iiCds1EMdiR/bw+KEVpqujd20sQUjB33pNU7n3A/H1JvGrhh7A # j7Ot5JnRFsfpNBKeFxclJPx5XgD/VrKoqoQYBTBQBrhvmsOSMYIEDTCCBAkCAQEw # gZMwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcT # B1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UE # AxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAIZXrLYVHX0sY0A # AQAAAhkwDQYJYIZIAWUDBAIBBQCgggFKMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0B # CRABBDAvBgkqhkiG9w0BCQQxIgQgkC+HEXK9pyOjZMdQ4nJpVL5n621TVtCh3sEe # 73aZuikwgfoGCyqGSIb3DQEJEAIvMYHqMIHnMIHkMIG9BCDckX633E1y1EF32V18 # zQcrsgjzI9+3Le7mlvk2OebthjCBmDCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1w # IFBDQSAyMDEwAhMzAAACGV6y2FR19LGNAAEAAAIZMCIEIOZAy2CjR0CR+bOJ8osK # ptYjTlynL0/WfxRWQRDYIfcAMA0GCSqGSIb3DQEBCwUABIICACfZLEiDn3/mo75X # pNFhzAFz02YiWaHl5AWhvxhn9TjzizkxJ+9PkNNGlk7e9SCULoFAtkhmKDFXnR+L # kHUVXP5LhXsM8IamdPsVRpVFlf7+9c1e6dpCMIJ/l/BGteGAiL9XR8wvreXUg4fG # fmzq11nBvd66K2PWhXeUYgJ68mTWjlUauBN6O+4t3eHT3I3PvAtxkM/nhLMCZBGC # LVSsqwHNtMVhtVZeWTBkv8HNFD6NkeucaJlpyf0NZMaQBqyh/YC8GA9M7HD7Pe1P # W7znSiPgKtLLY60ZAjsNsN05Bg65WbU72jNwoZpPBgsWXWqbOEapNXPAlHpIT2YW # R3D4HaKS4TRdNeb0QlTZ+wtlVtpjqbCwgFiO/ULy6+5LxmfBETIO+pzPK+ZcZK1O # gDUEx/37HY92H06nbInv36EcbZvfw7Ymxi3pUxprJza9dpJ+vNenezEVAj6CRoNi # Mz/tQdxvYlH5IGo5fGCbBp6zBYVsmR66kbDfQw0VKqrRMsdoB2mZYXbYxKzEmig9 # HjqXN6Ub1Xx0I2js0twnX3VEUFh2SbSnGur3/IWww7RnZaFNAdLEUamCTnJdwiVW # dPEdygRF8ZP9uk3bfMgFNJbVpLkR7EOUSzFxr7Gvg2+85pt5Nw6Bx8ioHMzTlttZ # xHwjPAi5nBHlVePN0zPco92SBuJ0 # SIG # End signature block |