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-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] $NamePrefix, [scriptblock] $ToolResolver, [scriptblock] $ToolInstaller, [scriptblock] $ProcessInvoker ) $identity = Get-AvmBicepWorkflowModuleIdentityInternal -Root $Root -ModulePath $ModulePath -ModuleClientIds $ModuleClientIds -TenantId $TenantId $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' } } # SIG # Begin signature block # MIInUwYJKoZIhvcNAQcCoIInRDCCJ0ACAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAXbl9ifAYrwNkR # NEgcPM7YQUyhTlx27ImDLz2Ph5/dK6CCDMkwggYEMIID7KADAgECAhMzAAACHPrN # xZvoL37EAAAAAAIcMA0GCSqGSIb3DQEBCwUAMFcxCzAJBgNVBAYTAlVTMR4wHAYD # VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBD # b2RlIFNpZ25pbmcgUENBIDIwMjQwHhcNMjYwNDE2MTg1OTQxWhcNMjcwNDE1MTg1 # OTQxWjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE # BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYD # VQQDExVNaWNyb3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IB # DwAwggEKAoIBAQDVsZfgOKmM31HPfoWOoNEiw0SlCiIxUMC0I9NMWbucKOw/e9lP # oAoehQVu6SG65V4EPzrYsnBnFPNoi4/HoOdjhz1qkrEt4I6tEcxXU6oOeY9zGveC # /3iBeuhLYxM3M/PkcUoebF+Nednm8OkdSPoDu8imViHPQq/8CQUu0WRR4rE+dMRf # rpVqfmNi2qWCX94T4MsepijGVkwE//tJg0ryAiYdHT34LSnlG/RSBZmQRGWZ5g8j # qnKjRParSqMft1gvjuUTVgtWNZfgcLFSK5Wa0myrq8OPcgTGGsRgun+tnSS+IxDT # xVsAPH1OzvPjwomguByhUe/OcvUN0D5Wmp7xAgMBAAGjggGqMIIBpjAOBgNVHQ8B # Af8EBAMCB4AwHwYDVR0lBBgwFgYKKwYBBAGCN0wIAQYIKwYBBQUHAwMwHQYDVR0O # BBYEFNoH7a2YDjOSwpkp6DHcmUS7J+0yMFQGA1UdEQRNMEukSTBHMS0wKwYDVQQL # EyRNaWNyb3NvZnQgSXJlbGFuZCBPcGVyYXRpb25zIExpbWl0ZWQxFjAUBgNVBAUT # DTIzMDAxMis1MDc1NjkwHwYDVR0jBBgwFoAUf1k/VCHarU/vBeXmo9ctBpQSCDEw # YAYDVR0fBFkwVzBVoFOgUYZPaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9w # cy9jcmwvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNy # bDBtBggrBgEFBQcBAQRhMF8wXQYIKwYBBQUHMAKGUWh0dHA6Ly93d3cubWljcm9z # b2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmcl # MjBQQ0ElMjAyMDI0LmNydDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IC # AQAUnEqhaRXe0T3hIJjvdQErEkrA/7bByjn6t5IArODkkRjzkYwtKMc2yYj2quaN # rLutWw2YZcngKPy1b71YyDJQTy4NDRwaSh9Tw5thrk3NmcPrAHia5vtcBJ1CgtKK # 7mQbIcQ22d/N3813ayCDDFewu1+jsZmX+r/aTEqaOM4TVxVtRSkuCy8nAXKuChOK # Li/zA4XuH8iEYqIsj2YoNaeSxVmeGiERXpKdo3dDmYi0kO5w2D8VS4c3+9h6gElY # BaAAg/dYErBg27qT3vv0zRDJhJufvCNylA8S7/+8H5E/PV5cng6na9VV/w9OV3qu # uND6zdGa2EX38Glp50F9AIQk3p2xXmcvorDeM4XJ7UlWYBi6g80J1SSOQnInCYFE # msfUNn3+1AaTJKSJL83quKArTac2pKhu0Yzzzrzo6HrsRiQKzpnRBb1/dMa6P3hz # 75XbMRBctNsFhZC07WCmjExdLg2eHW5uV0TY8D5+6wozJf7vF3+WHkYPO85Z+BC6 # U4FkNbYNycZ9cE4j1tXRdyDCfml6c0HWPHjNVDObrv9lKt3qUqFpX38VCqVCyNOO # 1UcXfQiVjJw32U2WUKZjt/neJKHEBsm9kFsLuWzkQ53+qcaSaytmsCnk2gOglrlD # 5d3kKyvvAw+rzm0lT8K38P6PLxfZQHhu4W8dV7Av8N2ZmDCCBr0wggSloAMCAQIC # EzMAAAA5O7Y3Gb8GHWcAAAAAADkwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYT # AlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYD # VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBS # b290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDExMB4XDTI0MDgwODIwNTQxOFoX # DTM2MDMyMjIyMTMwNFowVzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29m # dCBDb3Jwb3JhdGlvbjEoMCYGA1UEAxMfTWljcm9zb2Z0IENvZGUgU2lnbmluZyBQ # Q0EgMjAyNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANgBnB7jOMeq # lRYHNa265v4IY9fH8TKhemHfPINe1gpLaV3dhg324WwH06LcHbpnsBukCDNitryo # 0dtS/EW6I/yEL/bLSY8hKpbfQuWusBPr9qazYcDxCW/qnjb5JsI1s8bNOg3bVATv # QVL4tcf03aTycsz8QeCdM0l/yHRObJ9QqazM1r6VPEOJ7LL+uEEb73w6QCuhs89a # 1uv1zerOYMnsneRRwCbpyW11IcggU0cRKDDq1pjVJzIbIF6+oiXXbReOsgeI8zu1 # FyQfK0fVkaya8SmVHQ/tOf23mZ4W9k0Ri22QW9p3UgSC5OUDktKxxcCmGL6tXLfO # GSWHIIV4YrTJTT6PNty5REojHJuZHArkF9VnHTERWoTjAzfI3kP+5b4alUdhgAZ7 # ttOu1bVnXfHaqPYl2rPs20ji03LOVWsh/radgE17es5hL+t6lV0eVHrVhsssROWJ # uz2MXMCt7iw7lFPG9LXKGjsmonn2gotGdHIuEg5JnJMJVmixd5LRlkmgYRZKzhxS # CwyoGIq0PhaA7Y+VPct5pCHkijcIIDm0nlkK+0KyepolcqGm0T/GYQRMhHJlGOOm # VQop36wUVUYklUy++vDWeEgEo4s7hxN6mIbf2MSIQ/iIfMZgJxC69oukMUXCrOC3 # SkE/xIkgpfl22MM1itkZ35nNXkMolU1lAgMBAAGjggFOMIIBSjAOBgNVHQ8BAf8E # BAMCAYYwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFH9ZP1Qh2q1P7wXl5qPX # LQaUEggxMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIAQwBBMA8GA1UdEwEB/wQFMAMB # Af8wHwYDVR0jBBgwFoAUci06AjGQQ7kUBU7h6qfHMdEjiTQwWgYDVR0fBFMwUTBP # oE2gS4ZJaHR0cDovL2NybC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMv # TWljUm9vQ2VyQXV0MjAxMV8yMDExXzAzXzIyLmNybDBeBggrBgEFBQcBAQRSMFAw # TgYIKwYBBQUHMAKGQmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMv # TWljUm9vQ2VyQXV0MjAxMV8yMDExXzAzXzIyLmNydDANBgkqhkiG9w0BAQwFAAOC # AgEAFJQfOChP7onn6fLIMKrSlN1WYKwDFgAddymOUO3FrM8d7B/W/iQ6DxXsDn7D # 5W4wMwYeLystcEqfkjz4NURRgazyMu5yRzQh4LqjA4tStTcJh1opExo7nn5PuPBY # nbu0+THSuVHTe0VTTPVhily/piFrDo3axQ9P4C+Ol5yet+2gTfekICS5xS+cYfSI # vgn0JksVBVMYVI5QFu/qhnLhsEFEUzG8fvv0hjgkO+lkpV9ty6GkN4vdnd7ya6Q6 # aR9y34aiM1qmxaxBi6OUnyNl6fkuun/diTFnYDLTppOkr/mg5WSfCiDVMNCxtj4w # PKC5OmHm1DQIt/MNokbbH3UGsFP1QbzsLocuSqLCvH09Io3fDPTmscR9Y75G4qX7 # RTX8AdBPo0I6OEojf39zuFZt0qOHm65YWQE69cZM2ueE1MB05dNNgHK9gTE7zKvK # /fg8B2qjW88MT/WF5V5uvZGtqa9FSL2RazArA+rDPuf6JGYz4HpgMZHB4S6szWSK # YBv0VisCzfxgeU+dquXW9bd0auYlOB58DPcOYKdc3Se94g+xL4pcEhbB54JOgAkw # YTu/9dLeH2pDqeJZAABVDWRQCaXfO5LgyKwKCLYXpigrZYCjUSBcr+Ve8PFWMhVT # Ql0v4q8J/AUmQN5W4n101cY2L4A7GTQG1h32HHAvfQESWP0xghngMIIZ3AIBATBu # MFcxCzAJBgNVBAYTAlVTMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x # KDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMjQCEzMAAAIc # +s3Fm+gvfsQAAAAAAhwwDQYJYIZIAWUDBAIBBQCggZAwGQYJKoZIhvcNAQkDMQwG # CisGAQQBgjcCAQQwLwYJKoZIhvcNAQkEMSIEIELZL5J+vJ3GMx9JoNSrYyq9t8Yh # SN4izKEMjMK9CATDMEIGCisGAQQBgjcCAQwxNDAyoBSAEgBNAGkAYwByAG8AcwBv # AGYAdKEagBhodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20wDQYJKoZIhvcNAQEBBQAE # ggEA0BZeS9SWiSee5TQ3AkvtYxiAVBs0g6D47+LG9+KTuqzJnzUX0K9dtrMKrWQN # 7SyQfhGYXugz5S+xmmwGRVsGD31hlDtfHtlQfgYtP8HUiDpUTsKGpQc0dyXKWSwl # 2lT12KXcXjk3GHsG3RM/c+wc0dqDB5faBeL5zJXgTpr6BA6pPLX+anCylj4yYbbN # eDKPKdHD8YtO7MTps22cJUdK54Ks+oBwvGljkT40VyIkFCXxXot+BY9HA3keVxhX # KNLFuag01Uv/DNfuIzgNqOtwb207ZpWM5GFHChgovxSz4bI54daTaNCC/VtyI/XX # RVEHQjHL9WsNpHJ9Vk4e0rsgvKGCF7AwghesBgorBgEEAYI3AwMBMYIXnDCCF5gG # CSqGSIb3DQEHAqCCF4kwgheFAgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFaBgsqhkiG # 9w0BCRABBKCCAUkEggFFMIIBQQIBAQYKKwYBBAGEWQoDATAxMA0GCWCGSAFlAwQC # AQUABCDpVfzbrHZnXE5HVt2kHfBxonE5T3/ekr1DkDxBbACyHgIGatuB1YahGBMy # MDI2MTAyNTExMzA1NS4wMzJaMASAAgH0oIHZpIHWMIHTMQswCQYDVQQGEwJVUzET # MBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMV # TWljcm9zb2Z0IENvcnBvcmF0aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQgSXJlbGFu # ZCBPcGVyYXRpb25zIExpbWl0ZWQxJzAlBgNVBAsTHm5TaGllbGQgVFNTIEVTTjoz # NjA1LTA1RTAtRDk0NzElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2Vy # dmljZaCCEf4wggcoMIIFEKADAgECAhMzAAACE7BDNWbPr5XoAAEAAAITMA0GCSqG # SIb3DQEBCwUAMHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw # DgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x # JjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMB4XDTI1MDgx # NDE4NDgxN1oXDTI2MTExMzE4NDgxN1owgdMxCzAJBgNVBAYTAlVTMRMwEQYDVQQI # EwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3Nv # ZnQgQ29ycG9yYXRpb24xLTArBgNVBAsTJE1pY3Jvc29mdCBJcmVsYW5kIE9wZXJh # dGlvbnMgTGltaXRlZDEnMCUGA1UECxMeblNoaWVsZCBUU1MgRVNOOjM2MDUtMDVF # MC1EOTQ3MSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNlMIIC # IjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9Jl64LoZxDINSFgz+9KS5Ozv # 5m548ePVzc9RXWe4T4/Mplfga4eq12RGdp5cVvnjde5vxfq2ax/jnu7vUW4rZN4m # OUm5vh+kcYsQlYQ53FwgIB3nEjcQHomrG3mZe/ozjFSAr6JbglKtIeAySPzAcFzy # Aer5lLNUHBEvQMM8BOjMyapCvh0xsg4xKFcVEJQLKEfCGBffMZI/amutHFb3CUTZ # 7aVpG2KHEFUNlZ1vwMKvxXTPRDnbwPGzyyqJJznfsLNHQ4vXt2ttS1PeCoGI0hN1 # Peq8yGsIXM9oocwC06DGNSM/4LAx2uKvwmUn6NwLc0+tmvny6w28rZLejskRfnVW # ofEv1mWY0jHUnHrwSGBS8gVP9gcBs6P5g0OpJPMfxdUkHXRkcMPPW0hIP8NbW8W5 # Sup8HuwnSKbjpyAlGBUdM/V5rZb0sZmkn714r6ULGK+cLLAN6R3FhX6N0nj64F27 # LTK2BbS0pJZaXjo0eDNz1QcxeIFLUgF+RBsLYDn8E8cCkexK8Nlt3Gi9zJf55w6U # fTZ+kwTMxMqFxh7+Tfx7+aBObZ+nx961AtiqAy7zVV69o/LWRdKPZdvZn9ESyGbT # nPfjkBERv22prSlETlRwzP6bmEVOKWLWVwxuwh7bUWUuUb1cj93zvttQYGQat5E9 # ALLJNmlvLKCskB7raLsCAwEAAaOCAUkwggFFMB0GA1UdDgQWBBQTnhBKx+FryphQ # WMRipH49sMFAOjAfBgNVHSMEGDAWgBSfpxVdAF5iXYP05dJlpxtTNRnpcjBfBgNV # HR8EWDBWMFSgUqBQhk5odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2Ny # bC9NaWNyb3NvZnQlMjBUaW1lLVN0YW1wJTIwUENBJTIwMjAxMCgxKS5jcmwwbAYI # KwYBBQUHAQEEYDBeMFwGCCsGAQUFBzAChlBodHRwOi8vd3d3Lm1pY3Jvc29mdC5j # b20vcGtpb3BzL2NlcnRzL01pY3Jvc29mdCUyMFRpbWUtU3RhbXAlMjBQQ0ElMjAy # MDEwKDEpLmNydDAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMI # MA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsFAAOCAgEAgmxaJrGqQ2D6UJhZ # 6Ql2SZFOaNuGbW3LzB+ES+l2BB1MJtBRSFdi/hVY33NpxsJQhQ5TLVp0DXYOkIoP # Qc17rH+IVhemO8jCt+U6I1TIw6cR7c+tEo/Jjp6EqEU1c4/mraMjgHhQ+raC/OUA # m98A1r4bIPHtsBmLROGmeE5XLIFaBIZWHvh2COXITKObXVd5wGtJ1dZZdwaHACXF # 506jta+uoUdyzAeuNlTPLTrZ8nyhxGwk9Vh6eiDQ7CQMWSSa8DJS9PUXjeoi9vTd # S7ZMXqu+tv6Qz3xtoBF5+YFK4uE+miGs90Fxm0VK2lWrmFhjkRl5zyoHOdwG7spN # YkDomCPNWIudUQmQYKpt/Hsspfcb+xpnWIDQdMzgE8pj1vpwLgWEnH7LtT4dZCeo # Do9PK40RxBD8kKJ769ngkEwfwCD2EX/MQk79eIvOhpnH12GuVByvaKZk5XZvqtPO # NNwr8q/qA3877IuWwWgnaeX+prpw0dZ/QLtbGGVrgP+TRQjt+2dcZA5P3X4LwANh # iPsy0Ol4XCdj7OxBLFvOzsCPDPaVnkp+dfDFG+NOBir7aqTJ68622pymg1V+6gc/ # 1RvxC/wgvYyG033ecJqv0On0ZRNYr+i/OkwgA3HP1aLD0aHrEpw6lt0263iRkCvr # cdcOW8w3jC8TJuaGWyC2S9jEjzgwggdxMIIFWaADAgECAhMzAAAAFcXna54Cm0mZ # AAAAAAAVMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECBMK # V2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0 # IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUm9vdCBDZXJ0aWZpY2F0 # ZSBBdXRob3JpdHkgMjAxMDAeFw0yMTA5MzAxODIyMjVaFw0zMDA5MzAxODMyMjVa # MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdS # ZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMT # HU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMIICIjANBgkqhkiG9w0BAQEF # AAOCAg8AMIICCgKCAgEA5OGmTOe0ciELeaLL1yR5vQ7VgtP97pwHB9KpbE51yMo1 # V/YBf2xK4OK9uT4XYDP/XE/HZveVU3Fa4n5KWv64NmeFRiMMtY0Tz3cywBAY6GB9 # alKDRLemjkZrBxTzxXb1hlDcwUTIcVxRMTegCjhuje3XD9gmU3w5YQJ6xKr9cmmv # Haus9ja+NSZk2pg7uhp7M62AW36MEBydUv626GIl3GoPz130/o5Tz9bshVZN7928 # jaTjkY+yOSxRnOlwaQ3KNi1wjjHINSi947SHJMPgyY9+tVSP3PoFVZhtaDuaRr3t # pK56KTesy+uDRedGbsoy1cCGMFxPLOJiss254o2I5JasAUq7vnGpF1tnYN74kpEe # HT39IM9zfUGaRnXNxF803RKJ1v2lIH1+/NmeRd+2ci/bfV+AutuqfjbsNkz2K26o # ElHovwUDo9Fzpk03dJQcNIIP8BDyt0cY7afomXw/TNuvXsLz1dhzPUNOwTM5TI4C # vEJoLhDqhFFG4tG9ahhaYQFzymeiXtcodgLiMxhy16cg8ML6EgrXY28MyTZki1ug # poMhXV8wdJGUlNi5UPkLiWHzNgY1GIRH29wb0f2y1BzFa/ZcUlFdEtsluq9QBXps # xREdcu+N+VLEhReTwDwV2xo3xwgVGD94q0W29R6HXtqPnhZyacaue7e3PmriLq0C # AwEAAaOCAd0wggHZMBIGCSsGAQQBgjcVAQQFAgMBAAEwIwYJKwYBBAGCNxUCBBYE # FCqnUv5kxJq+gpE8RjUpzxD/LwTuMB0GA1UdDgQWBBSfpxVdAF5iXYP05dJlpxtT # NRnpcjBcBgNVHSAEVTBTMFEGDCsGAQQBgjdMg30BATBBMD8GCCsGAQUFBwIBFjNo # dHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL0RvY3MvUmVwb3NpdG9yeS5o # dG0wEwYDVR0lBAwwCgYIKwYBBQUHAwgwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBD # AEEwCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU1fZW # y4/oolxiaNE9lJBb186aGMQwVgYDVR0fBE8wTTBLoEmgR4ZFaHR0cDovL2NybC5t # aWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0XzIwMTAt # MDYtMjMuY3JsMFoGCCsGAQUFBwEBBE4wTDBKBggrBgEFBQcwAoY+aHR0cDovL3d3 # dy5taWNyb3NvZnQuY29tL3BraS9jZXJ0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0y # My5jcnQwDQYJKoZIhvcNAQELBQADggIBAJ1VffwqreEsH2cBMSRb4Z5yS/ypb+pc # FLY+TkdkeLEGk5c9MTO1OdfCcTY/2mRsfNB1OW27DzHkwo/7bNGhlBgi7ulmZzpT # Td2YurYeeNg2LpypglYAA7AFvonoaeC6Ce5732pvvinLbtg/SHUB2RjebYIM9W0j # VOR4U3UkV7ndn/OOPcbzaN9l9qRWqveVtihVJ9AkvUCgvxm2EhIRXT0n4ECWOKz3 # +SmJw7wXsFSFQrP8DJ6LGYnn8AtqgcKBGUIZUnWKNsIdw2FzLixre24/LAl4FOmR # sqlb30mjdAy87JGA0j3mSj5mO0+7hvoyGtmW9I/2kQH2zsZ0/fZMcm8Qq3UwxTSw # ethQ/gpY3UA8x1RtnWN0SCyxTkctwRQEcb9k+SS+c23Kjgm9swFXSVRk2XPXfx5b # RAGOWhmRaw2fpCjcZxkoJLo4S5pu+yFUa2pFEUep8beuyOiJXk+d0tBMdrVXVAmx # aQFEfnyhYWxz/gq77EFmPWn9y8FBSX5+k77L+DvktxW/tM4+pTFRhLy/AsGConsX # HRWJjXD+57XQKBqJC4822rpM+Zv/Cuk0+CQ1ZyvgDbjmjJnW4SLq8CdCPSWU5nR0 # W2rRnj7tfqAxM328y+l7vzhwRNGQ8cirOoo6CGJ/2XBjU02N7oJtpQUQwXEGahC0 # HVUzWLOhcGbyoYIDWTCCAkECAQEwggEBoYHZpIHWMIHTMQswCQYDVQQGEwJVUzET # MBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMV # TWljcm9zb2Z0IENvcnBvcmF0aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQgSXJlbGFu # ZCBPcGVyYXRpb25zIExpbWl0ZWQxJzAlBgNVBAsTHm5TaGllbGQgVFNTIEVTTjoz # NjA1LTA1RTAtRDk0NzElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2Vy # dmljZaIjCgEBMAcGBSsOAwIaAxUAmBE8SCjxgjacmy8/VEdk7NxpR6aggYMwgYCk # fjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMH # UmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQD # Ex1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDANBgkqhkiG9w0BAQsFAAIF # AO6H+m4wIhgPMjAyNjEwMjUwMzQ3NThaGA8yMDI2MTAyNjAzNDc1OFowdzA9Bgor # BgEEAYRZCgQBMS8wLTAKAgUA7of6bgIBADAKAgEAAgIPnwIB/zAHAgEAAgITkTAK # AgUA7olL7gIBADA2BgorBgEEAYRZCgQCMSgwJjAMBgorBgEEAYRZCgMCoAowCAIB # AAIDB6EgoQowCAIBAAIDAYagMA0GCSqGSIb3DQEBCwUAA4IBAQChsbdRX2pXXblZ # oCAzkfopf6F6xUSI4N+aNKU5eLpNrIsM3R1FRIlJoM6r21aWUQmtU6Q+Gf8eBAyU # QI2Dwya9sLbssOvMWBF0GCNLger1Yay94hjybdDxpO10sjRbnD6xw/fo+75vEJFO # GzKPeY2ZQN6PmmNtvklBvvMnZke8SwuekOwu54syt7uGlIfoQZsD38f8CqsGmVt4 # F3NKcjCrnjuL/ubghVpUnchNr0/NINlYRVmzaSShfB5Yxk/UFg1wY8l+JvxW2MYq # EIWJlo/iefIc6NYMlQRZJlT5tSMg7PMkXa9+Sq6xu7drEf5ZsGr2Ksm0CT5FTX2r # W2bYuJ9kMYIEDTCCBAkCAQEwgZMwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldh # c2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBD # b3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIw # MTACEzMAAAITsEM1Zs+vlegAAQAAAhMwDQYJYIZIAWUDBAIBBQCgggFKMBoGCSqG # SIb3DQEJAzENBgsqhkiG9w0BCRABBDAvBgkqhkiG9w0BCQQxIgQgNK7sRcme/WLs # /39q+utZSRCwpbvLAhrGc/+n70xKcL8wgfoGCyqGSIb3DQEJEAIvMYHqMIHnMIHk # MIG9BCDM4QltFIUz8J4DjAzP4nVodZvQxYGleUIfp86Oa5xYaDCBmDCBgKR+MHwx # CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRt # b25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1p # Y3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwAhMzAAACE7BDNWbPr5XoAAEAAAIT # MCIEIFdilLsbt4U0/WhJ0Pw+DAuZMNHEGvRx6p70KyiGs+xHMA0GCSqGSIb3DQEB # CwUABIICAElI7DwOqMymXXtObX9sr9v5eNS0kRimX14aBHXAtxV0U7Ya4oPUj2Lx # lG7lyiw5CBnRo1sbe+39pNZ0uEYp1AT89utlYMvslJJawj1M+639LjtyYwRUU5Vq # k5GSYNILV3FD7a3kj2/uxA0S0TlH5exELwbYxxF0euuzJR1f83mC4P/hQN02uxq8 # 2X5ZLmNcu+SsWyJu7/URDipwMkfljFJGX91A+/ptAJuvMWRCZ1Fr7p1Ndf5WLSir # VXeLzFcbCK3wsnCuiFjKPHa0aQ/pRGqONoYOWznWQ1Ydpvy0v24fUDxCgRKuKFlt # D6wzIcMGhjwZPxGZ/aryB71Pp8AjbJ8fQay0yyOe5HSSUmo+np4pxtVKnB93AFWn # hHSdeaTQAjptIRbckgevsHoWx/drc/8YDqpVFQ4Ub6Nn/V4Aw0NaTduNVDxv+sgV # 6Xxr6/Uj3/R74TW3CSCkVKjPt5G8motOQC5WcCe3Ua355azDlMYqdpmJuNMmuOPT # BtI8En7a9zgpILKwCxq4EOy+qLfAil3BLppUMFoW3FdrcPxnmjd6eUnoD1pYZBRZ # LHjwUeihkq+KeSfraYNUsIl3X6OjV5spn5wGBQcPM+Ucnl4evmiii6C2qQTlCtIB # HWx+cjoDUXBEyhllyo+ELYlopKE+B9yCfg/hicCIvPdTP6fpRyF1 # SIG # End signature block |