AzOps.psm1
$script:ModuleRoot = $PSScriptRoot $script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\AzOps.psd1").ModuleVersion # Detect whether at some level dotsourcing was enforced $script:doDotSource = Get-PSFConfigValue -FullName AzOps.Import.DoDotSource -Fallback $false if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $script:doDotSource = $true } if ($AzOps_dotsourcemodule) { $script:doDotSource = $true } <# Note on Resolve-Path: All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator. This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS. Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist. This is important when testing for paths. #> # Detect whether at some level loading individual module files, rather than the compiled module was enforced $importIndividualFiles = Get-PSFConfigValue -FullName AzOps.Import.IndividualFiles -Fallback $false if ($AzOps_importIndividualFiles) { $importIndividualFiles = $true } if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true } if ("<was compiled>" -eq '<was not compiled>') { $importIndividualFiles = $true } function Import-ModuleFile { <# .SYNOPSIS Loads files into the module on module import. .DESCRIPTION This helper function is used during module initialization. It should always be dotsourced itself, in order to proper function. This provides a central location to react to files being imported, if later desired .PARAMETER Path The path to the file to load .EXAMPLE > . Import-ModuleFile -File $function.FullName Imports the file stored in $function according to import policy #> [CmdletBinding()] Param ( [string] $Path ) $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath if ($doDotSource) { . $resolvedPath } else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) } } #region Load individual files if ($importIndividualFiles) { # Execute Preimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\PreImport.ps1")) { . Import-ModuleFile -Path $path } # Import all internal functions foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Import all public functions foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Execute Postimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\PostImport.ps1")) { . Import-ModuleFile -Path $path } # End it here, do not load compiled code below return } #endregion Load individual files #region Load compiled code <# This file loads the strings documents from the respective language folders. This allows localizing messages and errors. Load psd1 language files for each language you wish to support. Partial translations are acceptable - when missing a current language message, it will fallback to English or another available language. #> Import-PSFLocalizedString -Path "$($script:ModuleRoot)\localized\en-us\*.psd1" -Module 'AzOps' -Language 'en-US' class AzOpsRoleEligibilityScheduleRequest { [string]$ResourceType [string]$Name [string]$Id [hashtable]$Properties AzOpsRoleEligibilityScheduleRequest($roleEligibilitySchedule, $roleEligibilityScheduleRequest) { $this.Properties = [ordered]@{ Condition = $roleEligibilitySchedule.Condition ConditionVersion = $roleEligibilitySchedule.ConditionVersion PrincipalId = $roleEligibilitySchedule.PrincipalId RoleDefinitionId = $roleEligibilitySchedule.RoleDefinitionId RequestType = $roleEligibilityScheduleRequest.RequestType.ToString() ScheduleInfo = [ordered]@{ Expiration = [ordered]@{ EndDateTime = $roleEligibilitySchedule.EndDateTime Duration = $roleEligibilitySchedule.ExpirationDuration ExpirationType = if ($roleEligibilitySchedule.ExpirationType) {$roleEligibilitySchedule.ExpirationType.ToString()} } StartDateTime = $roleEligibilitySchedule.StartDateTime } } $this.Id = $roleEligibilitySchedule.RequestId $this.Name = $roleEligibilitySchedule.Name $this.ResourceType = $roleEligibilityScheduleRequest.Type } AzOpsRoleEligibilityScheduleRequest($roleEligibilitySchedule) { $this.Properties = [ordered]@{ Condition = $roleEligibilitySchedule.Condition ConditionVersion = $roleEligibilitySchedule.ConditionVersion PrincipalId = $roleEligibilitySchedule.PrincipalId RoleDefinitionId = $roleEligibilitySchedule.RoleDefinitionId RequestType = "AdminAssign" ScheduleInfo = [ordered]@{ Expiration = [ordered]@{ EndDateTime = $roleEligibilitySchedule.EndDateTime Duration = $roleEligibilitySchedule.ExpirationDuration ExpirationType = if ($roleEligibilitySchedule.ExpirationType) {$roleEligibilitySchedule.ExpirationType.ToString()} } StartDateTime = $roleEligibilitySchedule.StartDateTime } } $this.Id = $roleEligibilitySchedule.RequestId $this.Name = $roleEligibilitySchedule.Name $this.ResourceType = "Microsoft.Authorization/roleEligibilityScheduleRequests" } } class AzOpsScope { [string]$Scope [string]$Type [string]$Name [string]$StatePath [string]$ManagementGroup [string]$ManagementGroupDisplayName [string]$Subscription [string]$SubscriptionDisplayName [string]$ResourceGroup [string]$ResourceProvider [string]$Resource [string]$ChildResource hidden [string]$StateRoot #region Internal Regex Helpers hidden [regex]$regex_tenant = '/$' hidden [regex]$regex_managementgroup = '(?i)^/providers/Microsoft.Management/managementgroups/[^/]+$' hidden [regex]$regex_managementgroupExtract = '(?i)^/providers/Microsoft.Management/managementgroups/' hidden [regex]$regex_subscription = '(?i)^/subscriptions/[^/]*$' hidden [regex]$regex_subscriptionExtract = '(?i)^/subscriptions/' hidden [regex]$regex_resourceGroup = '(?i)^/subscriptions/.*/resourcegroups/[^/]*$' hidden [regex]$regex_resourceGroupExtract = '(?i)^/subscriptions/.*/resourcegroups/' hidden [regex]$regex_managementgroupProvider = '(?i)^/providers/Microsoft.Management/managementgroups/[\s\S]*/providers' hidden [regex]$regex_subscriptionProvider = '(?i)^/subscriptions/.*/providers' hidden [regex]$regex_resourceGroupProvider = '(?i)^/subscriptions/.*/resourcegroups/[\s\S]*/providers' hidden [regex]$regex_managementgroupResource = '(?i)^/providers/Microsoft.Management/managementGroups/[\s\S]*/providers/[\s\S]*/[\s\S]*/' hidden [regex]$regex_subscriptionResource = '(?i)^/subscriptions/([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})/providers/[\s\S]*/[\s\S]*/' hidden [regex]$regex_resourceGroupResource = '(?i)^/subscriptions/([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})/resourcegroups/[\s\S]*/providers/[\s\S]*/[\s\S]*/' #endregion Internal Regex Helpers #region Constructors AzOpsScope ([string]$Scope, [string]$StateRoot) { <# .SYNOPSIS Creates an AzOpsScope based on the specified resource ID or File System Path .DESCRIPTION Creates an AzOpsScope based on the specified resource ID or File System Path .PARAMETER Scope Scope == ResourceID or File System Path .INPUTS None. You cannot pipe objects to Add-Extension. .OUTPUTS System.String. Add-Extension returns a string with the extension or file name. .EXAMPLE New-AzOpsScope -Scope "/providers/Microsoft.Management/managementGroups/3fc1081d-6105-4e19-b60c-1ec1252cf560" Creates an AzOpsScope based on the specified resource ID #> Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.Constructor' -LogStringValues $scope -FunctionName "AzOpsScope" -ModuleName "AzOps" $this.StateRoot = $StateRoot if (Test-Path -Path $scope) { if ((Get-Item $scope -Force).GetType().ToString() -eq 'System.IO.FileInfo') { #Strong confidence based on content - file Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromFile' -LogStringValues $scope -FunctionName "AzOpsScope" -ModuleName "AzOps" $this.InitializeMemberVariablesFromFile($Scope) } else { # Weak confidence based on metadata at scope - directory Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromDirectory' -LogStringValues $scope -FunctionName "AzOpsScope" -ModuleName "AzOps" $this.InitializeMemberVariablesFromDirectory($Scope) } } else { Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariables' -LogStringValues $scope -FunctionName "AzOpsScope" -ModuleName "AzOps" $this.InitializeMemberVariables($Scope) } } # Overridden Constructor used for Child Resource AzOpsScope ([string]$Scope, [hashtable]$ChildResource, [string]$StateRoot) { <# .SYNOPSIS Creates an StatePath of Child Resource based on the specified resource ID of ResourceGroup, Resource provider of Resource and Resource name .DESCRIPTION Creates an StatePath of Child Resource based on the specified resource ID of ResourceGroup, Resource provider of Resource and Resource name .PARAMETER Scope Scope == ResourceID of Parent resource .PARAMETER ChildResource The ChildResource contains details of the child resource .INPUTS None. You cannot pipe objects to Add-Extension. .OUTPUTS Creates an StatePath of Child Resource .EXAMPLE New-AzOpsScope -Scope "/subscriptions/7d57452c-d765-4fc6-87ec-6649c37f0a0a/resourceGroups/resourcegroup" -ResourceProvider "Microsoft.Network/virtualHubs/hubRouteTables" -ResourceName "hubroutetable1" Using Parent Resource id , Resource provider and Resource name it generates a statepath to place the Child Resource file and parent scope Object #> $this.StateRoot = $StateRoot $this.ChildResource = $ChildResource.resourceProvider + '-' + $ChildResource.resourceName # Check and update generated name for invalid filesystem characters and exceeding maximum length $this.ChildResource = $this.ChildResource | Remove-AzOpsInvalidCharacter | Set-AzOpsStringLength Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.ChildResource.InitializeMemberVariables' -LogStringValues $ChildResource.ResourceProvider, $ChildResource.ResourceName, $scope -FunctionName "AzOpsScope" -ModuleName "AzOps" $this.InitializeMemberVariables($Scope) } # Overloaded constructors - repeat member assignments in each constructor definition #AzOpsScope ([System.IO.DirectoryInfo]$Path, [string]$StateRoot) { hidden [void] InitializeMemberVariablesFromDirectory([System.IO.DirectoryInfo]$Path) { $managementGroupFileName = "microsoft.management_managementGroups-*$(Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix')" $subscriptionFileName = "microsoft.subscription_subscriptions-*$(Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix')" $resourceGroupFileName = "microsoft.resources_resourceGroups-*$(Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix')" if ($Path.FullName -eq (Get-Item $this.StateRoot -Force).FullName) { # Root tenant path Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromDirectory.RootTenant' -LogStringValues $Path -FunctionName "InitializeMemberVariablesFromDirectory" -ModuleName "AzOps" $this.InitializeMemberVariables("/") return } # Always look into AutoGeneratedTemplateFolderPath folder regardless of path specified if ($Path.FullName -notlike "*$(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath')") { $Path = Join-Path $Path -ChildPath "$(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath')".ToLower() Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromDirectory.AutoGeneratedFolderPath' -LogStringValues $Path -FunctionName "InitializeMemberVariablesFromDirectory" -ModuleName "AzOps" } if ($managementGroupScopeFile = (Get-ChildItem -Force -Path $Path -File | Where-Object Name -like $managementGroupFileName)) { [string] $managementGroupID = $managementGroupScopeFile.Name.Replace('microsoft.management_managementgroups-', '').Replace('.parameters', '').Replace($(Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix'), '') Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.Input.FromFileName.ManagementGroup' -LogStringValues $managementGroupID -FunctionName "InitializeMemberVariablesFromDirectory" -ModuleName "AzOps" $this.InitializeMemberVariables("/providers/Microsoft.Management/managementGroups/$managementGroupID") } elseif ($subscriptionScopeFileName = (Get-ChildItem -Force -Path $Path -File | Where-Object Name -like $subscriptionFileName)) { [string] $subscriptionID = $subscriptionScopeFileName.Name.Replace('microsoft.subscription_subscriptions-', '').Replace('.parameters', '').Replace($(Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix'), '') Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.Input.FromFileName.Subscription' -LogStringValues $subscriptionID -FunctionName "InitializeMemberVariablesFromDirectory" -ModuleName "AzOps" $this.InitializeMemberVariables("/subscriptions/$subscriptionID") } elseif ((Get-ChildItem -Force -Path $Path -File | Where-Object Name -like $resourceGroupFileName) -or ((Get-ChildItem -Force -Path $Path.Parent -File | Where-Object Name -like $subscriptionFileName)) ) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromDirectory.ParentSubscription' -LogStringValues $Path.Parent -FunctionName "InitializeMemberVariablesFromDirectory" -ModuleName "AzOps" if ($(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath') -match $Path.Name) { $parent = New-AzOpsScope -Path ($Path.Parent.Parent) $rgName = $Path.Parent.Name } else { $parent = New-AzOpsScope -Path ($Path.Parent) $rgName = $Path.Name } $this.InitializeMemberVariables($("/subscriptions/{0}/resourceGroups/{1}" -f $parent.Subscription, $rgName)) } else { #Error Write-AzOpsMessage -LogLevel Warning -LogString 'AzOpsScope.Input.BadData.UnknownType' -LogStringValues $Path -FunctionName "AzOpsScope" -ModuleName "AzOps" throw "Invalid File Structure! Cannot find Management Group / Subscription / Resource Group files in $Path!" } } #AzOpsScope ([System.IO.FileInfo]$Path, [string]$StateRoot) { hidden [void] InitializeMemberVariablesFromFile([System.IO.FileInfo]$Path) { if (-not $Path.Exists) { throw 'Invalid Input!' } if ($Path.Extension -ne '.json') { # Try to determine based on directory Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromFile.NotJson' -LogStringValues $Path -FunctionName "InitializeMemberVariablesFromFile" -ModuleName "AzOps" $this.InitializeMemberVariablesFromDirectory($Path.Directory) return } else { $resourcePath = Get-Content $Path | ConvertFrom-Json -AsHashtable if (!$resourcePath) { # Empty file with .json is not valid JSON file. Empty Json should've minimum file content '{}' # However, due to bug that is combination of Get-Content and ConvertFrom-Json when empty file with .json (that is valid file but not valid Json), # switch statement is failing to handle $null value unless assigned explicitly. $resourcePath = $null } switch ($resourcePath) { { $_.parameters.input.value.Keys -contains "ResourceId" } { # Parameter Files - resource from parameters file Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromFile.ResourceId' -LogStringValues $($resourcePath.parameters.input.value.ResourceId) -FunctionName "InitializeMemberVariablesFromFile" -ModuleName "AzOps" $this.InitializeMemberVariables($resourcePath.parameters.input.value.ResourceId) break } { $_.parameters.input.value.Keys -contains "Id" } { # Parameter Files - ManagementGroup and Subscription Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromFile.Id' -LogStringValues $($resourcePath.parameters.input.value.Id) -FunctionName "InitializeMemberVariablesFromFile" -ModuleName "AzOps" $this.InitializeMemberVariables($resourcePath.parameters.input.value.Id) break } { $_.parameters.input.value.Keys -ccontains "Type" } { # Parameter Files - Determine Resource Type and Name (Management group) # Management group resource id do contain '/provider' Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromFile.Type' -LogStringValues ("$($resourcePath.parameters.input.value.Type)/$($resourcePath.parameters.input.value.Name)") -FunctionName "InitializeMemberVariablesFromFile" -ModuleName "AzOps" $this.InitializeMemberVariables("$($resourcePath.parameters.input.value.Type)/$($resourcePath.parameters.input.value.Name)") break } { $_.parameters.input.value.Keys -contains "ResourceType" } { # Parameter Files - Determine Resource Type and Name (Any ResourceType except management group) Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromFile.ResourceType' -LogStringValues ($resourcePath.parameters.input.value.ResourceType) -FunctionName "InitializeMemberVariablesFromFile" -ModuleName "AzOps" $currentScope = New-AzOpsScope -Path ($Path.Directory) # Creating Resource Id based on current scope, resource Type and Name of the resource $this.InitializeMemberVariables("$($currentScope.scope)/providers/$($resourcePath.parameters.input.value.ResourceType)/$($resourcePath.parameters.input.value.Name)") break } { $_.parameters.input.value.Keys -ccontains "type" } { # Parameter Files - Determine resource type and name (Any ResourceType except management group) Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromFile.ResourceType' -LogStringValues ($resourcePath.parameters.input.value.type) -FunctionName "InitializeMemberVariablesFromFile" -ModuleName "AzOps" $currentScope = New-AzOpsScope -Path ($Path.Directory) # Creating Resource Id based on current scope, resource type and name of the resource $this.InitializeMemberVariables("$($currentScope.scope)/providers/$($resourcePath.parameters.input.value.type)/$($resourcePath.parameters.input.value.name)") break } { $_.resources -and $_.resources[0].type -eq 'Microsoft.Management/managementGroups' } { # Template - Management Group Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromFile.managementgroups' -LogStringValues ($_.resources[0].name) -FunctionName "InitializeMemberVariablesFromFile" -ModuleName "AzOps" $currentScope = New-AzOpsScope -Path ($Path.Directory) $this.InitializeMemberVariables("$($currentScope.scope)") break } { $_.resources -and $_.resources[0].type -eq 'Microsoft.Management/managementGroups/subscriptions' } { # Template - Subscription Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromFile.subscriptions' -LogStringValues ($_.resources[0].name) -FunctionName "InitializeMemberVariablesFromFile" -ModuleName "AzOps" $currentScope = New-AzOpsScope -Path ($Path.Directory.Parent) $this.InitializeMemberVariables("$($currentScope.scope)") break } { $_.resources -and $_.resources[0].type -eq 'Microsoft.Resources/resourceGroups' } { Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromFile.resourceGroups' -LogStringValues ($_.resources[0].name) -FunctionName "InitializeMemberVariablesFromFile" -ModuleName "AzOps" if ($(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath') -match $Path.Directory.Name) { $currentScope = New-AzOpsScope -Path ($Path.Directory.Parent) } else { $currentScope = New-AzOpsScope -Path ($Path.Directory) } $this.InitializeMemberVariables("$($currentScope.scope)") break } { $_.resources } { # Template - 1st resource Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromFile.resource' -LogStringValues ($_.resources[0].type), ($_.resources[0].name) -FunctionName "InitializeMemberVariablesFromFile" -ModuleName "AzOps" if ($(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath') -match $Path.Directory.Name) { $currentScope = New-AzOpsScope -Path ($Path.Directory.Parent) } else { $currentScope = New-AzOpsScope -Path ($Path.Directory) } $this.InitializeMemberVariables("$($currentScope.scope)/providers/$($_.resources[0].type)/$($_.resources[0].name)") break } Default { # Only show warning about parameter file if parameter file doesn't have deploymentParameters schema defined if ($resourcePath.'$schema' -notmatch 'deploymentParameters.json') { Write-AzOpsMessage -LogLevel Warning -LogString 'AzOpsScope.Input.BadData.TemplateParameterFile' -LogStringValues $Path -FunctionName "AzOpsScope" -ModuleName "AzOps" } Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariablesFromDirectory' -LogStringValues $Path -FunctionName "AzOpsScope" -ModuleName "AzOps" $this.InitializeMemberVariablesFromDirectory($Path.Directory) } } } } #endregion Constructors hidden [void] InitializeMemberVariables([string]$Scope) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariables.Start' -LogStringValues ($scope) -FunctionName "InitializeMemberVariables" -ModuleName "AzOps" $this.Scope = $Scope if ($this.IsResource()) { $this.Type = "resource" $this.Name = $this.IsResource() $this.Subscription = $this.GetSubscription() $this.SubscriptionDisplayName = $this.GetSubscriptionDisplayName() $this.ManagementGroup = $this.GetManagementGroup() $this.ManagementGroupDisplayName = $this.GetManagementGroupName() $this.ResourceGroup = $this.GetResourceGroup() $this.ResourceProvider = $this.IsResourceProvider() $this.Resource = $this.GetResource() if ( (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix') -notcontains 'parameters.json' -and ("$($this.ResourceProvider)/$($this.Resource)" -in 'Microsoft.Authorization/policyDefinitions', 'Microsoft.Authorization/policySetDefinitions') ) { $this.StatePath = ($this.GetAzOpsResourcePath() + '.parameters' + (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix')) } else { $this.StatePath = ($this.GetAzOpsResourcePath() + (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix')) } } elseif ($this.IsResourceGroup()) { $this.Type = "resourcegroups" $this.ResourceProvider = "Microsoft.Resources" $this.Resource = "resourceGroups" $this.Name = $this.IsResourceGroup() $this.Subscription = $this.GetSubscription() $this.SubscriptionDisplayName = $this.GetSubscriptionDisplayName() $this.ManagementGroup = $this.GetManagementGroup() $this.ManagementGroupDisplayName = $this.GetManagementGroupName() $this.ResourceGroup = $this.GetResourceGroup() if ($this.ChildResource -and (-not(Get-PSFConfigValue -FullName AzOps.Core.SkipChildResource))) { $this.StatePath = (Join-Path $this.GetAzOpsResourceGroupPath() -ChildPath ("$(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath')\$($this.ChildResource).json").ToLower()) } else { $this.StatePath = (Join-Path $this.GetAzOpsResourceGroupPath() -ChildPath ("$(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath')\microsoft.resources_resourcegroups-$($this.ResourceGroup)" + $(Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix')).ToLower()) } } elseif ($this.IsSubscription()) { $this.Type = "subscriptions" $this.ResourceProvider = "Microsoft.Management" $this.Resource = "managementGroups/subscriptions" $this.Name = $this.IsSubscription() $this.Subscription = $this.GetSubscription() $this.SubscriptionDisplayName = $this.GetSubscriptionDisplayName() if ($script:AzOpsAzManagementGroup) { $this.ManagementGroup = $this.GetManagementGroup() $this.ManagementGroupDisplayName = $this.GetManagementGroupName() } $this.StatePath = (Join-Path $this.GetAzOpsSubscriptionPath() -ChildPath (("$(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath')\microsoft.subscription_subscriptions-$($this.Subscription)" + $(Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix'))).ToLower()) } elseif ($this.IsManagementGroup()) { $this.Type = "managementGroups" $this.ResourceProvider = "Microsoft.Management" $this.Resource = "managementGroups" $this.Name = $this.GetManagementGroup() $this.ManagementGroup = ($this.GetManagementGroup()).Trim() $this.ManagementGroupDisplayName = if($this.Name) { ($script:AzOpsAzManagementGroup | Where-Object Name -eq $this.Name).DisplayName } $this.StatePath = (Join-Path $this.GetAzOpsManagementGroupPath($this.ManagementGroup) -ChildPath (("$(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath')\microsoft.management_managementgroups-$($this.ManagementGroup)" + $(Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix'))).ToLower()) } elseif ($this.IsRoot()) { $this.Type = "root" $this.Name = "/" $this.StatePath = $this.StateRoot.ToLower() } Write-AzOpsMessage -LogLevel InternalComment -LogString 'AzOpsScope.InitializeMemberVariables.End' -LogStringValues ($scope) -FunctionName "InitializeMemberVariables" -ModuleName "AzOps" } #endregion Initializers [String] ToString() { return $this.Scope } #region Validators [bool] IsRoot() { if (($this.Scope -match $this.regex_tenant)) { return $true } return $false } [bool] IsManagementGroup() { if (($this.Scope -match $this.regex_managementgroup)) { return $true } return $false } [string] IsSubscription() { if (($this.Scope -match $this.regex_subscription)) { return ($this.Scope.Split('/')[2]) } return $null } [string] IsResourceGroup() { if (($this.Scope -match $this.regex_resourceGroup)) { return ($this.Scope.Split('/')[4]) } return $null } [string] IsResourceProvider() { if ($this.Scope -match $this.regex_managementgroupProvider) { return (($this.regex_managementgroupProvider.Split($this.Scope) | Select-Object -last 1) -split '/')[1] } if ($this.Scope -match $this.regex_subscriptionProvider) { return (($this.regex_subscriptionProvider.Split($this.Scope) | Select-Object -last 1) -split '/')[1] } if ($this.Scope -match $this.regex_resourceGroupProvider) { return (($this.regex_resourceGroupProvider.Split($this.Scope) | Select-Object -last 1) -split '/')[1] } return $null } [string] IsResource() { if ($this.Scope -match $this.regex_managementgroupResource) { return ($this.regex_managementgroupResource.Split($this.Scope) | Select-Object -last 1) } if ($this.Scope -match $this.regex_subscriptionResource) { return ($this.regex_subscriptionResource.Split($this.Scope) | Select-Object -last 1) } if ($this.Scope -match $this.regex_resourceGroupResource) { return ($this.regex_resourceGroupResource.Split($this.Scope) | Select-Object -last 1) } return $null } #endregion Validators #region Data Accessors <# Should Return Management Group Name #> [string] GetManagementGroup() { if ($this.GetManagementGroupName()) { foreach ($mgmt in $script:AzOpsAzManagementGroup) { if ($mgmt.Name -eq $this.GetManagementGroupName()) { $private:mgmtHit = $true return $mgmt.Name } } if (-not $private:mgmtHit) { $mgId = $this.Scope -split $this.regex_managementgroupExtract -split '/' | Where-Object { $_ } | Select-Object -First 1 Write-AzOpsMessage -LogLevel Debug -LogString 'AzOpsScope.GetManagementGroup.NotFound' -LogStringValues $mgId -FunctionName "AzOpsScope" -ModuleName "AzOps" return $mgId } } if ($this.Subscription) { foreach ($mgmt in $script:AzOpsAzManagementGroup) { foreach ($child in $mgmt.Children) { if ($child.DisplayName -eq $this.subscriptionDisplayName) { return $mgmt.Name } } } } return $null } [string] GetAzOpsManagementGroupPath([string]$managementgroupName) { if ($groupObject = $script:AzOpsAzManagementGroup | Where-Object Name -eq $managementgroupName) { $parentMgName = $groupObject.parentId -split "/" | Select-Object -Last 1 $parentObject = $script:AzOpsAzManagementGroup | Where-Object Name -eq $parentMgName if ($groupObject.parentId -and $parentObject) { $parentPath = $this.GetAzOpsManagementGroupPath($parentMgName) $childPath = "{0} ({1})" -f $groupObject.DisplayName, $groupObject.Name | Remove-AzOpsInvalidCharacter return Join-Path $parentPath -ChildPath ($childPath.ToLower()) } else { $childPath = "{0} ({1})" -f $groupObject.DisplayName, $groupObject.Name | Remove-AzOpsInvalidCharacter return Join-Path $this.StateRoot -ChildPath ($childPath.ToLower()) } } else { Write-AzOpsMessage -LogLevel Warning -LogString 'AzOpsScope.GetAzOpsManagementGroupPath.NotFound' -LogStringValues $managementgroupName -FunctionName "AzOpsScope" -ModuleName "AzOps" $assumeNewResource = "azopsscope-assume-new-resource_$managementgroupName" return $assumeNewResource.ToLower() } } [string] GetManagementGroupName() { if ($this.Scope -match $this.regex_managementgroupExtract) { $mgId = $this.Scope -split $this.regex_managementgroupExtract -split '/' | Where-Object { $_ } | Select-Object -First 1 if ($mgId) { $mgName = ($script:AzOpsAzManagementGroup | Where-Object Name -eq $mgId).Name if ($mgName) { Write-AzOpsMessage -LogLevel Debug -LogString 'AzOpsScope.GetManagementGroupName.Found.Azure' -LogStringValues $mgName -FunctionName "AzOpsScope" -ModuleName "AzOps" return $mgName } else { Write-AzOpsMessage -LogLevel Debug -LogString 'AzOpsScope.GetManagementGroupName.NotFound' -LogStringValues $mgId -FunctionName "AzOpsScope" -ModuleName "AzOps" return $mgId } } } if ($this.Subscription) { foreach ($managementGroup in $script:AzOpsAzManagementGroup) { foreach ($child in $managementGroup.Children) { if ($child.Type -eq '/subscriptions' -and $child.DisplayName -eq $this.subscriptionDisplayName) { return $managementGroup.Name } } } } return $null } [string] GetAzOpsSubscriptionPath() { $childpath = "{0} ({1})" -f $this.SubscriptionDisplayName, $this.Subscription | Remove-AzOpsInvalidCharacter if ($script:AzOpsAzManagementGroup) { return (Join-Path $this.GetAzOpsManagementGroupPath($this.ManagementGroup) -ChildPath ($childpath).ToLower()) } else { return (Join-Path $this.StateRoot -ChildPath ($childpath).ToLower()) } } [string] GetAzOpsResourceGroupPath() { $childpath = $this.ResourceGroup | Remove-AzOpsInvalidCharacter return (Join-Path $this.GetAzOpsSubscriptionPath() -ChildPath ($childpath).ToLower()) } [string] GetSubscription() { if ($this.Scope -match $this.regex_subscriptionExtract) { $subId = $this.Scope -split $this.regex_subscriptionExtract -split '/' | Where-Object { $_ } | Select-Object -First 1 $sub = $script:AzOpsSubscriptions | Where-Object subscriptionId -eq $subId if ($sub) { Write-AzOpsMessage -LogLevel Debug -LogString 'AzOpsScope.GetSubscription.Found' -LogStringValues $sub.Id -FunctionName "AzOpsScope" -ModuleName "AzOps" return $sub.subscriptionId } else { Write-AzOpsMessage -LogLevel Debug -LogString 'AzOpsScope.GetSubscription.NotFound' -LogStringValues $subId -FunctionName "AzOpsScope" -ModuleName "AzOps" return $subId } } return $null } [string] GetSubscriptionDisplayName() { if ($this.Scope -match $this.regex_subscriptionExtract) { $subId = $this.Scope -split $this.regex_subscriptionExtract -split '/' | Where-Object { $_ } | Select-Object -First 1 $sub = $script:AzOpsSubscriptions | Where-Object subscriptionId -eq $subId if ($sub) { Write-AzOpsMessage -LogLevel Debug -LogString 'AzOpsScope.GetSubscriptionDisplayName.Found' -LogStringValues $sub.displayName -FunctionName "AzOpsScope" -ModuleName "AzOps" return $sub.displayName } else { Write-AzOpsMessage -LogLevel Debug -LogString 'AzOpsScope.GetSubscriptionDisplayName.NotFound' -LogStringValues $subId -FunctionName "AzOpsScope" -ModuleName "AzOps" return $subId } } return $null } [string] GetResourceGroup() { if ($this.Scope -match $this.regex_resourceGroupExtract) { return ($this.Scope -split $this.regex_resourceGroupExtract -split '/' | Where-Object { $_ } | Select-Object -First 1) } return $null } [string] GetResource() { if ($this.Scope -match $this.regex_managementgroupProvider) { return (($this.regex_managementgroupProvider.Split($this.Scope) | Select-Object -last 1) -split '/')[2] } if ($this.Scope -match $this.regex_subscriptionProvider) { return (($this.regex_subscriptionProvider.Split($this.Scope) | Select-Object -last 1) -split '/')[2] } if ($this.Scope -match $this.regex_resourceGroupProvider) { return (($this.regex_resourceGroupProvider.Split($this.Scope) | Select-Object -last 1) -split '/')[2] } return $null } [string] GetAzOpsResourcePath() { Write-AzOpsMessage -LogLevel Debug -LogString 'AzOpsScope.GetAzOpsResourcePath.Retrieving' -LogStringValues $this.Scope -FunctionName "AzOpsScope" -ModuleName "AzOps" $childpath = $this.Name | Remove-AzOpsInvalidCharacter if ($this.Scope -match $this.regex_resourceGroupResource) { $rgpath = $this.GetAzOpsResourceGroupPath() return (Join-Path (Join-Path $rgpath -ChildPath "$(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath')".ToLower()) -ChildPath ($this.ResourceProvider + "_" + $this.Resource + "-" + $childpath).ToLower()) } elseif ($this.Scope -match $this.regex_subscriptionResource) { $subpath = $this.GetAzOpsSubscriptionPath() return (Join-Path (Join-Path $subpath -ChildPath "$(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath')".ToLower()) -ChildPath ($this.ResourceProvider + "_" + $this.Resource + "-" + $childpath).ToLower()) } elseif ($this.Scope -match $this.regex_managementgroupResource) { $mgmtPath = $this.GetAzOpsManagementGroupPath($this.ManagementGroup) return (Join-Path (Join-Path $mgmtPath -ChildPath "$(Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath')".ToLower()) -ChildPath ($this.ResourceProvider + "_" + $this.Resource + "-" + $childpath).ToLower()) } Write-AzOpsMessage -LogLevel Warning -LogString 'AzOpsScope.GetAzOpsResourcePath.NotFound' -LogStringValues $this.Scope -FunctionName "AzOpsScope" -ModuleName "AzOps" throw "Unable to determine Resource Scope for: $($this.Scope)" } #endregion Data Accessors } function Assert-AzOpsBicepDependency { <# .SYNOPSIS Asserts that - if bicep is installed and in current path .DESCRIPTION Asserts that - if bicep is installed and in current path .PARAMETER Cmdlet The $PSCmdlet variable of the calling command. .EXAMPLE > Assert-AzOpsBicepDependency -Cmdlet $PSCmdlet #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] $Cmdlet ) process { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Assert-AzOpsBicepDependency.Validating' $result = (Invoke-AzOpsNativeCommand -ScriptBlock { bicep --version } -IgnoreExitcode) $installed = $result -as [bool] if ($installed) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Assert-AzOpsBicepDependency.Success' } else { $exception = [System.InvalidOperationException]::new('Unable to locate bicep installation') $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, "ConfigurationError", 'InvalidOperation', $null) Write-AzOpsMessage -LogLevel Warning -LogString 'Assert-AzOpsBicepDependency.NotFound' $Cmdlet.ThrowTerminatingError($errorRecord) } } } function Assert-AzOpsInitialization { <# .SYNOPSIS Asserts AzOps has been correctly prepare for execution. .DESCRIPTION Asserts AzOps has been correctly prepare for execution. This boils down to Initialize-AzOpsEnvironment having been executed successfully. .PARAMETER Cmdlet The $PSCmdlet variable of the calling command. .PARAMETER StatePath Path to where the AzOps processing state / repository is located at. .EXAMPLE > Assert-AzOpsInitialization -Cmdlet $PSCmdlet -Statepath $StatePath Asserts AzOps has been correctly prepare for execution. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $Cmdlet, [Parameter(Mandatory = $true)] [string] $StatePath ) begin { $strings = Get-PSFLocalizedString -Module AzOps $invalidPathPattern = [System.IO.Path]::GetInvalidPathChars() -replace '\|', '\|' -join "|" } process { $stateGood = $StatePath -and $StatePath -notmatch $invalidPathPattern if (-not $stateGood) { Write-AzOpsMessage -LogLevel Warning -LogString 'Assert-AzOpsInitialization.StateError' $exception = [System.InvalidOperationException]::new($strings.'Assert-AzOpsInitialization.StateError') $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, "BadData", 'InvalidData', $null) } $cacheBuilt = $script:AzOpsSubscriptions -or $script:AzOpsAzManagementGroup if (-not $cacheBuilt) { Write-AzOpsMessage -LogLevel Warning -LogString 'Assert-AzOpsInitialization.NoCache' $exception = [System.InvalidOperationException]::new($strings.'Assert-AzOpsInitialization.NoCache') $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, "NoCache", 'InvalidData', $null) } if (-not $stateGood -or -not $cacheBuilt) { $Cmdlet.ThrowTerminatingError($errorRecord) } } } function Assert-AzOpsJqDependency { <# .SYNOPSIS Asserts that - if jq is installed and in current path .DESCRIPTION Asserts that - if jq is installed and in current path .PARAMETER Cmdlet The $PSCmdlet variable of the calling command. .EXAMPLE > Assert-AzOpsJqDependency -Cmdlet $PSCmdlet #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] $Cmdlet ) process { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Assert-AzOpsJqDependency.Validating' $minVersion = New-Object System.Version("1.6") $result = (Invoke-AzOpsNativeCommand -ScriptBlock { jq --version } -IgnoreExitcode) $installed = $result -as [bool] if ($installed) { $version = New-Object System.Version(($result).Split("-")[1]) if ($version -ge $minVersion) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Assert-AzOpsJqDependency.Success' return } else { $exception = [System.InvalidOperationException]::new('Unsupported version of jq installed. Please update to a minimum jq version of 1.6') $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, "ConfigurationError", 'InvalidOperation', $null) Write-AzOpsMessage -LogLevel Warning -LogString 'Assert-AzOpsJqDependency.Failed' $Cmdlet.ThrowTerminatingError($errorRecord) } } $exception = [System.InvalidOperationException]::new('Unable to locate jq installation') $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, "ConfigurationError", 'InvalidOperation', $null) Write-AzOpsMessage -LogLevel Warning -LogString 'Assert-AzOpsJqDependency.Failed' $Cmdlet.ThrowTerminatingError($errorRecord) } } function Assert-AzOpsWindowsLongPath { <# .SYNOPSIS Asserts that - if on windows - long paths have been enabled. .DESCRIPTION Asserts that - if on windows - long paths have been enabled. .PARAMETER Cmdlet The $PSCmdlet variable of the calling command. .EXAMPLE > Assert-AzOpsWindowsLongPath -Cmdlet $PSCmdlet Asserts that - if on windows - long paths have been enabled. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $Cmdlet ) process { if (-not $IsWindows) { return } Write-AzOpsMessage -LogLevel InternalComment -LogString 'Assert-AzOpsWindowsLongPath.Validating' $hasRegKey = 1 -eq (Get-ItemPropertyValue -Path HKLM:SYSTEM\CurrentControlSet\Control\FileSystem -Name LongPathsEnabled) $hasGitConfig = (Invoke-AzOpsNativeCommand -ScriptBlock { git config --system -l } -IgnoreExitcode | Select-String 'core.longpaths=true') -as [bool] if (-not $hasGitConfig) { # Check global git config if setting not found in system settings $hasGitConfig = (Invoke-AzOpsNativeCommand -ScriptBlock { git config --global -l } -IgnoreExitcode | Select-String 'core.longpaths=true') -as [bool] } if ($hasGitConfig -and $hasRegKey) { return } if (-not $hasRegKey) { Write-AzOpsMessage -LogLevel Warning -LogString 'Assert-AzOpsWindowsLongPath.No.Registry' } if (-not $hasGitConfig) { Write-AzOpsMessage -LogLevel Warning -LogString 'Assert-AzOpsWindowsLongPath.No.GitCfg' } $exception = [System.InvalidOperationException]::new('Windows not configured for long paths. Please follow instructions for "Enabling long paths on Windows" on https://github.com/azure/azops/wiki/troubleshooting#windows.') $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, "ConfigurationError", 'InvalidOperation', $null) Write-AzOpsMessage -LogLevel Warning -LogString 'Assert-AzOpsWindowsLongPath.Failed' $Cmdlet.ThrowTerminatingError($errorRecord) } } function ConvertFrom-AzOpsBicepTemplate { <# .SYNOPSIS Transpiles bicep template and associated bicepparam to Azure Resource Manager (ARM) template. The json file will be created in the same folder as the bicep file. .PARAMETER BicepTemplatePath BicepTemplatePath. .PARAMETER BicepParamTemplatePath BicepParamTemplatePath, when provided function does not attempt default parameter file discovery. .PARAMETER SkipParam Switch when set will avoid parameter file discovery. .PARAMETER ConvertedTemplate Array of strings, already converted base template, if file is on list skip conversion. .PARAMETER ConvertedParameter Array of strings, already converted parameter, if file is on list skip conversion. .EXAMPLE ConvertFrom-AzOpsBicepTemplate -BicepTemplatePath "root/tenant root group (xxxx-xxxx-xxxx-xxxx-xxxx)/es (es)/subscription (xxxx-xxxx-xxxx-xxxx)/resource-rg/main.bicep" transpiledTemplatePath : root/tenant root group (xxxx-xxxx-xxxx-xxxx-xxxx)/es (es)/subscription (xxxx-xxxx-xxxx-xxxx)/resource-rg/main.json transpiledTemplateNew : True transpiledParametersPath : root/tenant root group (xxxx-xxxx-xxxx-xxxx-xxxx)/es (es)/subscription (xxxx-xxxx-xxxx-xxxx)/resource-rg/main.parameters.json transpiledParametersNew : True #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $BicepTemplatePath, [string] $BicepParamTemplatePath, [switch] $SkipParam, [string[]] $ConvertedTemplate, [string[]] $ConvertedParameter, [switch] $CompareDeploymentToDeletion ) begin { # Assert bicep binaries Assert-AzOpsBicepDependency -Cmdlet $PSCmdlet # Default transpiled values to false $transpiledTemplateNew = $false $transpiledParametersNew = $false } process { if ($CompareDeploymentToDeletion) { # Avoid adding files destined for deletion to a deployment list if ($BicepTemplatePath -in $deleteSet -or $BicepTemplatePath -in ($deleteSet | Resolve-Path).Path) { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertFrom-AzOpsBicepTemplate.Resolve.DeployDeletionOverlap' -LogStringValues $BicepTemplatePath continue } } $transpiledTemplatePath = [IO.Path]::GetFullPath("$($BicepTemplatePath -replace '\.bicep$', '.json')") if ($transpiledTemplatePath -notin $ConvertedTemplate) { # Convert bicep template Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertFrom-AzOpsBicepTemplate.Resolve.ConvertBicepTemplate' -LogStringValues $BicepTemplatePath, $transpiledTemplatePath Invoke-AzOpsNativeCommand -ScriptBlock { bicep build $bicepTemplatePath --outfile $transpiledTemplatePath } $transpiledTemplateNew = $true # Check if bicep build created (ARM) template if (-not (Test-Path $transpiledTemplatePath)) { # If bicep build did not produce file exit with error Write-AzOpsMessage -LogLevel Error -LogString 'ConvertFrom-AzOpsBicepTemplate.Resolve.ConvertBicepTemplate.Error' -LogStringValues $BicepTemplatePath throw } } if (-not $SkipParam) { if (-not $BicepParamTemplatePath) { # Check if bicep template has associated bicepparam file $bicepParametersPath = $BicepTemplatePath -replace '\.bicep$', '.bicepparam' Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertFrom-AzOpsBicepTemplate.Resolve.BicepParam' -LogStringValues $BicepTemplatePath, $bicepParametersPath } elseif ($BicepParamTemplatePath) { # BicepParamTemplatePath path provided as input $bicepParametersPath = $BicepParamTemplatePath Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertFrom-AzOpsBicepTemplate.Resolve.BicepParam' -LogStringValues $BicepTemplatePath, $bicepParametersPath } if ($CompareDeploymentToDeletion) { # Avoid adding files destined for deletion to a deployment list if ($bicepParametersPath -in $deleteSet -or $bicepParametersPath -in ($deleteSet | Resolve-Path).Path) { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertFrom-AzOpsBicepTemplate.Resolve.DeployDeletionOverlap' -LogStringValues $bicepParametersPath $skipParameters = $true } } if (-not $skipParameters -and $bicepParametersPath -and (Test-Path $bicepParametersPath)) { $transpiledParametersPath = [IO.Path]::GetFullPath("$($bicepParametersPath -replace '\.bicepparam$', '.parameters.json')") if ($transpiledParametersPath -notin $ConvertedParameter) { # Convert bicepparam to ARM parameter file Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertFrom-AzOpsBicepTemplate.Resolve.ConvertBicepParam' -LogStringValues $bicepParametersPath, $transpiledParametersPath Invoke-AzOpsNativeCommand -ScriptBlock { bicep build-params $bicepParametersPath --outfile $transpiledParametersPath } $transpiledParametersNew = $true # Check if bicep build-params created (ARM) parameters if (-not (Test-Path $transpiledParametersPath)) { # If bicep build-params did not produce file exit with error Write-AzOpsMessage -LogLevel Error -LogString 'ConvertFrom-AzOpsBicepTemplate.Resolve.ConvertBicepParam.Error' -LogStringValues $bicepParametersPath throw } } } else { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertFrom-AzOpsBicepTemplate.Resolve.BicepParam.NotFound' -LogStringValues $BicepTemplatePath } } # Return transpiled (ARM) template paths $return = [PSCustomObject]@{ transpiledTemplatePath = $transpiledTemplatePath transpiledTemplateNew = $transpiledTemplateNew transpiledParametersPath = $transpiledParametersPath transpiledParametersNew = $transpiledParametersNew } return $return } } function ConvertTo-AzOpsState { <# .SYNOPSIS The cmdlet converts Azure resources (Resources/ResourceGroups/Policy/PolicySet/PolicyAssignments/RoleAssignment/Definition) to the AzOps state format and exports them to the file structure. .DESCRIPTION The cmdlet converts Azure resources (Resources/ResourceGroups/Policy/PolicySet/PolicyAssignments/RoleAssignment/Definition) to the AzOps state format and exports them to the file structure. It is normally executed and orchestrated through the Invoke-AzOpsPull cmdlet. As most of the AzOps-cmdlets, it is dependant on the AzOpsAzManagementGroup and AzOpsSubscriptions variables. Cmdlet will look into jq filter is template directory for the specific one before using the generic one at the root of the module .PARAMETER Resource Object with resource as input .PARAMETER ExportPath ExportPath is used if resource needs to be exported to other path than the AzOpsScope path .PARAMETER ReturnObject Used if to return object in pipeline instead of exporting file .PARAMETER ChildResource The ChildResource contains details of the child resource .PARAMETER StatePath The root path to where the entire state is being built in. .EXAMPLE $policy = Get-AzPolicyDefinition -Custom | Select-Object -Last 1 ConvertTo-AzOpsState -Resource $policy Export custom policy definition to the AzOps StatePath .EXAMPLE $policy = Get-AzPolicyDefinition -Custom | Select-Object -Last 1 ConvertTo-AzOpsState -Resource $policy -ReturnObject Name Value ---- ----- $schema http://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json# contentVersion 1.0.0.0 parameters {input} Serialize custom policy definition to the AzOps format, return object instead of export file .INPUTS Resource .OUTPUTS Resource in AzOpsState json format or object returned as [PSCustomObject] depending on parameters used #> [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] $Resource, [string] $ExportPath, [switch] $ReturnObject, [hashtable] $ChildResource, [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string] $StatePath, [string] $JqTemplatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.JqTemplatePath') ) begin { Write-AzOpsMessage -LogLevel InternalComment -LogString 'ConvertTo-AzOpsState.Starting' } process { if ($ChildResource) { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.Processing' -LogStringValues $ChildResource.resourceName $objectFilePath = (New-AzOpsScope -scope $ChildResource.parentResourceId -ChildResource $ChildResource -StatePath $Statepath).statepath $jqJsonTemplate = Get-AzOpsTemplateFile -File "templateChildResource.jq" Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.Subscription.ChildResource.Jq.Template' -LogStringValues $jqJsonTemplate $object = ($Resource | ConvertTo-Json -Depth 100 -EnumsAsStrings | jq -r '--sort-keys' | jq -r -f $jqJsonTemplate | ConvertFrom-Json) Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.Subscription.ChildResource.Exporting' -LogStringValues $objectFilePath ConvertTo-Json -InputObject $object -Depth 100 -EnumsAsStrings | Set-Content -Path $objectFilePath -Encoding UTF8 -Force return } else { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.Processing' -LogStringValues $Resource.id } if (-not $ExportPath) { if ($Resource.Id) { # Handle subscription-only scenarios without managementGroup access if ($Resource -is [Microsoft.Azure.Commands.Profile.Models.PSAzureSubscription]) { $objectFilePath = (New-AzOpsScope -scope "/subscriptions/$($Resource.id)" -StatePath $StatePath).statepath } else { $objectFilePath = (New-AzOpsScope -scope $Resource.id -StatePath $StatePath).statepath } } elseif ($Resource.ResourceId) { $objectFilePath = (New-AzOpsScope -scope $Resource.ResourceId -StatePath $StatePath).statepath } else { Write-AzOpsMessage -LogLevel Error -LogString 'ConvertTo-AzOpsState.NoExportPath' -LogStringValues $Resource.GetType() } } else { $objectFilePath = $ExportPath } # Create folder structure if it doesn't exist if (-not (Test-Path -Path $objectFilePath)) { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.File.Create' -LogStringValues $objectFilePath $null = New-Item -Path $objectFilePath -ItemType "file" -Force } else { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.File.UseExisting' -LogStringValues $objectFilePath } # If export file path ends with parameter $generateTemplateParameter = $objectFilePath.EndsWith('.parameters.json') ? $true : $false Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.GenerateTemplateParameter' -LogStringValues "$generateTemplateParameter" $resourceType = $null switch ($Resource) { { $_.ResourceType } { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.ObjectType.Resolved.ResourceType' -LogStringValues "$($Resource.ResourceType)" $resourceType = $_.ResourceType break } # Management Groups { $_ -is [Microsoft.Azure.Commands.Resources.Models.ManagementGroups.PSManagementGroup] -or $_ -is [Microsoft.Azure.Commands.Resources.Models.ManagementGroups.PSManagementGroupChildInfo] } { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.ObjectType.Resolved.PSObject' -LogStringValues "$($_.GetType())" if ($_.Type -eq "/subscriptions") { $resourceType = 'Microsoft.Management/managementGroups/subscriptions' break } else { $resourceType = 'Microsoft.Management/managementGroups' break } } # Subscriptions { $_ -is [Microsoft.Azure.Commands.Profile.Models.PSAzureSubscription] } { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.ObjectType.Resolved.PSObject' -LogStringValues "$($_.GetType())" $resourceType = 'Microsoft.Subscription/subscriptions' if (-not $Resource.Type) { $Resource | Add-Member -NotePropertyName Type -NotePropertyValue $resourceType } break } # Resource Groups { $_ -is [Microsoft.Azure.Commands.ResourceManager.Cmdlets.SdkModels.PSResourceGroup] } { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.ObjectType.Resolved.PSObject' -LogStringValues "$($_.GetType())" $resourceType = 'Microsoft.Resources/resourceGroups' break } # Resources - Controlled group for raw objects { $_ -is [Microsoft.Azure.Commands.Profile.Models.PSAzureTenant] } { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.ObjectType.Resolved.PSObject' -LogStringValues "$($_.GetType())" break } { $_.type } { if ( $_.type -eq 'Microsoft.Resources/subscriptions/resourceGroups') { $resourceType = 'Microsoft.Resources/resourceGroups' } else { $resourceType = $_.type } Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.ObjectType.Resolved.ResourceType' -LogStringValues $resourceType break } Default { Write-AzOpsMessage -LogLevel Warning -LogString 'ConvertTo-AzOpsState.ObjectType.Resolved.Generic' -LogStringValues "$($_.GetType())" break } } if ($resourceType) { $providerNamespace = ($resourceType -split '/' | Select-Object -First 1) Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.GenerateTemplate.ProviderNamespace' -LogStringValues $providerNamespace if (($resourceType -split '/').Count -eq 2) { $resourceTypeName = (($resourceType -split '/', 2) | Select-Object -Last 1) Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.GenerateTemplate.ResourceTypeName' -LogStringValues $resourceTypeName $resourceApiTypeName = (($resourceType -split '/', 2) | Select-Object -Last 1) Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.GenerateTemplate.ResourceApiTypeName' -LogStringValues $resourceApiTypeName } if (($resourceType -split '/').Count -eq 3) { $resourceTypeName = ((($resourceType -split '/', 3) | Select-Object -Last 2) -join '/') Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.GenerateTemplate.ResourceTypeName' -LogStringValues $resourceTypeName $resourceApiTypeName = (($resourceType -split '/', 3) | Select-Object -Index 1) Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.GenerateTemplate.ResourceApiTypeName' -LogStringValues $resourceApiTypeName } $jqRemoveTemplate = Get-AzOpsTemplateFile -File (Join-Path $providerNamespace -ChildPath "$resourceTypeName.jq") -Fallback "generic.jq" Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.Jq.Remove' -LogStringValues $jqRemoveTemplate # If we were able to determine resourceType, apply filter and write template or template parameter files based on output filename. $object = $Resource | ConvertTo-Json -Depth 100 -EnumsAsStrings | jq -r -f $jqRemoveTemplate | ConvertFrom-Json if ($ReturnObject) { return $object } else { if ($generateTemplateParameter) { #region Generating Template Parameter Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.GenerateTemplateParameter' $jqJsonTemplate = Get-AzOpsTemplateFile -File (Join-Path $providerNamespace -ChildPath "$resourceTypeName.parameters.jq") -Fallback "template.parameters.jq" Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.Jq.Template' -LogStringValues $jqJsonTemplate $object = ($object | ConvertTo-Json -Depth 100 -EnumsAsStrings | jq -r '--sort-keys' | jq -r -f $jqJsonTemplate | ConvertFrom-Json) #endregion } else { #region Generating Template Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.GenerateTemplate' -LogStringValues "$true" $jqJsonTemplate = Get-AzOpsTemplateFile -File (Join-Path $providerNamespace -ChildPath "$resourceTypeName.template.jq") -Fallback "template.jq" Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.Jq.Template' -LogStringValues $jqJsonTemplate $object = ($object | ConvertTo-Json -Depth 100 -EnumsAsStrings | jq -r '--sort-keys' | jq -r -f $jqJsonTemplate | ConvertFrom-Json) #endregion #region Replace Resource Type and API Version if ( ($Script:AzOpsResourceProvider | Where-Object { $_.ProviderNamespace -eq $providerNamespace }) -and (($Script:AzOpsResourceProvider | Where-Object { $_.ProviderNamespace -eq $providerNamespace }).ResourceTypes | Where-Object { $_.ResourceTypeName -eq $resourceApiTypeName }) ) { $apiVersions = (($Script:AzOpsResourceProvider | Where-Object { $_.ProviderNamespace -eq $providerNamespace }).ResourceTypes | Where-Object { $_.ResourceTypeName -eq $resourceApiTypeName }).ApiVersions # Handle GA/Preview API versions $gaApiVersion = $apiVersions | Where-Object {$_ -match '^\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])$'} | Sort-Object -Descending $preApiVersion = $apiVersions | Where-Object {$_ -notmatch '^\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])$'} | Sort-Object -Descending if ($null -eq $gaApiVersion) { $apiVersion = $preApiVersion | Select-Object -First 1 } else { $apiVersion = $gaApiVersion | Select-Object -First 1 } Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.GenerateTemplate.ApiVersion' -LogStringValues $resourceType, $apiVersion $object.resources[0].apiVersion = $apiVersion $object.resources[0].type = $resourceType } else { Write-AzOpsMessage -LogLevel Warning -LogString 'ConvertTo-AzOpsState.GenerateTemplate.NoApiVersion' -LogStringValues $resourceType } #endregion #region Append Name for child resource # [Patch] Temporary until mangementGroup() is fully implemented if ($resourceType -eq "Microsoft.Management/managementGroups/subscriptions") { $resourceName = (((New-AzOpsScope -Scope $Resource.Id).ManagementGroup) + "/" + $Resource.Name) $object.resources[0].name = $resourceName Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.GenerateTemplate.ChildResource' -LogStringValues $resourceName } #endregion } Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.Exporting' -LogStringValues $objectFilePath ConvertTo-Json -InputObject $object -Depth 100 -EnumsAsStrings | Set-Content -Path ([WildcardPattern]::Escape($objectFilePath)) -Encoding UTF8 -Force } } else { Write-AzOpsMessage -LogLevel Debug -LogString 'ConvertTo-AzOpsState.Exporting.Default' -LogStringValues $objectFilePath if ($ReturnObject) { return $Resource } else { ConvertTo-Json -InputObject $Resource -Depth 100 -EnumsAsStrings | Set-Content -Path ([WildcardPattern]::Escape($objectFilePath)) -Encoding UTF8 -Force } } } } function Get-AzOpsCurrentPrincipal { <# .SYNOPSIS Gets the objectid/clientid from the current Azure context .DESCRIPTION Gets the objectid/clientid from the current Azure context .PARAMETER AzContext The AzContext used when pulling the information. .EXAMPLE > Get-AzOpsCurrentPrincipal -AzContext $AzContext #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] $AzContext = (Get-AzContext) ) process { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Get-AzOpsCurrentPrincipal.AccountType' -LogStringValues $AzContext.Account.Type switch ($AzContext.Account.Type) { 'User' { $restMethodResult = Invoke-AzRestMethod -Uri https://graph.microsoft.com/v1.0/me -ErrorAction Stop if ($restMethodResult) { $principalObject = $restMethodResult.Content | ConvertFrom-Json -ErrorAction Stop } } 'ManagedService' { # Get managed identity application id via IMDS (https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token) $restMethodResult = Invoke-RestMethod -Uri "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2021-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F" -Headers @{ Metadata = $true } -ErrorAction Stop if ($restMethodResult.client_id) { $principalObject = Get-AzADServicePrincipal -ApplicationId $restMethodResult.client_id -ErrorAction Stop } } default { $principalObject = Get-AzADServicePrincipal -ApplicationId $AzContext.Account.Id -ErrorAction Stop } } Write-AzOpsMessage -LogLevel InternalComment -LogString 'Get-AzOpsCurrentPrincipal.PrincipalId' -LogStringValues $principalObject.Id return $principalObject } } function Get-AzOpsManagementGroup { <# .SYNOPSIS The cmdlet will recursively enumerates a management group and returns all children .DESCRIPTION The cmdlet will recursively enumerates a management group and returns all children mgs. If the -PartialDiscovery parameter has been used, it will add all MG's where discovery should initiate to the AzOpsPartialRoot variable. .PARAMETER ManagementGroup Name of the management group to enumerate .PARAMETER PartialDiscovery Whether to recursively grab all Management Groups and add them to the partial root cache .EXAMPLE Get-AzOpsManagementGroup -ManagementGroup Tailspin Id : /providers/Microsoft.Management/managementGroups/Tailspin Type : /providers/Microsoft.Management/managementGroups Name : Tailspin TenantId : d4c7591d-9b0c-49a4-9670-5f0349b227f1 DisplayName : Tailspin UpdatedTime : 0001-01-01 00:00:00 UpdatedBy : ParentId : /providers/Microsoft.Management/managementGroups/d4c7591d-9b0c-49a4-9670-5f0349b227f1 ParentName : d4c7591d-9b0c-49a4-9670-5f0349b227f1 ParentDisplayName : Tenant Root Group .INPUTS ManagementGroupName .OUTPUTS Management Group Object #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $ManagementGroup, [switch] $PartialDiscovery ) process { try { $groupObject = Get-AzManagementGroup -GroupId $ManagementGroup -Expand -WarningAction SilentlyContinue } catch { Write-AzOpsMessage -LogLevel Error -LogString 'Get-AzOpsManagementGroup.Failed' -LogStringValues $ManagementGroup throw } if ($PartialDiscovery) { if ($groupObject.ParentId -and -not (Get-AzManagementGroup -GroupId $groupObject.ParentName -ErrorAction Ignore -WarningAction SilentlyContinue)) { $script:AzOpsPartialRoot += $groupObject } if ($groupObject.Children) { $groupObject.Children | Where-Object Type -eq "Microsoft.Management/managementGroups" | Foreach-Object -Process { Get-AzOpsManagementGroup -ManagementGroup $_.Name -PartialDiscovery:$PartialDiscovery } } } return $groupObject } } function Get-AzOpsNestedSubscription { <# .SYNOPSIS Create a list of subscriptionId's nested at ManagementGroup Scope .PARAMETER Scope ManagementGroup Name .EXAMPLE > Get-AzOpsNestedSubscription -Scope 5663f39e-feb1-4303-a1f9-cf20b702de61 Discover subscriptions at Management Group scope and below #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [string] $Scope ) process { $children = ($script:AzOpsAzManagementGroup | Where-Object {$_.Name -eq $Scope}).Children if ($children) { $subscriptionIds = @() foreach ($child in $children) { if (($child.Type -eq '/subscriptions') -and ($script:AzOpsSubscriptions.id -contains $child.Id)) { $subscriptionIds += [PSCustomObject] @{ Name = $child.DisplayName Id = $child.Name Type = $child.Type } } else { $subscriptionIds += Get-AzOpsNestedSubscription -Scope $child.Name } } if ($subscriptionIds) { return $subscriptionIds } } } } function Get-AzOpsPim { <# .SYNOPSIS Get Privileged Identity Management objects from provided scope .PARAMETER ScopeObject ScopeObject .PARAMETER StatePath StatePath #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Object] $ScopeObject, [Parameter(Mandatory = $true)] $StatePath ) process { # Process RoleEligibilitySchedule Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceDefinition.Processing.Detail' -LogStringValues 'RoleEligibilitySchedule', $scopeObject.Scope $roleEligibilityScheduleRequest = Get-AzOpsRoleEligibilityScheduleRequest -ScopeObject $ScopeObject if ($roleEligibilityScheduleRequest) { $roleEligibilityScheduleRequest | ConvertTo-AzOpsState -StatePath $StatePath } } } function Get-AzOpsPolicy { <# .SYNOPSIS Get policy objects from provided scope .PARAMETER ScopeObject ScopeObject .PARAMETER StatePath StatePath .PARAMETER Subscription Complete Subscription list .PARAMETER SubscriptionsToIncludeResourceGroups Scoped Subscription list .PARAMETER ResourceGroup ResourceGroup switch indicating desired scope condition #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [object] $ScopeObject, [Parameter(Mandatory = $true)] $StatePath, [Parameter(Mandatory = $false)] [object] $Subscription, [Parameter(Mandatory = $false)] [object] $SubscriptionsToIncludeResourceGroups, [Parameter(Mandatory = $false)] [switch] $ResourceGroup ) process { if (-not $ResourceGroup) { # Process policy definitions Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceDefinition.Processing.Detail' -LogStringValues 'Policy Definitions', $scopeObject.Scope $policyDefinitions = Get-AzOpsPolicyDefinition -ScopeObject $ScopeObject -Subscription $Subscription $policyDefinitionsClean = @() foreach ($policyDefinition in $policyDefinitions) { $policyDefinitionClean = $policyDefinition | ConvertTo-Json -Depth 100 $policyDefinitionsClean += $policyDefinitionClean -replace 'T00:00:00Z' | ConvertFrom-Json -Depth 100 } $policyDefinitionsClean | ConvertTo-AzOpsState -StatePath $StatePath # Process policy set definitions (initiatives) Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceDefinition.Processing.Detail' -LogStringValues 'Policy Set Definitions', $ScopeObject.Scope $policySetDefinitions = Get-AzOpsPolicySetDefinition -ScopeObject $ScopeObject -Subscription $Subscription $policySetDefinitions | ConvertTo-AzOpsState -StatePath $StatePath } # Process policy assignments Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceDefinition.Processing.Detail' -LogStringValues 'Policy Assignments', $ScopeObject.Scope $policyAssignments = Get-AzOpsPolicyAssignment -ScopeObject $ScopeObject -Subscription $Subscription -SubscriptionsToIncludeResourceGroups $SubscriptionsToIncludeResourceGroups -ResourceGroup $ResourceGroup $policyAssignments | ConvertTo-AzOpsState -StatePath $StatePath # Process policy exemptions Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceDefinition.Processing.Detail' -LogStringValues 'Policy Exemptions', $ScopeObject.Scope $policyExemptions = Get-AzOpsPolicyExemption -ScopeObject $ScopeObject -Subscription $Subscription -SubscriptionsToIncludeResourceGroups $SubscriptionsToIncludeResourceGroups -ResourceGroup $ResourceGroup $policyExemptions | ConvertTo-AzOpsState -StatePath $StatePath } } function Get-AzOpsPolicyAssignment { <# .SYNOPSIS Discover all custom policy assignments at the provided scope (Management Groups, subscriptions or resource groups) .DESCRIPTION Discover all custom policy assignments at the provided scope (Management Groups, subscriptions or resource groups) .PARAMETER ScopeObject The scope object representing the azure entity to retrieve policyset definitions for. .PARAMETER Subscription Complete Subscription list .PARAMETER SubscriptionsToIncludeResourceGroups Scoped Subscription list .PARAMETER ResourceGroup ResourceGroup switch indicating desired scope condition .EXAMPLE > Get-AzOpsPolicyAssignment -ScopeObject (New-AzOpsScope -Scope /providers/Microsoft.Management/managementGroups/contoso -StatePath $StatePath) Discover all custom policy assignments deployed at Management Group scope #> [OutputType([Microsoft.Azure.PowerShell.Cmdlets.Policy.Models.IPolicyAssignment])] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [object] $ScopeObject, [Parameter(Mandatory = $false)] [object] $Subscription, [Parameter(Mandatory = $false)] [object] $SubscriptionsToIncludeResourceGroups, [Parameter(Mandatory = $false)] [bool] $ResourceGroup ) process { if ($ScopeObject.Type -notin 'resourceGroups', 'subscriptions', 'managementGroups') { return } if ($ScopeObject.Type -eq 'managementGroups') { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicyAssignment.ManagementGroup' -LogStringValues $ScopeObject.ManagementGroupDisplayName, $ScopeObject.ManagementGroup -Target $ScopeObject if ((-not $SubscriptionsToIncludeResourceGroups) -or (-not $ResourceGroups)) { $query = "policyresources | where type == 'microsoft.authorization/policyassignments' and resourceGroup == '' and subscriptionId == '' | order by ['id'] asc" Search-AzOpsAzGraph -ManagementGroupName $ScopeObject.Name -Query $query -ErrorAction Stop } } if ($Subscription) { if ($SubscriptionsToIncludeResourceGroups -and $ResourceGroup) { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicyAssignment.Subscription' -LogStringValues $SubscriptionsToIncludeResourceGroups.count -Target $ScopeObject $query = "policyresources | where type == 'microsoft.authorization/policyassignments' and resourceGroup != '' | order by ['id'] asc" Search-AzOpsAzGraph -Subscription $SubscriptionsToIncludeResourceGroups -Query $query -ErrorAction Stop } elseif ($ResourceGroup) { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicyAssignment.ResourceGroup' -LogStringValues $Subscription.count -Target $ScopeObject $query = "policyresources | where type == 'microsoft.authorization/policyassignments' and resourceGroup != '' | order by ['id'] asc" Search-AzOpsAzGraph -Subscription $Subscription -Query $query -ErrorAction Stop } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicyAssignment.Subscription' -LogStringValues $Subscription.count -Target $ScopeObject $query = "policyresources | where type == 'microsoft.authorization/policyassignments' and resourceGroup == '' | order by ['id'] asc" Search-AzOpsAzGraph -Subscription $Subscription -Query $query -ErrorAction Stop } } } } function Get-AzOpsPolicyDefinition { <# .SYNOPSIS Discover all custom policy definitions at the provided scope (Management Groups or subscriptions) .DESCRIPTION Discover all custom policy definitions at the provided scope (Management Groups or subscriptions) .PARAMETER ScopeObject The scope object representing the azure entity to retrieve policy definitions for. .PARAMETER Subscription Complete Subscription list .EXAMPLE > Get-AzOpsPolicyDefinition -ScopeObject (New-AzOpsScope -Scope /providers/Microsoft.Management/managementGroups/contoso -StatePath $StatePath) Discover all custom policy definitions deployed at Management Group scope #> [OutputType([Microsoft.Azure.PowerShell.Cmdlets.Policy.Models.IPolicyDefinition])] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [object] $ScopeObject, [Parameter(Mandatory = $false)] [object] $Subscription ) process { if ($ScopeObject.Type -notin 'subscriptions', 'managementGroups') { return } if ($ScopeObject.Type -eq 'managementGroups') { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicyDefinition.ManagementGroup' -LogStringValues $ScopeObject.ManagementGroupDisplayName, $ScopeObject.ManagementGroup -Target $ScopeObject $query = "policyresources | where type == 'microsoft.authorization/policydefinitions' and properties.policyType == 'Custom' and subscriptionId == '' | order by ['id'] asc" Search-AzOpsAzGraph -ManagementGroupName $ScopeObject.Name -Query $query -ErrorAction Stop } if ($Subscription) { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicyDefinition.Subscription' -LogStringValues $Subscription.count -Target $ScopeObject $query = "policyresources | where type == 'microsoft.authorization/policydefinitions' and properties.policyType == 'Custom' | order by ['id'] asc" Search-AzOpsAzGraph -Subscription $Subscription -Query $query -ErrorAction Stop } } } function Get-AzOpsPolicyExemption { <# .SYNOPSIS Discover all custom policy exemptions at the provided scope (Management Groups, subscriptions or resource groups) .DESCRIPTION Discover all custom policy exemptions at the provided scope (Management Groups, subscriptions or resource groups) .PARAMETER ScopeObject The scope object representing the azure entity to retrieve excemptions for. .PARAMETER Subscription Complete Subscription list .PARAMETER SubscriptionsToIncludeResourceGroups Scoped Subscription list .PARAMETER ResourceGroup ResourceGroup switch indicating desired scope condition .EXAMPLE > Get-AzOpsPolicyExemption -ScopeObject (New-AzOpsScope -Scope /providers/Microsoft.Management/managementGroups/contoso -StatePath $StatePath) Discover all custom policy exemptions deployed at Management Group scope #> [OutputType([Microsoft.Azure.PowerShell.Cmdlets.Policy.Models.IPolicyExemption])] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Object] $ScopeObject, [Parameter(Mandatory = $false)] [object] $Subscription, [Parameter(Mandatory = $false)] [object] $SubscriptionsToIncludeResourceGroups, [Parameter(Mandatory = $false)] [bool] $ResourceGroup ) process { if ($ScopeObject.Type -notin 'resourceGroups', 'subscriptions', 'managementGroups') { return } if ($ScopeObject.Type -eq 'managementGroups') { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicyExemption.ManagementGroup' -LogStringValues $ScopeObject.ManagementGroupDisplayName, $ScopeObject.ManagementGroup -Target $ScopeObject if ((-not $SubscriptionsToIncludeResourceGroups) -or (-not $ResourceGroups)) { $query = "policyresources | where type == 'microsoft.authorization/policyexemptions' and resourceGroup == '' and subscriptionId == '' | order by ['id'] asc" Search-AzOpsAzGraph -ManagementGroupName $ScopeObject.Name -Query $query -ErrorAction Stop } } if ($Subscription) { if ($SubscriptionsToIncludeResourceGroups -and $ResourceGroup) { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicyExemption.Subscription' -LogStringValues $ScopeObject.SubscriptionDisplayName, $ScopeObject.Subscription -Target $ScopeObject $query = "policyresources | where type == 'microsoft.authorization/policyexemptions' and resourceGroup != '' | order by ['id'] asc" Search-AzOpsAzGraph -Subscription $SubscriptionsToIncludeResourceGroups -Query $query -ErrorAction Stop } elseif ($ResourceGroup) { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicyExemption.ResourceGroup' -LogStringValues $ScopeObject.ResourceGroup -Target $ScopeObject $query = "policyresources | where type == 'microsoft.authorization/policyexemptions' and resourceGroup != '' | order by ['id'] asc" Search-AzOpsAzGraph -Subscription $Subscription -Query $query -ErrorAction Stop } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicyExemption.Subscription' -LogStringValues $ScopeObject.SubscriptionDisplayName, $ScopeObject.Subscription -Target $ScopeObject $query = "policyresources | where type == 'microsoft.authorization/policyexemptions' and resourceGroup == '' | order by ['id'] asc" Search-AzOpsAzGraph -Subscription $Subscription -Query $query -ErrorAction Stop } } } } function Get-AzOpsPolicySetDefinition { <# .SYNOPSIS Discover all custom policyset definitions at the provided scope (Management Groups or subscriptions) .DESCRIPTION Discover all custom policyset definitions at the provided scope (Management Groups or subscriptions) .PARAMETER ScopeObject The scope object representing the azure entity to retrieve policyset definitions for. .PARAMETER Subscription Complete Subscription list .EXAMPLE > Get-AzOpsPolicySetDefinition -ScopeObject (New-AzOpsScope -Scope /providers/Microsoft.Management/managementGroups/contoso -StatePath $StatePath) Discover all custom policyset definitions deployed at Management Group scope #> [OutputType([Microsoft.Azure.PowerShell.Cmdlets.Policy.Models.IPolicySetDefinition])] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [object] $ScopeObject, [Parameter(Mandatory = $false)] [object] $Subscription ) process { if ($ScopeObject.Type -notin 'subscriptions', 'managementGroups') { return } if ($ScopeObject.Type -eq 'managementGroups') { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicySetDefinition.ManagementGroup' -LogStringValues $ScopeObject.ManagementGroupDisplayName, $ScopeObject.ManagementGroup -Target $ScopeObject $query = "policyresources | where type == 'microsoft.authorization/policysetdefinitions' and properties.policyType == 'Custom' and subscriptionId == '' | order by ['id'] asc" Search-AzOpsAzGraph -ManagementGroupName $ScopeObject.Name -Query $query -ErrorAction Stop } if ($Subscription) { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsPolicySetDefinition.Subscription' -LogStringValues $Subscription.count -Target $ScopeObject $query = "policyresources | where type == 'microsoft.authorization/policysetdefinitions' and properties.policyType == 'Custom' | order by ['id'] asc" Search-AzOpsAzGraph -Subscription $Subscription -Query $query -ErrorAction Stop } } } function Get-AzOpsResource { <# .SYNOPSIS Check if the Azure resource exists. .DESCRIPTION Check if the Azure resource exists. .PARAMETER ScopeObject The Resource to check. .EXAMPLE > Get-AzOpsResource -ScopeObject $ScopeObject #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [AzOpsScope] $ScopeObject ) process { Set-AzOpsContext -ScopeObject $ScopeObject try { switch ($ScopeObject.Resource) { # Check if the resource exist 'locks' { $resource = Get-AzResourceLock -Scope "/subscriptions/$($ScopeObject.Subscription)" -ErrorAction SilentlyContinue | Where-Object { $_.ResourceID -eq $ScopeObject.Scope } } 'policyAssignments' { $resource = Get-AzPolicyAssignment -Id $scopeObject.Scope -ErrorAction SilentlyContinue } 'policyDefinitions' { $resource = Get-AzPolicyDefinition -Id $scopeObject.Scope -ErrorAction SilentlyContinue } 'policyExemptions' { $resource = Get-AzPolicyExemption -Id $scopeObject.Scope -ErrorAction SilentlyContinue } 'policySetDefinitions' { $resource = Get-AzPolicySetDefinition -Id $scopeObject.Scope -ErrorAction SilentlyContinue } 'roleAssignments' { $resource = Invoke-AzRestMethod -Path "$($scopeObject.Scope)?api-version=2022-04-01" | Where-Object { $_.StatusCode -eq 200 } } 'resourceGroups' { $resource = Get-AzResourceGroup -Id $scopeObject.Scope -ErrorAction SilentlyContinue } default { $resource = Get-AzResource -ResourceId $ScopeObject.Scope -ErrorAction SilentlyContinue } } } catch { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Get-AzOpsResource.Failed' -LogStringValues $_ return } if ($resource) { return $resource } } } function Get-AzOpsResourceDefinition { <# .SYNOPSIS This cmdlet discovers resources (Management Groups, Subscriptions, Resource Groups, Resources, Privileged Identity Management resources, Policies, Role Assignments) from the provided input scope. .DESCRIPTION This cmdlet discovers resources (Management Groups, Subscriptions, Resource Groups, Resources, Privileged Identity Management resources, Policies, Role Assignments) from the provided input scope. .PARAMETER Scope Discovery Scope .PARAMETER IncludeResourcesInResourceGroup Discover only resources in these resource groups. .PARAMETER IncludeResourceType Discover only specific resource types. .PARAMETER SkipChildResource Skip childResource discovery. .PARAMETER SkipPim Skip discovery of Privileged Identity Management. .PARAMETER SkipLock Skip discovery of resourceLock. .PARAMETER SkipPolicy Skip discovery of policies. .PARAMETER SkipResource Skip discovery of resources inside resource groups. .PARAMETER SkipResourceGroup Skip discovery of resource groups. .PARAMETER SkipResourceType Skip discovery of specific resource types. .PARAMETER SkipRole Skip discovery of roles for better performance. .PARAMETER StatePath The root folder under which to write the resource json. .PARAMETER SubscriptionsToIncludeChildResource Filter which Subscription IDs should include child resources in pull. .PARAMETER SubscriptionsToIncludeResourceGroups Filter which Subscription IDs should include Resource Groups in pull. .EXAMPLE $TenantRootId = '/providers/Microsoft.Management/managementGroups/{0}' -f (Get-AzTenant).Id Get-AzOpsResourceDefinition -scope $TenantRootId -Verbose Discover all resources from root Management Group .EXAMPLE Get-AzOpsResourceDefinition -scope /providers/Microsoft.Management/managementGroups/landingzones -SkipPolicy -SkipResourceGroup Discover all resources from child Management Group, skip discovery of policies and resource groups .EXAMPLE Get-AzOpsResourceDefinition -scope /subscriptions/623625ae-cfb0-4d55-b8ab-0bab99cbf45c Discover all resources from Subscription level .EXAMPLE Get-AzOpsResourceDefinition -scope /subscriptions/623625ae-cfb0-4d55-b8ab-0bab99cbf45c/resourceGroups/myresourcegroup Discover all resources from resource group level .EXAMPLE Get-AzOpsResourceDefinition -scope /subscriptions/623625ae-cfb0-4d55-b8ab-0bab99cbf45c/resourceGroups/contoso-global-dns/providers/Microsoft.Network/privateDnsZones/privatelink.database.windows.net Discover a single resource #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $Scope, [string[]] $IncludeResourcesInResourceGroup = (Get-PSFConfigValue -FullName 'AzOps.Core.IncludeResourcesInResourceGroup'), [string[]] $IncludeResourceType = (Get-PSFConfigValue -FullName 'AzOps.Core.IncludeResourceType'), [switch] $SkipChildResource = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipChildResource'), [switch] $SkipPim = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipPim'), [switch] $SkipLock = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipLock'), [switch] $SkipPolicy = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipPolicy'), [switch] $SkipResource = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipResource'), [switch] $SkipResourceGroup = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipResourceGroup'), [string[]] $SkipResourceType = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipResourceType'), [switch] $SkipRole = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipRole'), [Parameter(Mandatory = $false)] [string] $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State'), [string[]] $SubscriptionsToIncludeChildResource = (Get-PSFConfigValue -FullName 'AzOps.Core.SubscriptionsToIncludeChildResource'), [string[]] $SubscriptionsToIncludeResourceGroups = (Get-PSFConfigValue -FullName 'AzOps.Core.SubscriptionsToIncludeResourceGroups') ) begin { # Set variables for retry with exponential backoff $backoffMultiplier = 2 $maxRetryCount = 3 # Prepare Input Data for parallel processing $runspaceData = @{ AzOpsPath = "$($script:ModuleRoot)\AzOps.psd1" StatePath = $StatePath runspace_AzOpsAzManagementGroup = $script:AzOpsAzManagementGroup runspace_AzOpsSubscriptions = $script:AzOpsSubscriptions runspace_AzOpsPartialRoot = $script:AzOpsPartialRoot runspace_AzOpsResourceProvider = $script:AzOpsResourceProvider BackoffMultiplier = $backoffMultiplier MaxRetryCount = $maxRetryCount } } process { Write-AzOpsMessage -LogLevel Important -LogString 'Get-AzOpsResourceDefinition.Processing' -LogStringValues $Scope try { $scopeObject = New-AzOpsScope -Scope $Scope -StatePath $StatePath -ErrorAction Stop } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsResourceDefinition.Processing.NotFound' -LogStringValues $Scope return } if ($scopeObject.Type -notin 'subscriptions', 'managementGroups') { Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceDefinition.Finished' -LogStringValues $scopeObject.Scope return } switch ($scopeObject.Type) { subscriptions { Write-AzOpsMessage -LogLevel Important -LogString 'Get-AzOpsResourceDefinition.Subscription.Processing' -LogStringValues $ScopeObject.SubscriptionDisplayName, $ScopeObject.Subscription -Target $ScopeObject $subscriptions = Get-AzSubscription -SubscriptionId $scopeObject.Name | Where-Object { "/subscriptions/$($_.Id)" -in $script:AzOpsSubscriptions.id } } managementGroups { Write-AzOpsMessage -LogLevel Important -LogString 'Get-AzOpsResourceDefinition.ManagementGroup.Processing' -LogStringValues $ScopeObject.ManagementGroupDisplayName, $ScopeObject.ManagementGroup -Target $ScopeObject $query = "resourcecontainers | where type == 'microsoft.management/managementgroups' | order by ['id'] asc" $managementgroups = Search-AzOpsAzGraph -ManagementGroupName $scopeObject.Name -Query $query -ErrorAction Stop | Where-Object { $_.id -in $script:AzOpsAzManagementGroup.Id } $subscriptions = Get-AzOpsNestedSubscription -Scope $scopeObject.Name if ($managementgroups) { # Process managementGroup scope in parallel $managementgroups | Foreach-Object -ThrottleLimit (Get-PSFConfigValue -FullName 'AzOps.Core.ThrottleLimit') -Parallel { $managementgroup = $_ $runspaceData = $using:runspaceData Import-Module "$([PSFramework.PSFCore.PSFCoreHost]::ModuleRoot)/PSFramework.psd1" $azOps = Import-Module $runspaceData.AzOpsPath -Force -PassThru & $azOps { $script:AzOpsAzManagementGroup = $runspaceData.runspace_AzOpsAzManagementGroup $script:AzOpsSubscriptions = $runspaceData.runspace_AzOpsSubscriptions $script:AzOpsPartialRoot = $runspaceData.runspace_AzOpsPartialRoot $script:AzOpsResourceProvider = $runspaceData.runspace_AzOpsResourceProvider } # Process Privileged Identity Management resources and Roles at managementGroup scope if ((-not $using:SkipPim) -or (-not $using:SkipRole)) { & $azOps { $ScopeObject = New-AzOpsScope -Scope $managementgroup.id -StatePath $runspaceData.Statepath -ErrorAction Stop if (-not $using:SkipPim) { Get-AzOpsPim -ScopeObject $ScopeObject -StatePath $runspaceData.Statepath } if (-not $using:SkipRole) { Get-AzOpsRole -ScopeObject $ScopeObject -StatePath $runspaceData.Statepath } } } } Clear-PSFMessage } } } #region Process Policies at $scopeObject if (-not $SkipPolicy) { Get-AzOpsPolicy -ScopeObject $scopeObject -Subscription $subscriptions -StatePath $StatePath } #endregion Process Policies at $scopeObject #region Process subscription scope in parallel if ($subscriptions) { $subscriptions | Foreach-Object -ThrottleLimit (Get-PSFConfigValue -FullName 'AzOps.Core.ThrottleLimit') -Parallel { $subscription = $_ $runspaceData = $using:runspaceData Import-Module "$([PSFramework.PSFCore.PSFCoreHost]::ModuleRoot)/PSFramework.psd1" $azOps = Import-Module $runspaceData.AzOpsPath -Force -PassThru & $azOps { $script:AzOpsAzManagementGroup = $runspaceData.runspace_AzOpsAzManagementGroup $script:AzOpsSubscriptions = $runspaceData.runspace_AzOpsSubscriptions $script:AzOpsPartialRoot = $runspaceData.runspace_AzOpsPartialRoot $script:AzOpsResourceProvider = $runspaceData.runspace_AzOpsResourceProvider } # Process Privileged Identity Management resources, Locks and Roles at subscription scope if ((-not $using:SkipPim) -or (-not $using:SkipLock) -or (-not $using:SkipRole)) { & $azOps { $scopeObject = New-AzOpsScope -Scope ($subscription.Type + '/' + $subscription.Id) -StatePath $runspaceData.Statepath -ErrorAction Stop if (-not $using:SkipPim) { Get-AzOpsPim -ScopeObject $scopeObject -StatePath $runspaceData.Statepath } if (-not $using:SkipLock) { Get-AzOpsResourceLock -ScopeObject $scopeObject -StatePath $runspaceData.Statepath } if (-not $using:SkipRole) { Get-AzOpsRole -ScopeObject $scopeObject -StatePath $runspaceData.Statepath } } } } Clear-PSFMessage } #endregion Process subscription scope in parallel #region Process Resource Groups if ($SkipResourceGroup -or (-not $subscriptions)) { if ($SkipResourceGroup) { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsResourceDefinition.SkippingResourceGroup' -Target $ScopeObject } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsResourceDefinition.Subscription.NotFound' -Target $ScopeObject } } else { $query = "resourcecontainers | where type == 'microsoft.resources/subscriptions/resourcegroups' | where managedBy == '' | order by ['id'] asc" if ($SubscriptionsToIncludeResourceGroups -ne '*') { $newSubscriptionsToIncludeResourceGroups = $subscriptions | Where-Object { $_.Id -in $SubscriptionsToIncludeResourceGroups } if ($newSubscriptionsToIncludeResourceGroups) { $resourceGroups = Search-AzOpsAzGraph -Subscription $newSubscriptionsToIncludeResourceGroups -Query $query -ErrorAction Stop } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsResourceDefinition.Subscription.NotFound' -Target $ScopeObject } } else { $resourceGroups = Search-AzOpsAzGraph -Subscription $subscriptions -Query $query -ErrorAction Stop } if ($resourceGroups) { # Process Resource Groups in parallel $resourceGroups | Foreach-Object -ThrottleLimit (Get-PSFConfigValue -FullName 'AzOps.Core.ThrottleLimit') -Parallel { $resourceGroup = $_ $runspaceData = $using:runspaceData Import-Module "$([PSFramework.PSFCore.PSFCoreHost]::ModuleRoot)/PSFramework.psd1" $azOps = Import-Module $runspaceData.AzOpsPath -Force -PassThru & $azOps { $script:AzOpsAzManagementGroup = $runspaceData.runspace_AzOpsAzManagementGroup $script:AzOpsSubscriptions = $runspaceData.runspace_AzOpsSubscriptions $script:AzOpsPartialRoot = $runspaceData.runspace_AzOpsPartialRoot $script:AzOpsResourceProvider = $runspaceData.runspace_AzOpsResourceProvider } # Create Resource Group in file system & $azOps { ConvertTo-AzOpsState -Resource $resourceGroup -StatePath $runspaceData.Statepath } # Process Privileged Identity Management resources, Locks and Roles at resource group scope if ((-not $using:SkipPim) -or (-not $using:SkipRole) -or (-not $using:SkipLock)) { & $azOps { $rgScopeObject = New-AzOpsScope -Scope $resourceGroup.id -StatePath $runspaceData.Statepath -ErrorAction Stop if (-not $using:SkipLock) { Get-AzOpsResourceLock -ScopeObject $rgScopeObject -StatePath $runspaceData.Statepath } if (-not $using:SkipPim) { Get-AzOpsPim -ScopeObject $rgScopeObject -StatePath $runspaceData.Statepath } if (-not $using:SkipRole) { Get-AzOpsRole -ScopeObject $rgScopeObject -StatePath $runspaceData.Statepath } } } } Clear-PSFMessage } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsResourceDefinition.NoResourceGroup' -LogStringValues $scopeObject.Name -Target $ScopeObject } # Process Policies at Resource Group scope if (-not $SkipPolicy) { if ($newSubscriptionsToIncludeResourceGroups) { Get-AzOpsPolicy -ScopeObject $scopeObject -Subscription $subscriptions -SubscriptionsToIncludeResourceGroups $newSubscriptionsToIncludeResourceGroups -ResourceGroup -StatePath $StatePath } else { Get-AzOpsPolicy -ScopeObject $scopeObject -Subscription $subscriptions -ResourceGroup -StatePath $StatePath } } # Process Resources at Resource Group scope if (-not $SkipResource) { Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceDefinition.Processing.Resource.Discovery' -LogStringValues $scopeObject.Name -Target $ScopeObject try { $SkipResourceType | ForEach-Object { $skipResourceTypes += ($(if($skipResourceTypes){","}) + "'" + $_ + "'") } $query = "resources | where type !in~ ($skipResourceTypes)" if ($IncludeResourceType -ne "*") { $IncludeResourceType | ForEach-Object { $includeResourceTypes += ($(if($includeResourceTypes){","}) + "'" + $_ + "'") } $query = $query + " and type in~ ($includeResourceTypes)" } if ($IncludeResourcesInResourceGroup -ne "*") { $IncludeResourcesInResourceGroup | ForEach-Object { $includeResourcesInResourceGroups += ($(if($includeResourcesInResourceGroups){","}) + "'" + $_ + "'") } $query = $query + " and resourceGroup in~ ($includeResourcesInResourceGroups)" } $query = $query + " | order by ['id'] asc" $resourcesBase = Search-AzOpsAzGraph -Subscription $subscriptions -Query $query -ErrorAction Stop } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsResourceDefinition.Processing.Resource.Warning' -LogStringValues $scopeObject.Name -Target $ScopeObject } if ($resourcesBase) { $resources = @() foreach ($resource in $resourcesBase) { if ($resourceGroups | Where-Object { $_.name -eq $resource.resourceGroup -and $_.subscriptionId -eq $resource.subscriptionId }) { Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceDefinition.Processing.Resource' -LogStringValues $resource.name, $resource.resourcegroup -Target $resource $resources += $resource ConvertTo-AzOpsState -Resource $resource -StatePath $Statepath } } } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsResourceDefinition.Processing.Resource.Discovery.NotFound' -LogStringValues $scopeObject.Name -Target $ScopeObject } } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsResourceDefinition.SkippingResources' -Target $ScopeObject } # Process Child resources at resource scope in parallel if (-not $SkipResource -and -not $SkipChildResource) { if ($SubscriptionsToIncludeChildResource -ne '*') { $resources = $resources | Where-Object { $_.subscriptionId -in $SubscriptionsToIncludeChildResource } } $resources | Foreach-Object -ThrottleLimit (Get-PSFConfigValue -FullName 'AzOps.Core.ThrottleLimit') -Parallel { $resource = $_ $runspaceData = $using:runspaceData Import-Module "$([PSFramework.PSFCore.PSFCoreHost]::ModuleRoot)/PSFramework.psd1" $azOps = Import-Module $runspaceData.AzOpsPath -Force -PassThru & $azOps { $script:AzOpsAzManagementGroup = $runspaceData.runspace_AzOpsAzManagementGroup $script:AzOpsSubscriptions = $runspaceData.runspace_AzOpsSubscriptions $script:AzOpsPartialRoot = $runspaceData.runspace_AzOpsPartialRoot $script:AzOpsResourceProvider = $runspaceData.runspace_AzOpsResourceProvider } $context = Get-AzContext $context.Subscription.Id = $resource.subscriptionId $tempExportPath = [System.IO.Path]::GetTempPath() + (New-Guid).ToString() + '.json' try { & $azOps { $exportParameters = @{ Resource = $resource.id ResourceGroupName = $resource.resourceGroup SkipAllParameterization = $true Path = $tempExportPath DefaultProfile = $context | Select-Object -First 1 } Invoke-AzOpsScriptBlock -ArgumentList $exportParameters -ScriptBlock { param ( $ExportParameters ) $param = $ExportParameters | Write-Output Export-AzResourceGroup @param -Confirm:$false -Force -ErrorAction Stop | Out-Null } -RetryCount $runspaceData.MaxRetryCount -RetryWait $runspaceData.BackoffMultiplier -RetryType Exponential } $exportResources = (Get-Content -Path $tempExportPath | ConvertFrom-Json).resources $resourceGroup = $using:resourceGroups | Where-Object {$_.subscriptionId -eq $resource.subscriptionId -and $_.name -eq $resource.resourceGroup} foreach ($exportResource in $exportResources) { if (-not(($resource.name -eq $exportResource.name) -and ($resource.type -eq $exportResource.type))) { & $azOps { Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceDefinition.Processing.ChildResource' -LogStringValues $exportResource.name, $resource.resourceGroup -FunctionName "Get-AzOpsResourceDefinition" -ModuleName "AzOps" -Target $exportResource } $ChildResource = @{ resourceProvider = $exportResource.type -replace '/', '_' resourceName = $exportResource.name -replace '/', '_' parentResourceId = $resourceGroup.id } if (Get-Member -InputObject $exportResource -name 'dependsOn') { $exportResource.PsObject.Members.Remove('dependsOn') } $resourceHash = @{resources = @($exportResource) } & $azOps { ConvertTo-AzOpsState -Resource $resourceHash -ChildResource $ChildResource -StatePath $runspaceData.Statepath } } } } catch { & $azOps { Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsResourceDefinition.ChildResource.Warning' -LogStringValues $resource.resourceGroup, $_ -FunctionName "Get-AzOpsResourceDefinition" -ModuleName "AzOps" } } if (Test-Path -Path $tempExportPath) { Remove-Item -Path $tempExportPath } } Clear-PSFMessage } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsResourceDefinition.SkippingChildResources' -Target $ScopeObject } } #endregion Process Resource Groups Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsResourceDefinition.Finished' -LogStringValues $scopeObject.Scope -Target $ScopeObject } } function Get-AzOpsResourceLock { <# .SYNOPSIS Discover resource locks at the provided scope (Subscription or resource group) .DESCRIPTION Discover resource locks at the provided scope (Subscription or resource group) .PARAMETER ScopeObject The scope object representing the azure entity to retrieve resource locks from. .PARAMETER StatePath StatePath .EXAMPLE > Get-AzOpsResourceLock -ScopeObject xxx Discover all resource locks deployed at resource group scope #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Object] $ScopeObject, [Parameter(Mandatory = $true)] $StatePath ) process { if ($ScopeObject.Type -notin 'resourceGroups', 'subscriptions') { return } switch ($ScopeObject.Type) { subscriptions { # ScopeObject is a subscription Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceLock.Subscription' -LogStringValues $ScopeObject.SubscriptionDisplayName, $ScopeObject.Subscription -Target $ScopeObject } resourcegroups { # ScopeObject is a resourcegroup Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceLock.ResourceGroup' -LogStringValues $ScopeObject.ResourceGroup -Target $ScopeObject } } try { $parameters = @{ Scope = $ScopeObject.Scope } # Gather resource locks at scopeObject with retry and backoff support from Invoke-AzOpsScriptBlock $resourceLocks = Invoke-AzOpsScriptBlock -ArgumentList $parameters -ScriptBlock { Get-AzResourceLock @parameters -AtScope -ErrorAction Stop | Where-Object {$($_.ResourceID.Substring(0, $_.ResourceId.LastIndexOf('/'))) -Like ("$($parameters.Scope)/providers/Microsoft.Authorization/locks")} } -RetryCount 3 -RetryWait 5 -RetryType Exponential -ErrorAction Stop } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsResourceLock.Failed' -LogStringValues $_ } if ($resourceLocks) { # Process each resource lock foreach ($lock in $resourceLocks) { $lock | ConvertTo-AzOpsState -StatePath $StatePath } } } } function Get-AzOpsRole { <# .SYNOPSIS Get role objects from provided scope .PARAMETER ScopeObject ScopeObject .PARAMETER StatePath StatePath #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Object] $ScopeObject, [Parameter(Mandatory = $true)] $StatePath ) process { # Process role definitions Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceDefinition.Processing.Detail' -LogStringValues 'Role Definitions', $ScopeObject.Scope $roleDefinitions = Get-AzOpsRoleDefinition -ScopeObject $ScopeObject if ($roleDefinitions) { $roleDefinitions | ConvertTo-AzOpsState -StatePath $StatePath } # Process role assignments Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsResourceDefinition.Processing.Detail' -LogStringValues 'Role Assignments', $ScopeObject.Scope $roleAssignments = Get-AzOpsRoleAssignment -ScopeObject $ScopeObject if ($roleAssignments) { $roleAssignments | ConvertTo-AzOpsState -StatePath $StatePath } } } function Get-AzOpsRoleAssignment { <# .SYNOPSIS Discovers all custom Role Assignment at the provided scope (Management Groups, subscriptions or resource groups) .DESCRIPTION Discovers all custom Role Assignment at the provided scope (Management Groups, subscriptions or resource groups) .PARAMETER ScopeObject The scope object representing the azure entity to retrieve role assignments for. .EXAMPLE > Get-AzOpsRoleAssignment -ScopeObject (New-AzOpsScope -Scope /providers/Microsoft.Management/managementGroups/contoso -StatePath $StatePath) Discover all custom role assignments deployed at Management Group scope #> [CmdletBinding()] param ( [parameter(Mandatory = $true, ValueFromPipeline = $true)] [Object] $ScopeObject ) process { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsRoleAssignment.Processing' -LogStringValues $ScopeObject -Target $ScopeObject $apiVersion = (($script:AzOpsResourceProvider | Where-Object {$_.ProviderNamespace -eq 'Microsoft.Authorization'}).ResourceTypes | Where-Object {$_.ResourceTypeName -eq 'roleAssignments'}).ApiVersions | Select-Object -First 1 $path = "$($scopeObject.Scope)/providers/Microsoft.Authorization/roleAssignments?api-version=$apiVersion&`$filter=atScope()" try { $parameters = @{ Path = $path Method = 'GET' } # Gather roleAssignment with retry and backoff support from Invoke-AzOpsScriptBlock $roleAssignments = Invoke-AzOpsScriptBlock -ArgumentList $parameters -ScriptBlock { Invoke-AzOpsRestMethod @parameters -ErrorAction Stop } -RetryCount 3 -RetryWait 5 -RetryType Exponential -ErrorAction Stop } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsRoleAssignment.Processing.Failed' -LogStringValues $_ } if ($roleAssignments) { $roleAssignmentMatch = @() foreach ($roleAssignment in $roleAssignments) { if ($roleAssignment.properties.scope -eq $ScopeObject.Scope) { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsRoleAssignment.Assignment' -LogStringValues $roleAssignment.id, $roleAssignment.properties.roleDefinitionId -Target $ScopeObject $roleAssignmentMatch += [PSCustomObject]@{ id = $roleAssignment.id name = $roleAssignment.name properties = $roleAssignment.properties type = $roleAssignment.type } } } if ($roleAssignmentMatch) { return $roleAssignmentMatch } } } } function Get-AzOpsRoleDefinition { <# .SYNOPSIS Discover all custom Role Definition at the provided scope (Management Groups, subscriptions or resource groups) .DESCRIPTION Discover all custom Role Definition at the provided scope (Management Groups, subscriptions or resource groups) .PARAMETER ScopeObject The scope object representing the azure entity to retrieve role definitions for. .EXAMPLE > Get-AzOpsRoleDefinition -ScopeObject (New-AzOpsScope -Scope /providers/Microsoft.Management/managementGroups/contoso -StatePath $StatePath) Discover all custom role definitions deployed at Management Group scope #> [CmdletBinding()] param ( [parameter(Mandatory = $true, ValueFromPipeline = $true)] [Object] $ScopeObject ) process { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsRoleDefinition.Processing' -LogStringValues $ScopeObject -Target $ScopeObject $apiVersion = (($script:AzOpsResourceProvider | Where-Object {$_.ProviderNamespace -eq 'Microsoft.Authorization'}).ResourceTypes | Where-Object {$_.ResourceTypeName -eq 'roleDefinitions'}).ApiVersions | Select-Object -First 1 $path = "$($scopeObject.Scope)/providers/Microsoft.Authorization/roleDefinitions?api-version=$apiVersion&`$filter=type+eq+'CustomRole'" try { $parameters = @{ Path = $path Method = 'GET' } # Gather roleDefinitions with retry and backoff support from Invoke-AzOpsScriptBlock $roleDefinitions = Invoke-AzOpsScriptBlock -ArgumentList $parameters -ScriptBlock { Invoke-AzOpsRestMethod @parameters -ErrorAction Stop } -RetryCount 3 -RetryWait 5 -RetryType Exponential -ErrorAction Stop } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsRoleDefinition.Processing.Failed' -LogStringValues $_ } if ($roleDefinitions) { $roleDefinitionsMatch = @() foreach ($roleDefinition in $roleDefinitions) { if ($roleDefinition.properties.assignableScopes -eq $ScopeObject.Scope) { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsRoleDefinition.Definition' -LogStringValues $roleDefinition.id -Target $ScopeObject $roleDefinitionsMatch += [PSCustomObject]@{ # Removing the Trailing slash to ensure that '/' is not appended twice when adding '/providers/xxx'. # Example: '/subscriptions/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX/' is a valid assignment scope. id = '/' + $roleDefinition.properties.assignableScopes[0].Trim('/') + '/providers/Microsoft.Authorization/roleDefinitions/' + $roleDefinition.id name = $roleDefinition.Name properties = $roleDefinition.properties type = $roleDefinition.type } } } if ($roleDefinitionsMatch) { return $roleDefinitionsMatch } } } } function Get-AzOpsRoleEligibilityScheduleRequest { <# .SYNOPSIS Discover all Privileged Identity Management RoleEligibilityScheduleRequest at the provided scope (Management Groups, subscriptions or resource groups) .DESCRIPTION Discover all Privileged Identity Management RoleEligibilityScheduleRequest at the provided scope (Management Groups, subscriptions or resource groups) .PARAMETER ScopeObject The scope object representing the azure entity to retrieve policy definitions for. .EXAMPLE > Get-AzOpsRoleEligibilityScheduleRequest -ScopeObject (New-AzOpsScope -Scope /providers/Microsoft.Management/managementGroups/contoso -StatePath $StatePath) Discover all Privileged Identity Management RoleEligibilityScheduleRequest at Management Group scope #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Object] $ScopeObject ) process { if ($ScopeObject.Type -notin 'resourceGroups', 'subscriptions', 'managementGroups') { return } # Process RoleEligibilitySchedule which is used to construct AzOpsRoleEligibilityScheduleRequest Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsRoleEligibilityScheduleRequest.Processing' -LogStringValues $ScopeObject.Scope -Target $ScopeObject try { $parameters = @{ Scope = $ScopeObject.Scope } $roleEligibilitySchedules = Invoke-AzOpsScriptBlock -ArgumentList $parameters -ScriptBlock { Get-AzRoleEligibilitySchedule @parameters -WarningAction SilentlyContinue -ErrorAction Stop | Where-Object { $_.Scope -eq $parameters.Scope } } -RetryCount 3 -RetryWait 5 -RetryType Exponential -ErrorAction Stop } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsRoleEligibilityScheduleRequest.Processing.Failed' -LogStringValues $_ return } if ($roleEligibilitySchedules) { foreach ($roleEligibilitySchedule in $roleEligibilitySchedules) { # Process roleEligibilitySchedule together with RoleEligibilityScheduleRequest $parameters = @{ Scope = $ScopeObject.Scope Name = $roleEligibilitySchedule.Name } $roleEligibilityScheduleRequest = $null $roleEligibilityScheduleRequest = Invoke-AzOpsScriptBlock -ArgumentList $parameters -ScriptBlock { Get-AzRoleEligibilityScheduleRequest @parameters -ErrorAction SilentlyContinue } -RetryCount 3 -RetryWait 5 -RetryType Exponential -ErrorAction SilentlyContinue if ($roleEligibilityScheduleRequest) { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsRoleEligibilityScheduleRequest.Assignment' -LogStringValues $roleEligibilitySchedule.Name -Target $ScopeObject # Construct AzOpsRoleEligibilityScheduleRequest by combining information from roleEligibilitySchedule and roleEligibilityScheduleRequest [AzOpsRoleEligibilityScheduleRequest]::new($roleEligibilitySchedule, $roleEligibilityScheduleRequest) } else { Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsRoleEligibilityScheduleRequest.Processing.NotFound' -LogStringValues $ScopeObject.Scope, $roleEligibilitySchedule.Name -Target $ScopeObject # Construct AzOpsRoleEligibilityScheduleRequest from roleEligibilitySchedule since no AzRoleEligibilityScheduleRequest was found [AzOpsRoleEligibilityScheduleRequest]::new($roleEligibilitySchedule) } } } } } function Get-AzOpsSubscription { <# .SYNOPSIS Returns a list of applicable subscriptions. .DESCRIPTION Returns a list of applicable subscriptions. "Applicable" generally refers to active, non-trial subscriptions. .PARAMETER ExcludedOffers Specific offers to exclude (e.g. specific trial offerings) .PARAMETER ExcludedStates Specific subscription states to ignore (e.g. expired subscriptions) .PARAMETER TenantId ID of the tenant to search in. Must be a connected tenant. .PARAMETER ApiVersion What version of the AZ Api to communicate with. .EXAMPLE > Get-AzOpsSubscription -TenantId $TenantId Returns active, non-trial subscriptions of the specified tenant. #> [CmdletBinding()] param ( [string[]] $ExcludedOffers = (Get-PSFConfigValue -FullName 'AzOps.Core.ExcludedSubOffer'), [string[]] $ExcludedStates = (Get-PSFConfigValue -FullName 'AzOps.Core.ExcludedSubState'), [Parameter(Mandatory = $true)] [ValidateScript({ $_ -in (Get-AzContext).Tenant.Id })] [guid] $TenantId, [string] $ApiVersion = '2022-12-01' ) process { Write-AzOpsMessage -LogLevel Important -LogString 'Get-AzOpsSubscription.Excluded.States' -LogStringValues ($ExcludedStates -join ',') Write-AzOpsMessage -LogLevel Important -LogString 'Get-AzOpsSubscription.Excluded.Offers' -LogStringValues ($ExcludedOffers -join ',') $nextLink = "/subscriptions?api-version=$ApiVersion" $allSubscriptionsResults = do { $allSubscriptionsJson = ((Invoke-AzRestMethod -Path $nextLink -Method GET).Content | ConvertFrom-Json -Depth 100) $allSubscriptionsJson.value | Where-Object tenantId -eq $TenantId $nextLink = $allSubscriptionsJson.nextLink -replace 'https://management\.azure\.com' } while ($nextLink) $includedSubscriptions = $allSubscriptionsResults | Where-Object { $_.state -notin $ExcludedStates -and $_.subscriptionPolicies.quotaId -notin $ExcludedOffers } if (-not $includedSubscriptions) { Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsSubscription.NoSubscriptions' return } Write-AzOpsMessage -LogLevel Important -LogString 'Get-AzOpsSubscription.Subscriptions.Found' -LogStringValues $allSubscriptionsResults.Count if ($allSubscriptionsResults.Count -gt $includedSubscriptions.Count) { Write-AzOpsMessage -LogLevel Important -LogString 'Get-AzOpsSubscription.Subscriptions.Excluded' -LogStringValues ($allSubscriptionsResults.Count - $includedSubscriptions.Count) } if ($includedSubscriptions | Where-Object State -EQ PastDue) { Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsSubscription.Subscriptions.PastDue' -LogStringValues ($includedSubscriptions | Where-Object State -EQ PastDue).Count } Write-AzOpsMessage -LogLevel Important -LogString 'Get-AzOpsSubscription.Subscriptions.Included' -LogStringValues $includedSubscriptions.Count -Metric $includedSubscriptions.Count -MetricName 'Subscription Count' $includedSubscriptions } } function Get-AzOpsTemplateFile { <# .SYNOPSIS Takes file name input and returns first match, looks at Core.SkipCustomJqTemplate, Core.CustomJqTemplatePath followed by Core.JqTemplatePath. .DESCRIPTION Takes file name input and returns first match, looks at Core.SkipCustomJqTemplate, Core.CustomJqTemplatePath followed by Core.JqTemplatePath. .PARAMETER File Filename of template file to look for. .PARAMETER Fallback Fallback filename to look for if parameter:file is not found. .EXAMPLE > Get-AzOpsTemplateFile -File "templateChildResource.jq" Returns the following: /workspaces/AzOps/src/data/template/templateChildResource.jq #> [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string] $File, [string] $Fallback, [string] $JqTemplatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.JqTemplatePath'), [string] $CustomJqTemplatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.CustomJqTemplatePath'), [bool] $SkipCustomJqTemplate = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipCustomJqTemplate') ) process { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Get-AzOpsTemplateFile.Processing' -LogStringValues $File # Evaluate JqTemplate Conditions if ($SkipCustomJqTemplate) { # Use default module templates only Write-AzOpsMessage -LogLevel InternalComment -LogString 'Get-AzOpsTemplateFile.Processing.Path' -LogStringValues $File, $JqTemplatePath if ($Fallback) { # Process with Fallback Write-AzOpsMessage -LogLevel InternalComment -LogString 'Get-AzOpsTemplateFile.Processing.Fallback' -LogStringValues $File, $Fallback $return = (Test-Path -Path (Join-Path $JqTemplatePath -ChildPath $File) -PathType Leaf) ? (Get-Item -Path (Join-Path $JqTemplatePath -ChildPath $File) -ErrorAction SilentlyContinue): (Get-Item -Path (Join-Path $JqTemplatePath -ChildPath $Fallback) -ErrorAction SilentlyContinue) } else { # Process without Fallback if (Test-Path -Path (Join-Path $JqTemplatePath -ChildPath $File) -PathType Leaf) { $return = (Get-Item -Path (Join-Path $JqTemplatePath -ChildPath $File) -ErrorAction SilentlyContinue) } } } else { # Use custom templates Write-AzOpsMessage -LogLevel InternalComment -LogString 'Get-AzOpsTemplateFile.Processing.Path' -LogStringValues $File, $CustomJqTemplatePath if ($Fallback) { # Process with Fallback Write-AzOpsMessage -LogLevel InternalComment -LogString 'Get-AzOpsTemplateFile.Processing.Fallback' -LogStringValues $File, $Fallback $return = (Test-Path -Path (Join-Path $CustomJqTemplatePath -ChildPath $File) -PathType Leaf) ? (Get-Item -Path (Join-Path $CustomJqTemplatePath -ChildPath $File) -ErrorAction SilentlyContinue): (Get-Item -Path (Join-Path $CustomJqTemplatePath -ChildPath $Fallback) -ErrorAction SilentlyContinue) if (-not $return) { # Use default templates since no custom templates was found Write-AzOpsMessage -LogLevel InternalComment -LogString 'Get-AzOpsTemplateFile.Processing.Path' -LogStringValues $File, $JqTemplatePath $return = (Test-Path -Path (Join-Path $JqTemplatePath -ChildPath $File) -PathType Leaf) ? (Get-Item -Path (Join-Path $JqTemplatePath -ChildPath $File) -ErrorAction SilentlyContinue): (Get-Item -Path (Join-Path $JqTemplatePath -ChildPath $Fallback) -ErrorAction SilentlyContinue) } } else { # Process without Fallback if (Test-Path -Path (Join-Path $CustomJqTemplatePath -ChildPath $File) -PathType Leaf) { $return = (Get-Item -Path (Join-Path $CustomJqTemplatePath -ChildPath $File) -ErrorAction SilentlyContinue) } if (-not $return) { # Use default templates since no custom templates was found Write-AzOpsMessage -LogLevel InternalComment -LogString 'Get-AzOpsTemplateFile.Processing.Path' -LogStringValues $File, $JqTemplatePath if (Test-Path -Path (Join-Path $JqTemplatePath -ChildPath $File) -PathType Leaf) { $return = (Get-Item -Path (Join-Path $JqTemplatePath -ChildPath $File) -ErrorAction SilentlyContinue) } } } } if ($return) { # Template file found $return = ($return | Select-Object -First 1).VersionInfo.FileName Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsTemplateFile.Processing.Found' -LogStringValues $return return $return } else { # No template file found, throw Write-AzOpsMessage -LogLevel Error -LogString 'Get-AzOpsTemplateFile.Processing.NotFound' -LogStringValues $File throw } } } function Invoke-AzOpsNativeCommand { <# .SYNOPSIS Executes a native command. .DESCRIPTION Executes a native command. .PARAMETER ScriptBlock The scriptblock containing the native command to execute. Note: Specifying a scriptblock WITHOUT any native command may cause erroneous LASTEXITCODE detection. .PARAMETER IgnoreExitcode Whether to ignore exitcodes. .PARAMETER Quiet Quiet mode disables printing error output of a native command. .EXAMPLE > Invoke-AzOpsNativeCommand -Scriptblock { git config --system -l } Executes "git config --system -l" #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [scriptblock] $ScriptBlock, [switch] $IgnoreExitcode, [switch] $Quiet ) try { if ($Quiet) { $output = & $ScriptBlock 2>&1 } else { $output = & $ScriptBlock } if (-not $Quiet -and $output) { $output | Out-String -NoNewLine | ForEach-Object { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsNativeCommand' -LogStringValues $ScriptBlock, $_ } $output } } catch { if (-not $IgnoreExitcode) { $caller = Get-PSCallStack -ErrorAction SilentlyContinue if ($caller) { Stop-PSFFunction -String 'Invoke-AzOpsNativeCommand.Failed.WithCallstack' -StringValues $ScriptBlock, $caller[1].ScriptName, $caller[1].ScriptLineNumber, $LASTEXITCODE -Cmdlet $PSCmdlet -EnableException $true } Stop-PSFFunction -String 'Invoke-AzOpsNativeCommand.Failed.NoCallstack' -StringValues $ScriptBlock, $LASTEXITCODE -Cmdlet $PSCmdlet -EnableException $true } $output } } function Invoke-AzOpsRestMethod { <# .SYNOPSIS Process Path with given Method and manage paging of results and returns value's .PARAMETER Path Path .PARAMETER Method Method .EXAMPLE > Invoke-AzOpsRestMethod -Path "/subscriptions/{subscription}/resourcegroups/{resourcegroup}/providers/microsoft.operationalinsights/workspaces/{workspace}?api-version={API}" -Method GET #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Object] $Path, [Parameter(Mandatory = $true, ValueFromPipeline = $true)] $Method ) process { # Process Path with given Method Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsRestMethod.Processing' -LogStringValues $Path $allresults = do { try { $results = ((Invoke-AzRestMethod -Path $Path -Method $Method -ErrorAction Stop).Content | ConvertFrom-Json -Depth 100) $results.value $path = $results.nextLink -replace 'https://management\.azure\.com' if ($results.StatusCode -eq '429' -or $results.StatusCode -like '5*') { $results.Headers.GetEnumerator() | ForEach-Object { if ($_.key -eq 'Retry-After') { Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsRestMethod.Processing.RateLimit' -LogStringValues $Path, $_.value Start-Sleep -Seconds $_.value } } } } catch { Write-AzOpsMessage -LogLevel Error -LogString 'Invoke-AzOpsRestMethod.Processing.Error' -LogStringValues $_, $Path } } while ($path) if ($allresults) { return $allresults } } } function Invoke-AzOpsScriptBlock { <# .SYNOPSIS Execute a scriptblock, retry if it fails. .DESCRIPTION Execute a scriptblock, retry if it fails. .PARAMETER ScriptBlock The scriptblock to execute. .PARAMETER ArgumentList Any arguments to pass to the scriptblock. .PARAMETER RetryCount How often to try again before giving up. Default: 0 .PARAMETER RetryWait How long to wait between retries in seconds. Default: 3 .PARAMETER RetryType How to wait for a retry? Either always the exact time specified in RetryWait as seconds, or exponentially increase the time between waits. Assuming a wait time of 2 seconds and three retries, this will result in the following waits between attempts: Linear (default): 2, 2, 2 Exponential: 2, 4, 8 .EXAMPLE > Invoke-AzOpsScriptBlock -ScriptBlock { 1 / 0 } Will attempt once to divide by zero. Hint: This is unlikely to succeede. Ever. .EXAMPLE > Invoke-AzOpsScriptBlock -ScriptBlock { 1 / 0 } -RetryCount 3 Will attempt to divide by zero, retrying up to 3 additional times (for a total of 4 attempts). Hint: Trying to divide by zero more than once does not increase your chance of success. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [ScriptBlock] $ScriptBlock, [object[]] $ArgumentList, [int] $RetryCount = 0, [int] $RetryWait = 3, [ValidateSet('Linear','Exponential')] [string] $RetryType = 'Linear' ) begin { $count = 0 } process { $data = @{ ScriptBlock = $ScriptBlock ArgumentList = $ArgumentList } while ($count -le $RetryCount) { $count++ try { if (Test-PSFParameterBinding -ParameterName ArgumentList) { & $ScriptBlock $ArgumentList } else { & $ScriptBlock } break } catch { if ($count -lt $RetryCount) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsScriptBlock.Failed.WillRetry' -LogStringValues $ScriptBlock, $count, $RetryCount -ErrorRecord $_ -Data $data switch ($RetryType) { Linear { Start-Sleep -Seconds $RetryWait } Exponential { Start-Sleep -Seconds ([math]::Pow($RetryWait, $count)) } } continue } Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsScriptBlock.Failed.GivingUp' -LogStringValues $ScriptBlock, $count, $RetryCount -ErrorRecord $_ -Data $data throw } } } } function New-AzOpsDeployment { <# .SYNOPSIS Deploys a full state into azure. .DESCRIPTION Deploys a full state into azure. .PARAMETER DeploymentName Name under which to deploy the state. .PARAMETER TemplateFilePath Path where the ARM templates can be found. .PARAMETER TemplateObject TemplateObject where the templates content is stored in-memory. .PARAMETER TemplateParameterFilePath Path where the parameters of the ARM templates can be found. .PARAMETER Mode Mode in which to process the templates. Defaults to incremental. .PARAMETER StatePath The root folder under which to find the resource json. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatifExcludedChangeTypes Exclude specific change types from WhatIf operations. .PARAMETER WhatIfResultFormat Accepts ResourceIdOnly or FullResourcePayloads. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE > $AzOpsDeploymentList | Select-Object $uniqueProperties -Unique | Sort-Object -Property TemplateParameterFilePath | New-Deployment Deploy all unique deployments provided from $AzOpsDeploymentList Name Value ---- ----- filePath /root/managementgroup/subscription/resourcegroup/template.json parameterFilePath /root/managementgroup/subscription/resourcegroup/template.parameters.json results Microsoft.Azure.Commands.ResourceManager.Cmdlets.SdkModels.Deployments.PSWhatIfOperationResult #> [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $DeploymentName = "azops-template-deployment", [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $TemplateFilePath = (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate'), [Parameter(ValueFromPipelineByPropertyName = $true)] [hashtable] $TemplateObject, [Parameter(ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [AllowNull()] [string] $TemplateParameterFilePath, [string] $Mode = "Incremental", [string] $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State'), [string[]] $WhatifExcludedChangeTypes = (Get-PSFConfigValue -FullName 'AzOps.Core.WhatifExcludedChangeTypes'), [string] [ValidateSet("ResourceIdOnly","FullResourcePayloads")] $WhatIfResultFormat ) process { Write-AzOpsMessage -LogLevel Important -LogString 'New-AzOpsDeployment.Processing' -LogStringValues $DeploymentName, $TemplateFilePath, $TemplateParameterFilePath, $Mode -Target $TemplateFilePath #region Resolve Scope try { if ($TemplateParameterFilePath) { $scopeObject = New-AzOpsScope -Path $TemplateParameterFilePath -StatePath $StatePath -ErrorAction Stop -WhatIf:$false } else { $scopeObject = New-AzOpsScope -Path $TemplateFilePath -StatePath $StatePath -ErrorAction Stop -WhatIf:$false } $scopeFound = $true } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.Scope.Failed' -LogStringValues $TemplateFilePath, $TemplateParameterFilePath -ErrorRecord $_ return } if (-not $scopeObject) { Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.Scope.Empty' -LogStringValues $TemplateFilePath, $TemplateParameterFilePath return } #endregion Resolve Scope #region Parse Content if ($null -eq $TemplateObject) { $TemplateFileContent = [System.IO.File]::ReadAllText($TemplateFilePath) $TemplateObject = ConvertFrom-Json $TemplateFileContent -AsHashtable } if ($TemplateObject.metadata._generator.name -eq 'bicep') { # Detect bicep templates $bicepTemplate = $true } #endregion #region Process Scope # Configure variables/parameters and the WhatIf/Deployment cmdlets to be used per scope $defaultDeploymentRegion = (Get-PSFConfigValue -FullName 'AzOps.Core.DefaultDeploymentRegion') $parameters = @{ 'TemplateObject' = $TemplateObject 'SkipTemplateParameterPrompt' = $true 'Location' = $defaultDeploymentRegion } if ($WhatIfResultFormat) { $parameters.ResultFormat = $WhatIfResultFormat } # Resource Groups excluding Microsoft.Resources/resourceGroups that needs to be submitted at subscription scope if ($scopeObject.resourcegroup -and $TemplateObject.resources[0].type -ne 'Microsoft.Resources/resourceGroups') { Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.ResourceGroup.Processing' -LogStringValues $scopeObject -Target $scopeObject Set-AzOpsContext -ScopeObject $scopeObject $whatIfCommand = 'Get-AzResourceGroupDeploymentWhatIfResult' $deploymentCommand = 'New-AzResourceGroupDeployment' $parameters.ResourceGroupName = $scopeObject.resourcegroup $parameters.Remove('Location') } # Subscriptions elseif ($scopeObject.subscription) { Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.Subscription.Processing' -LogStringValues $defaultDeploymentRegion, $scopeObject -Target $scopeObject Set-AzOpsContext -ScopeObject $scopeObject $whatIfCommand = 'Get-AzSubscriptionDeploymentWhatIfResult' $deploymentCommand = 'New-AzDeployment' } # Management Groups elseif ($scopeObject.managementGroup -and (-not ($scopeObject.StatePath).StartsWith('azopsscope-assume-new-resource_'))) { Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.ManagementGroup.Processing' -LogStringValues $defaultDeploymentRegion, $scopeObject -Target $scopeObject $parameters.ManagementGroupId = $scopeObject.managementgroup $whatIfCommand = 'Get-AzManagementGroupDeploymentWhatIfResult' $deploymentCommand = 'New-AzManagementGroupDeployment' } # Tenant deployments elseif ($scopeObject.type -eq 'root' -and $scopeObject.scope -eq '/') { Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.Root.Processing' -LogStringValues $defaultDeploymentRegion, $scopeObject -Target $scopeObject $whatIfCommand = 'Get-AzTenantDeploymentWhatIfResult' $deploymentCommand = 'New-AzTenantDeployment' } # If Management Group resource was not found, validate and prepare for first time deployment of resource elseif ($scopeObject.managementGroup -and (($scopeObject.StatePath).StartsWith('azopsscope-assume-new-resource_'))) { $resourceScopeFileContent = Get-Content -Path $addition | ConvertFrom-Json -Depth 100 $resource = ($resourceScopeFileContent.resources | Where-Object {$_.type -eq 'Microsoft.Management/managementGroups'} | Select-Object -First 1) $pathDir = (Get-Item -Path $addition).Directory | Resolve-Path -Relative if ((Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath') -ne '.') { $pathDir = Split-Path -Path $pathDir -Parent } $parentDirScopeObject = New-AzOpsScope -Path (Split-Path -Path $pathDir -Parent) -WhatIf:$false | Where-Object {(-not ($_.StatePath).StartsWith('azopsscope-assume-new-resource_'))} $parentIdScope = New-AzOpsScope -Scope (($resource).properties.details.parent.id) -WhatIf:$false | Where-Object {(-not ($_.StatePath).StartsWith('azopsscope-assume-new-resource_'))} # Validate parent existence with content parent scope, statepath and name match, determines file location match deployment scope if ($parentDirScopeObject -and $parentIdScope -and $parentDirScopeObject.Scope -eq $parentIdScope.Scope -and $parentDirScopeObject.StatePath -eq $parentIdScope.StatePath -and $parentDirScopeObject.Name -eq $parentIdScope.Name) { # Validate directory name match resource information if ((Get-Item -Path $pathDir).Name -eq "$($resource.properties.displayName) ($($resource.name))") { Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.Root.Processing' -LogStringValues $defaultDeploymentRegion, $scopeObject -Target $scopeObject $whatIfCommand = 'Get-AzTenantDeploymentWhatIfResult' $deploymentCommand = 'New-AzTenantDeployment' } # Invalid directory name else { Write-AzOpsMessage -LogLevel Error -LogString 'New-AzOpsDeployment.Directory.NotFound' -LogStringValues (Get-Item -Path $pathDir).Name, "$($resource.properties.displayName) ($($resource.name))" throw } } # Parent missing else { Write-AzOpsMessage -LogLevel Error -LogString 'New-AzOpsDeployment.Parent.NotFound' -LogStringValues $addition throw } } else { Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.Scope.Unidentified' -LogStringValues $scopeObject $scopeFound = $false } # Proceed with WhatIf or Deployment if scope was found if ($scopeFound) { $deploymentResult = [PSCustomObject]@{ filePath = '' parameterFilePath = '' results = '' deployment = '' } if ($TemplateParameterFilePath) { $parameters.TemplateParameterFile = $TemplateParameterFilePath } if ($WhatifExcludedChangeTypes) { $parameters.ExcludeChangeType = $WhatifExcludedChangeTypes } # Get predictive deployment results from WhatIf API $results = & $whatIfCommand @parameters -ErrorAction Continue -ErrorVariable resultsError if ($resultsError) { $resultsErrorMessage = $resultsError.exception.InnerException.Message # Ignore errors for bicep modules if ($resultsErrorMessage -match 'https://aka.ms/resource-manager-parameter-files' -and $true -eq $bicepTemplate) { Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.TemplateParameterError' -Target $scopeObject $invalidTemplate = $true } # Handle WhatIf prediction errors elseif ($resultsErrorMessage -match 'DeploymentWhatIfResourceError' -and $resultsErrorMessage -match "The request to predict template deployment") { Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.WhatIfWarning' -LogStringValues $resultsErrorMessage -Target $scopeObject if ($parameters.TemplateParameterFile) { $deploymentResult.filePath = $parameters.TemplateFile $deploymentResult.parameterFilePath = $parameters.TemplateParameterFile $deploymentResult.results = ('{0}WhatIf prediction failed with error - validate changes manually before merging:{0}{1}' -f [environment]::NewLine, $resultsErrorMessage) } else { $deploymentResult.filePath = $parameters.TemplateFile $deploymentResult.results = ('{0}WhatIf prediction failed with error - validate changes manually before merging:{0}{1}' -f [environment]::NewLine, $resultsErrorMessage) } } else { Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.WhatIfWarning' -LogStringValues $resultsErrorMessage -Target $scopeObject throw $resultsErrorMessage } } elseif ($results.Error) { Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.TemplateError' -LogStringValues $TemplateFilePath -Target $scopeObject return } else { Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.WhatIfResults' -LogStringValues ($results | Out-String) -Target $scopeObject Write-AzOpsMessage -LogLevel InternalComment -LogString 'New-AzOpsDeployment.WhatIfFile' -Target $scopeObject if ($parameters.TemplateParameterFile) { $deploymentResult.filePath = $TemplateFilePath $deploymentResult.parameterFilePath = $parameters.TemplateParameterFile $deploymentResult.results = $results } else { $deploymentResult.filePath = $TemplateFilePath $deploymentResult.results = $results } } # Remove ExcludeChangeType parameter as it doesn't exist for deployment cmdlets if ($parameters.ExcludeChangeType) { $parameters.Remove('ExcludeChangeType') } $parameters.Name = $DeploymentName if ($PSCmdlet.ShouldProcess("Start $($scopeObject.type) Deployment with $deploymentCommand?")) { if (-not $invalidTemplate) { $deploymentResult.deployment = & $deploymentCommand @parameters } } else { # Exit deployment Write-AzOpsMessage -LogLevel InternalComment -LogString 'New-AzOpsDeployment.SkipDueToWhatIf' } } #Return if ($deploymentResult) { return $deploymentResult } } } function New-AzOpsScope { <# .SYNOPSIS Returns an AzOpsScope for a path or for a scope .DESCRIPTION Returns an AzOpsScope for a path or for a scope .PARAMETER Scope The scope for which to return a scope object. .PARAMETER Path The path from which to build a scope. .PARAMETER StatePath The root path to where the entire state is being built in. .PARAMETER ChildResource The ChildResource contains details of the child resource. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE > New-AzOpsScope -Scope "/providers/Microsoft.Management/managementGroups/3fc1081d-6105-4e19-b60c-1ec1252cf560" Return AzOpsScope for a root Management Group scope scope in Azure: scope : /providers/Microsoft.Management/managementGroups/3fc1081d-6105-4e19-b60c-1ec1252cf560 type : managementGroups name : 3fc1081d-6105-4e19-b60c-1ec1252cf560 statepath : C:\git\cet-northstar\azops\3fc1081d-6105-4e19-b60c-1ec1252cf560\.AzState\Microsoft.Management_managementGroups-3fc1081d-6105-4e19-b60c-1ec1252cf560.parameters.json managementgroup : 3fc1081d-6105-4e19-b60c-1ec1252cf560 managementgroupDisplayName : 3fc1081d-6105-4e19-b60c-1ec1252cf560 subscription : subscriptionDisplayName : resourcegroup : resourceprovider : resource : .EXAMPLE > New-AzOpsScope -path "C:\Users\jodahlbo\git\CET-NorthStar\azops\Tenant Root Group\Non-Production Subscriptions\Dalle MSDN MVP\365lab-dcs" Return AzOpsScope for a filepath .INPUTS Scope .INPUTS Path .OUTPUTS [AzOpsScope] #> [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(ParameterSetName = "scope")] [string] [ValidateScript( { $null -ne $script:AzOpsAzManagementGroup -or $script:AzOpsSubscription })] $Scope, [Parameter(ParameterSetName = "pathfile", ValueFromPipeline = $true)] [string] $Path, [hashtable] $ChildResource, [string] $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State') ) process { Write-AzOpsMessage -LogLevel InternalComment -LogString 'New-AzOpsScope.Starting' switch ($PSCmdlet.ParameterSetName) { scope { if (($ChildResource) -and (-not(Get-PSFConfigValue -FullName AzOps.Core.SkipChildResource))) { Invoke-PSFProtectedCommand -ActionString 'New-AzOpsScope.Creating.FromParentScope' -ActionStringValues $Scope -Target $Scope -ScriptBlock { [AzOpsScope]::new($Scope, $ChildResource, $StatePath) } -EnableException $true -PSCmdlet $PSCmdlet } else { Invoke-PSFProtectedCommand -ActionString 'New-AzOpsScope.Creating.FromScope' -ActionStringValues $Scope -Target $Scope -ScriptBlock { [AzOpsScope]::new($Scope, $StatePath) } -EnableException $true -PSCmdlet $PSCmdlet } } pathfile { if (-not (Test-Path $Path)) { Stop-PSFFunction -String 'New-AzOpsScope.Path.NotFound' -StringValues $Path -EnableException $true -Cmdlet $PSCmdlet } $Path = Resolve-PSFPath -Path $Path -SingleItem -Provider FileSystem $StatePathValidator = Resolve-PSFPath -Path $StatePath -SingleItem -Provider FileSystem if (-not $Path.StartsWith($StatePathValidator)) { Stop-PSFFunction -String 'New-AzOpsScope.Path.InvalidRoot' -StringValues $Path, $StatePath -EnableException $true -Cmdlet $PSCmdlet } Invoke-PSFProtectedCommand -ActionString 'New-AzOpsScope.Creating.FromFile' -ActionStringValues $Path -Target $Path -ScriptBlock { [AzOpsScope]::new($(Get-Item -Path $Path -Force), $StatePath) } -EnableException $true -PSCmdlet $PSCmdlet } } } } function New-AzOpsStateDeployment { <# .SYNOPSIS Deploys a set of ARM templates into Azure. .DESCRIPTION Deploys a set of ARM templates into Azure. Define the state using Invoke-AzOpsPull and maintain it via: - Invoke-AzOpsGitPull - Invoke-AzOpsGitPush .PARAMETER FileName Root path from which to deploy. .PARAMETER StatePath The overall path of the state to deploy. .EXAMPLE > New-StateDeployment -FileName $fileName -StatePath $StatePath Deploys the specified set of ARM templates into Azure. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [ValidateScript({ Test-Path $_ })] $FileName, [string] $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State') ) begin { $subscriptions = Get-AzSubscription $enrollmentAccounts = Get-AzEnrollmentAccount } process { Write-AzOpsMessage -LogLevel Important -LogString 'New-AzOpsStateDeployment.Processing' -LogStringValues $FileName $scopeObject = New-AzOpsScope -Path (Get-Item -Path $FileName).FullName -StatePath $StatePath if (-not $scopeObject.Type) { Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsStateDeployment.InvalidScope' -LogStringValues $FileName -Target $scopeObject return } #TODO: Clarify whether this exclusion was intentional if ($scopeObject.Type -ne 'subscriptions') { return } #region Process Subscriptions if ($FileName -match '/*.subscription.json$') { Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsStateDeployment.Subscription' -LogStringValues $FileName -Target $scopeObject $subscription = $subscriptions | Where-Object Name -EQ $scopeObject.subscriptionDisplayName #region Subscription needs to be created if (-not $subscription) { Write-AzOpsMessage -LogLevel Important -LogString 'New-AzOpsStateDeployment.Subscription.New' -LogStringValues $FileName -Target $scopeObject if (-not $enrollmentAccounts) { Write-AzOpsMessage -LogLevel Error -LogString 'New-AzOpsStateDeployment.NoEnrollmentAccount' -Target $scopeObject Write-AzOpsMessage -LogLevel Error -LogString 'New-AzOpsStateDeployment.NoEnrollmentAccount.Solution' -Target $scopeObject return } if ($cfgEnrollmentAccount = Get-PSFConfigValue -FullName 'AzOps.Core.EnrollmentAccountPrincipalName') { Write-AzOpsMessage -LogLevel Important -LogString 'New-AzOpsStateDeployment.EnrollmentAccount.Selected' -LogStringValues $cfgEnrollmentAccount $enrollmentAccountObjectId = ($enrollmentAccounts | Where-Object PrincipalName -eq $cfgEnrollmentAccount).ObjectId } else { Write-AzOpsMessage -LogLevel Important -LogString 'New-AzOpsStateDeployment.EnrollmentAccount.First' -LogStringValues @($enrollmentAccounts)[0].PrincipalName $enrollmentAccountObjectId = @($enrollmentAccounts)[0].ObjectId } Invoke-PSFProtectedCommand -ActionString 'New-AzOpsStateDeployment.Subscription.Creating' -ActionStringValues $scopeObject.Name -ScriptBlock { $subscription = New-AzSubscription -Name $scopeObject.Name -OfferType (Get-PSFConfigValue -FullName 'AzOps.Core.OfferType') -EnrollmentAccountObjectId $enrollmentAccountObjectId -ErrorAction Stop $subscriptions = @($subscriptions) + @($subscription) } -Target $scopeObject -EnableException $true -PSCmdlet $PSCmdlet Invoke-PSFProtectedCommand -ActionString 'New-AzOpsStateDeployment.Subscription.AssignManagementGroup' -ActionStringValues $subscription.Name, $scopeObject.ManagementGroupDisplayName -ScriptBlock { New-AzManagementGroupSubscription -GroupName $scopeObject.ManagementGroup -SubscriptionId $subscription.SubscriptionId -ErrorAction Stop } -Target $scopeObject -EnableException $true -PSCmdlet $PSCmdlet } #endregion Subscription needs to be created #region Subscription exists already else { Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsStateDeployment.Subscription.Exists' -LogStringValues $subscription.Name, $subscription.Id -Target $scopeObject Invoke-PSFProtectedCommand -ActionString 'New-AzOpsStateDeployment.Subscription.AssignManagementGroup' -ActionStringValues $subscription.Name, $scopeObject.ManagementGroupDisplayName -ScriptBlock { New-AzManagementGroupSubscription -GroupName $scopeObject.ManagementGroup -SubscriptionId $subscription.SubscriptionId -ErrorAction Stop } -Target $scopeObject -EnableException $true -PSCmdlet $PSCmdlet } #endregion Subscription exists already } if ($FileName -match '/*.providerfeatures.json$') { Register-AzOpsProviderFeature -FileName $FileName -ScopeObject $scopeObject } if ($FileName -match '/*.resourceproviders.json$') { Register-AzOpsResourceProvider -FileName $FileName -ScopeObject $scopeObject } #endregion Process Subscriptions } } function Register-AzOpsProviderFeature { <# .SYNOPSIS Registers a provider feature from ARM. .DESCRIPTION Registers a provider feature from ARM. .PARAMETER FileName Path to the ARM template file representing a provider feature. .PARAMETER ScopeObject The current AzOps scope. .EXAMPLE PS C:\> Register-ProviderFeature -FileName $file -ScopeObject $scopeObject Registers a provider feature from ARM. #> [CmdletBinding()] param ( [string] $FileName, [AzOpsScope] $ScopeObject ) process { #TODO: Clarify original function design intent # Get Subscription ID from scope (since Subscription ID is not available for Resource Groups and Resources) Write-AzOpsMessage -LogLevel Verbose -LogString 'Register-AzOpsProviderFeature.Processing' -LogStringValues $ScopeObject, $FileName -Target $scopeObject $currentContext = Get-AzContext if ($ScopeObject.Subscription -and $currentContext.Subscription.Id -ne $ScopeObject.Subscription) { Write-AzOpsMessage -LogLevel Verbose -LogString 'Register-AzOpsProviderFeature.Context.Switching' -LogStringValues $currentContext.Subscription.Name, $CurrentAzContext.Subscription.Id, $ScopeObject.Subscription, $ScopeObject.Name -Target $scopeObject try { $null = Set-AzContext -SubscriptionId $ScopeObject.Subscription -ErrorAction Stop } catch { Stop-PSFFunction -String 'Register-AzOpsProviderFeature.Context.Failed' -StringValues $ScopeObject.SubscriptionDisplayName -ErrorRecord $_ -EnableException $true -Cmdlet $PSCmdlet -Target $ScopeObject throw "Couldn't switch context $_" } } $providerFeatures = Get-Content $FileName | ConvertFrom-Json foreach ($providerFeature in $providerFeatures) { if ($ProviderFeature.FeatureName -and $ProviderFeature.ProviderName) { Write-AzOpsMessage -LogLevel Verbose -LogString 'Register-AzOpsProviderFeature.Provider.Feature' -LogStringValues $ProviderFeature.FeatureName, $ProviderFeature.ProviderName -Target $scopeObject Register-AzProviderFeature -Confirm:$false -ProviderNamespace $ProviderFeature.ProviderName -FeatureName $ProviderFeature.FeatureName } } } } function Register-AzOpsResourceProvider { <# .SYNOPSIS Registers an azure resource provider. .DESCRIPTION Registers an azure resource provider. Assumes an ARM definition of a resource provider as input. .PARAMETER FileName The path to the file containing an ARM template defining a resource provider. .PARAMETER ScopeObject The current AzOps scope. .EXAMPLE PS C:\> Register-ResourceProvider -FileName $fileName -ScopeObject $scopeObject Registers an azure resource provider. #> [CmdletBinding()] param ( [string] $FileName, [AzOpsScope] $ScopeObject ) process { Write-AzOpsMessage -LogLevel Verbose -LogString 'Register-AzOpsResourceProvider.Processing' -LogStringValues $ScopeObject, $FileName -Target $ScopeObject $currentContext = Get-AzContext if ($ScopeObject.Subscription -and $currentContext.Subscription.Id -ne $ScopeObject.Subscription) { Write-AzOpsMessage -LogLevel Verbose -LogString 'Register-AzOpsResourceProvider.Context.Switching' -LogStringValues $currentContext.Subscription.Name, $CurrentAzContext.Subscription.Id, $ScopeObject.Subscription, $ScopeObject.Name -Target $ScopeObject try { $null = Set-AzContext -SubscriptionId $ScopeObject.Subscription -ErrorAction Stop } catch { Stop-PSFFunction -String 'Register-AzOpsResourceProvider.Context.Failed' -StringValues $ScopeObject.SubscriptionDisplayName -ErrorRecord $_ -EnableException $true -Cmdlet $PSCmdlet -Target $ScopeObject throw "Couldn't switch context $_" } } $resourceproviders = Get-Content $FileName | ConvertFrom-Json foreach ($resourceprovider in $resourceproviders | Where-Object RegistrationState -eq 'Registered') { if (-not $resourceprovider.ProviderNamespace) { continue } Write-AzOpsMessage -LogLevel Important -LogString 'Register-AzOpsResourceProvider.Provider.Register' -LogStringValues $resourceprovider.ProviderNamespace Register-AzResourceProvider -Confirm:$false -Pre -ProviderNamespace $resourceprovider.ProviderNamespace } } } function Remove-AzOpsDeployment { <# .SYNOPSIS Deletion of supported resource types AzOps.Core.DeletionSupportedResourceType and custom templates. .DESCRIPTION Deletion of supported resource types AzOps.Core.DeletionSupportedResourceType and custom templates. .PARAMETER CustomTemplateResourceDeletion Enable or disable, deletion of resources in custom templates. .PARAMETER DeploymentName Dummy name used to run Azure WhatIf deployment. .PARAMETER TemplateFilePath Path where the ARM templates can be found. .PARAMETER TemplateParameterFilePath Path where the ARM parameters templates can be found. .PARAMETER StatePath The root folder under which to find the resource json. .PARAMETER DeletionSupportedResourceType Supported resource types for deletion of AzOps generated file. .PARAMETER DeleteSet String of file names to validate deletion. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE > $AzOpsRemovalList | Select-Object $uniqueProperties -Unique | Remove-AzOpsDeployment Remove all unique deployments provided from $AzOpsRemovalList #> [CmdletBinding(SupportsShouldProcess = $true)] param ( [bool] $CustomTemplateResourceDeletion = (Get-PSFConfigValue -FullName 'AzOps.Core.CustomTemplateResourceDeletion'), [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $DeploymentName = "azops-template-deployment", [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $TemplateFilePath = (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate'), [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $TemplateParameterFilePath, [string] $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State'), [object[]] $DeletionSupportedResourceType = (Get-PSFConfigValue -FullName 'AzOps.Core.DeletionSupportedResourceType'), [string[]] $DeleteSet ) process { function Get-AzLocksDeletionDependency { param ( $resourceToDelete ) $dependency = @() if ($resourceToDelete.Type -in $DeletionSupportedResourceType) { $subPattern = '^/subscriptions/([0-9a-fA-F-]{36})' $rgPattern = '^/subscriptions/([0-9a-fA-F-]{36})/resourceGroups/([^/]+)' if ($resourceToDelete.Id -match $subPattern) { $deletionScope = $matches[0] $depLock = Get-AzResourceLock -Scope $deletionScope if ($depLock) { foreach ($lock in $depLock) { #Filter through each return and validate if resource has rg and is not at child resource scope if ($lock.ResourceId -match $rgPattern) { if ($resourceToDelete.Id.StartsWith($matches[0]) -and $lock.ResourceId -notlike '*/resourcegroups/*/providers/*/providers/*') { $dependency += [PSCustomObject]@{ Type = 'locks' Id = $lock.ResourceId } } } elseif ($lock.ResourceId -notlike '*/resourcegroups/*') { $dependency += [PSCustomObject]@{ Type = 'locks' Id = $lock.ResourceId } } } if ($dependency) { $dependency = $dependency | Sort-Object Id -Unique | Where-Object {$_.Id -ne $resourceToDelete.Id} return $dependency } } } } } function Get-AzPolicyAssignmentDeletionDependency { param ( $resourceToDelete ) $dependency = @() if ($resourceToDelete.Type -in $DeletionSupportedResourceType) { switch ($resourceToDelete.Type) { 'Microsoft.Authorization/policyAssignments' { $depPolicyAssignment = $resourceToDelete } 'Microsoft.Authorization/policyDefinitions' { $depPolicyAssignment = Get-AzPolicyAssignment -PolicyDefinitionId $resourceToDelete.Id -ErrorAction SilentlyContinue } 'Microsoft.Authorization/policySetDefinitions' { $query = "PolicyResources | where type == 'microsoft.authorization/policyassignments' and properties.policyDefinitionId == '$($resourceToDelete.Id)' | order by id asc" $depPolicyAssignment = Search-AzGraphDeletionDependency -query $query if ($depPolicyAssignment) { #Loop through each return from graph cache and validate resource is still present in Azure $depPolicyAssignment = foreach ($policyAssignment in $depPolicyAssignment) {Get-AzPolicyAssignment -Id $policyAssignment.Id -ErrorAction SilentlyContinue} } } } } if ($depPolicyAssignment) { foreach ($policyAssignment in $depPolicyAssignment) { $dependency += [PSCustomObject]@{ Type = $policyAssignment.Type Id = $policyAssignment.Id } if ($policyAssignment.IdentityType -eq 'SystemAssigned') { $depSystemAssignedRoleAssignment = $null $depSystemAssignedRoleAssignment = Get-AzRoleAssignment -ObjectId $policyAssignment.IdentityPrincipalId -Scope $policyAssignment.Scope if ($depSystemAssignedRoleAssignment) { foreach ($roleAssignmentId in $depSystemAssignedRoleAssignment.RoleAssignmentId) { #Filter through each return and validate resource is not at child resource scope if ($roleAssignmentId -notlike '*/resourcegroups/*/providers/*/providers/*') { $dependency += [PSCustomObject]@{ Type = 'roleAssignments' Id = $roleAssignmentId } } else { Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceDependencyNested' -LogStringValues $roleAssignmentId, $policyAssignment.Id } } } } } } if ($dependency) { $dependency = $dependency | Sort-Object Id -Unique | Where-Object {$_.Id -ne $resourceToDelete.Id} return $dependency } } function Get-AzPolicyDefinitionDeletionDependency { param ( $resourceToDelete ) if ($resourceToDelete.Type -eq 'Microsoft.Authorization/policyDefinitions') { $dependency = @() $query = "PolicyResources | where type == 'microsoft.authorization/policysetdefinitions' and properties.policyType == 'Custom' | project id, type, policyDefinitions = (properties.policyDefinitions) | mv-expand policyDefinitions | project id, type, policyDefinitionId = tostring(policyDefinitions.policyDefinitionId) | where policyDefinitionId == '$($resourceToDelete.Id)' | order by policyDefinitionId asc | order by id asc" $depPolicySetDefinition = Search-AzGraphDeletionDependency -query $query if ($depPolicySetDefinition) { $depPolicySetDefinition = foreach ($policySetDefinition in $depPolicySetDefinition) { #Loop through each return from graph cache and validate resource is still present in Azure $policy = Get-AzPolicySetDefinition -Id $policySetDefinition.Id -ErrorAction SilentlyContinue if ($policy) { $dependency += [PSCustomObject]@{ Type = $policy.Type Id = $policy.Id } $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $policy } } } if ($dependency) { $dependency = $dependency | Sort-Object Id -Unique | Where-Object {$_.Id -ne $resourceToDelete.Id} return $dependency } } } function Search-AzGraphDeletionDependency { param ( $query, $PartialMgDiscoveryRoot = (Get-PSFConfigValue -FullName 'AzOps.Core.PartialMgDiscoveryRoot') ) $results = @() if ($PartialMgDiscoveryRoot) { foreach ($managementRoot in $PartialMgDiscoveryRoot) { $subscriptions = Get-AzOpsNestedSubscription -Scope $managementRoot $results += Search-AzOpsAzGraph -ManagementGroupName $managementRoot -Query $query -ErrorAction Stop if ($subscriptions) { $results += Search-AzOpsAzGraph -Subscription $subscriptions -Query $query -ErrorAction Stop } } } else { $results = Search-AzOpsAzGraph -Query $query -UseTenantScope -ErrorAction Stop } if ($results) { $results = $results | Sort-Object Id -Unique return $results } } $dependencyMissing = $null #Adjust TemplateParameterFilePath to compensate for policyDefinitions and policySetDefinitions usage of parameters.json if ($TemplateParameterFilePath -and $TemplateFilePath -eq (Resolve-Path (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate')).Path) { $TemplateFilePath = $TemplateParameterFilePath } #Deployment Name $fileItem = Get-Item -Path $TemplateFilePath $removeJobName = $fileItem.BaseName -replace '\.json$' -replace ' ', '_' $removeJobName = "AzOps-RemoveResource-$removeJobName" Write-AzOpsMessage -LogLevel Important -LogString 'Remove-AzOpsDeployment.Processing' -LogStringValues $removeJobName, $TemplateFilePath #region Parse Content $templateContent = Get-Content $TemplateFilePath | ConvertFrom-Json -AsHashtable #endregion Parse Content #region Validate template type AzOps generated or not $schemavalue = '$schema' $customDeletion = $false if ($templateContent.metadata._generator.name -eq "AzOps" -or $templateContent.$schemavalue -like "*deploymentParameters.json#") { Write-AzOpsMessage -LogLevel Verbose -LogString 'Remove-AzOpsDeployment.Metadata.AzOps' -LogStringValues $TemplateFilePath } elseif ($true -eq $CustomTemplateResourceDeletion) { Write-AzOpsMessage -LogLevel Verbose -LogString 'Remove-AzOpsDeployment.Metadata.Custom' -LogStringValues $TemplateFilePath $customDeletion = $true } else { Write-AzOpsMessage -LogLevel Error -LogString 'Remove-AzOpsDeployment.Metadata.Failed' -LogStringValues $TemplateFilePath return } #endregion Validate template type AzOps generated or not #region Resolve Scope try { $scopeObject = New-AzOpsScope -Path $TemplateFilePath -StatePath $StatePath -ErrorAction Stop -WhatIf:$false } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.Scope.Failed' -LogStringValues $TemplateFilePath -ErrorRecord $_ return } if (-not $scopeObject) { Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.Scope.Empty' -LogStringValues $TemplateFilePath return } #endregion Resolve Scope #region SetContext Set-AzOpsContext -ScopeObject $scopeObject #endregion SetContext #region remove resources if ($customDeletion -eq $false -and $scopeObject.Resource -in $DeletionSupportedResourceType) { $dependency = @() switch ($scopeObject.Resource) { # Check resource existance through optimal path 'locks' { $resourceToDelete = Get-AzResourceLock -Scope "/subscriptions/$($ScopeObject.Subscription)" -ErrorAction SilentlyContinue | Where-Object { $_.ResourceID -eq $ScopeObject.Scope } } 'policyAssignments' { $resourceToDelete = Get-AzPolicyAssignment -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $resourceToDelete $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'policyDefinitions' { $resourceToDelete = Get-AzPolicyDefinition -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $resourceToDelete $dependency += Get-AzPolicyDefinitionDeletionDependency -resourceToDelete $resourceToDelete $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'policyExemptions' { $resourceToDelete = Get-AzPolicyExemption -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'policySetDefinitions' { $resourceToDelete = Get-AzPolicySetDefinition -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $resourceToDelete $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'roleAssignments' { $resourceToDelete = (Invoke-AzRestMethod -Path "$($scopeObject.Scope)?api-version=2022-04-01" | Where-Object { $_.StatusCode -eq 200 }).Content | ConvertFrom-Json -Depth 100 if ($resourceToDelete) { $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'resourceGroups' { $resourceToDelete = Get-AzResourceGroup -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $resourceToDelete | Add-Member -MemberType NoteProperty -Name "Type" -Value "$($scopeObject.Type)" $resourceToDelete | Add-Member -MemberType NoteProperty -Name "SubscriptionId" -Value "$($scopeObject.Subscription)" $resourceToDelete | Add-Member -MemberType NoteProperty -Name "Id" -Value "$($resourceToDelete.ResourceId)" $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } } # If no resource to delete was found return if (-not $resourceToDelete) { Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceNotFound' -LogStringValues $scopeObject.Resource, $scopeObject.Scope $results = 'What if operation failed:{1}Deletion of target resource {0}.{1}Resource could not be found' -f $scopeObject.scope, [environment]::NewLine Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true return } if ($dependency) { foreach ($resource in $dependency) { if ($resource.Id -notin $deletionList.ScopeObject.Scope) { Write-AzOpsMessage -LogLevel Critical -LogString 'Remove-AzOpsDeployment.ResourceDependencyNotFound' -LogStringValues $resource.Id, $scopeObject.Scope $results = 'Missing resource dependency:{2}{0} for successful deletion of {1}.{2}{2}Please add dependent resource to pull request and retry.' -f $resource.Id, $scopeObject.scope, [environment]::NewLine Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true $dependencyMissing = [PSCustomObject]@{ dependencyMissing = $true } } } } else { $results = 'What if successful:{1}Performing the operation:{1}Deletion of target resource {0}.' -f $scopeObject.scope, [environment]::NewLine Write-AzOpsMessage -LogLevel Verbose -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $results Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true } if ($dependencyMissing) { return $dependencyMissing } elseif ($dependency) { $results = 'What if successful:{1}Performing the operation:{1}Deletion of target resource {0}.' -f $scopeObject.scope, [environment]::NewLine Write-AzOpsMessage -LogLevel Verbose -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $results Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true } if ($PSCmdlet.ShouldProcess("Remove $($scopeObject.Scope)?")) { $null = Remove-AzResourceRaw -ScopeObject $scopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath } else { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.SkipDueToWhatIf' } } elseif ($customDeletion -eq $false -and $scopeObject.Resource -notin $DeletionSupportedResourceType) { Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.SkipUnsupportedResource' -LogStringValues $TemplateFilePath -Target $scopeObject return } elseif ($customDeletion -eq $true) { # Perform a New-AzOpsDeployment using WhatIf with ResourceIdOnly to extrapolate resources inside template $removalJob = New-AzOpsDeployment -DeploymentName $DeploymentName -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath -WhatIfResultFormat 'ResourceIdOnly' -WhatIf:$true if ($removalJob.results.Changes.Count -gt 0) { # Initialize array to store items that need retry $retry = @() $removalJobChanges = Set-AzOpsRemoveOrder -DeletionList $removalJob.results.Changes -Index { (New-AzOpsScope -Scope $_.FullyQualifiedResourceId -WhatIf:$false).Resource } $allResults = @() foreach ($change in $removalJobChanges) { $resource = $null $resourceScopeObject = $null $removeAction = $null # Check if the resource exists $resourceScopeObject = New-AzOpsScope -Scope $change.FullyQualifiedResourceId -WhatIf:$false $resource = Get-AzOpsResource -ScopeObject $resourceScopeObject -ErrorAction SilentlyContinue if ($resource) { $results = 'What if successful:{1}Performing the operation:{1}Deletion of target resource {0}.' -f $resourceScopeObject.Scope, [environment]::NewLine $allResults += $results Write-AzOpsMessage -LogLevel Verbose -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $results Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' # Check if the removal should be performed if ($PSCmdlet.ShouldProcess("Remove $($resourceScopeObject.Scope)?")) { $removeAction = Remove-AzResourceRaw -ScopeObject $resourceScopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath # If removal failed, add to retry if ($removeAction.Status -eq 'failed') { $retry += $removeAction } } else { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.SkipDueToWhatIf' } } else { # Log warning if resource not found Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceNotFound' -LogStringValues $ScopeObject.Resource, $change.FullyQualifiedResourceId $results = 'What if operation failed:{1}Deletion of target resource {0}.{1}Resource could not be found' -f $change.FullyQualifiedResourceId, [environment]::NewLine $allResults += $results } } $baseTemplateCheck = $TemplateFilePath -replace '\.bicep$', '.json' if ($TemplateParameterFilePath) { $baseParameterCheck = $TemplateParameterFilePath -replace '\.bicepparam$', 'parameters.json' } if ($DeleteSet) { $deleteSetCheck = $DeleteSet -replace '\.bicep$', '.json' $deleteSetCheck = $deleteSetCheck -replace '\.bicepparam$', '.parameters.json' # Check if template and parameter file exist in $DeleteSet, example AzOps has been instructed to remove template.json but not the associated parameter.json $resultsFileAssociation = switch ($null) { { $baseTemplateCheck -notin $deleteSetCheck -and $baseParameterCheck -notin $deleteSetCheck } { 'Missing template and parameter file association:{2}{0} and {1} for deletion.{2}{2}Ensure that you have reviewed and confirmed the necessity of each deletion.{2}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{2}' -f $TemplateFilePath, $TemplateParameterFilePath, [environment]::NewLine } { $baseTemplateCheck -notin $deleteSetCheck } { 'Missing template file association:{1}{0} for deletion.{1}{1}Ensure that you have reviewed and confirmed the necessity of each deletion.{1}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{1}' -f $TemplateFilePath, [environment]::NewLine } { $baseParameterCheck -notin $deleteSetCheck } { 'Missing parameter file association:{1}{0} for deletion.{1}{1}Ensure that you have reviewed and confirmed the necessity of each deletion.{1}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{1}' -f $TemplateParameterFilePath, [environment]::NewLine } } # If there are $resultsFileAssociation, combine them with existing results and log a warning if ($resultsFileAssociation) { $finalResults = @() $finalResults += $resultsFileAssociation $finalResults += $allResults $allResults = $finalResults Write-AzOpsMessage -LogLevel Warning -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $allResults } } Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $allResults -RemoveAzOpsFlag $true if ($retry.Count -gt 0) { # Retry failed removals recursively Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.Resource.RetryCount' -LogStringValues $retry.Count foreach ($try in $retry) { $try.Status = $null } $removeActionRecursive = Remove-AzResourceRaw -InputObject $retry -Recursive $removeActionRecursiveRemaining = $removeActionRecursive | Where-Object { $_.Status -eq 'failed' } return $removeActionRecursiveRemaining } } else { # No resource to remove was found Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceNotFound' -LogStringValues $scopeObject.Resource, $scopeObject.Scope $results = 'What if operation failed:{1}Deletion of target resource {0}.{1}Resource could not be found' -f $scopeObject.Scope, [environment]::NewLine Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $results -RemoveAzOpsFlag $true return } } #endregion remove resources } } function Remove-AzOpsInvalidCharacter { <# .SYNOPSIS Takes string input and removes invalid characters. .DESCRIPTION Takes string input and removes invalid characters. .PARAMETER String String to remove invalid characters from. .PARAMETER Override Accepts input to skip selected invalid characters. .EXAMPLE > Remove-AzOpsInvalidCharacter -String "microsoft.operationalinsights_workspaces_savedsearches-fgh341_logmanagement(fgh343)_logmanagement|countofiislogentriesbyhostrequestedbyclient.json" Function returns with the '|' invalid character removed: microsoft.operationalinsights_workspaces_savedsearches-fgh341_logmanagement(fgh343)_logmanagementcountofiislogentriesbyhostrequestedbyclient.json #> [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string] $String, [array] $InvalidChars = @('"','<','>','|','â–º','â—„','↕','‼','¶','§','â–¬','↨','↑','↓','→','∟','↔','*','?','\','/',':'), [array] $Override ) process { # If Override has been provided remove them from InvalidChars if ($Override) { $InvalidChars = Compare-Object -ReferenceObject $InvalidChars -DifferenceObject $Override -PassThru } # Check if string contains invalid characters $pattern = $InvalidChars | Out-String -NoNewline if ($String -match "[$pattern]") { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsInvalidCharacter.Invalid' -LogStringValues $String # Arrange string into character array $fileNameChar = $String.ToCharArray() # Iterate over each character in string foreach ($character in $fileNameChar) { # If character exists in invalid array then replace character if ($character -in $InvalidChars) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsInvalidCharacter.Removal' -LogStringValues $character, $String # Remove invalid character $String = $String.Replace($character.ToString(),'') } } } # Always remove square brackets $String = $String -replace "(\[|\])","" Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsInvalidCharacter.Completed' -LogStringValues $String # Return processed string return $String } } function Remove-AzResourceRaw { <# .SYNOPSIS Performs resource deletion in Azure at any scope. .DESCRIPTION Performs resource deletion in Azure with FullyQualifiedResourceId and ScopeObject. .PARAMETER TemplateFilePath Path where the ARM templates can be found. .PARAMETER TemplateParameterFilePath Path where the parameters of the ARM templates can be found. .PARAMETER ScopeObject Resource to delete. .PARAMETER InputObject Object containing items for processing, used in combination with parameter Recursive. .PARAMETER Recursive If specified, performs recursive resource deletion and requires use of parameter InputObject. .EXAMPLE > Remove-AzResourceRaw -ScopeObject $ScopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath Name Value ---- ----- TemplateFilePath /root/managementgroup/subscription/resourcegroup/template.json TemplateParameterFilePath /root/managementgroup/subscription/resourcegroup/template.parameters.json ScopeObject ScopeObject Status success > Remove-AzResourceRaw -InputObject $retry -Recursive Name Value ---- ----- TemplateFilePath /root/managementgroup/subscription/resourcegroup/template.json TemplateParameterFilePath /root/managementgroup/subscription/resourcegroup/template.parameters.json ScopeObject ScopeObject Status success #> [CmdletBinding()] param ( [string] $TemplateFilePath, [string] $TemplateParameterFilePath, [AzOpsScope] $ScopeObject, [array] $InputObject, [switch] $Recursive ) process { function Remove-AzResourceRawRecursive { <# .SYNOPSIS Performs recursive resource deletion in Azure at any scope. .DESCRIPTION Takes $InputObject and performs recursive resource deletion in Azure and exhaust any permutation. .PARAMETER InputObject Parameter containing items for processing. .PARAMETER CurrentOrder Internal parameter to track recursive progress. .PARAMETER OutputObject Track item processing and return result. .EXAMPLE > $successFullItems, $failedItems = Remove-AzResourceRawRecursive -InputObject $retry Example of a $retry array with 6 items, the number of permutations will be 6×5×4×3×2×1=720 #> [CmdletBinding()] param ( [array] $InputObject, [array] $CurrentOrder = @(), [array] $OutputObject = @() ) process { if ($InputObject.Count -eq 0) { # Base case: All items have been used, perform action on the current order foreach ($item in $CurrentOrder) { if ($item.Status -eq 'failed' -or $null -eq $item.Status) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzResourceRawRecursive.Processing' -LogStringValues $item.ScopeObject.Resource, $item.ScopeObject.Scope # Attempt to remove the resource $result = Remove-AzResourceRaw -ScopeObject $item.ScopeObject -TemplateFilePath $item.TemplateFilePath -TemplateParameterFilePath $item.TemplateParameterFilePath if ($result.Status -eq 'failed' -and $result.ScopeObject.Scope -notin $OutputObject.ScopeObject.Scope){ # Add failed result to the output object $OutputObject += $result } } } # Return the final result return $OutputObject } else { if ($InputObject -and $OutputObject) { # Filter out items already processed successfully $filteredOutputObject = @() foreach ($item in $InputObject) { if ($item.ScopeObject.Scope -in $OutputObject.ScopeObject.Scope) { foreach ($output in $OutputObject) { if ($output.ScopeObject.Scope -eq $item.ScopeObject.Scope -and $output.Status -eq 'failed') { # Add previously failed item to the filtered output $filteredOutputObject += $output continue } } } } if ($filteredOutputObject) { $InputObject = $filteredOutputObject } } # Recursive case: Try each item in the current position and recurse with the remaining items foreach ($item in $InputObject) { $remainingItems = $InputObject -ne $item $newOrder = $CurrentOrder + $item # Recursively call Remove-AzResourceRawRecursive $OutputObject = Remove-AzResourceRawRecursive -InputObject $remainingItems -CurrentOrder $newOrder -OutputObject $OutputObject } # Return the output after all permutations return $OutputObject } } } if ($null -ne $InputObject -and $Recursive) { # Perform recursive resource deletion $result = Remove-AzResourceRawRecursive -InputObject $InputObject if ($result) { return $result } else { return } } elseif ($null -eq $InputObject -and $Recursive) { # Recursive resource deletion missing input Write-AzOpsMessage -LogLevel Error -LogString 'Remove-AzResourceRaw.Resource.Recursive.Missing' return } else { if (-not $ScopeObject) { # Resource deletion missing input Write-AzOpsMessage -LogLevel Error -LogString 'Remove-AzResourceRaw.Resource.Missing' return } # Construct result object $result = [PSCustomObject]@{ TemplateFilePath = $TemplateFilePath TemplateParameterFilePath = $TemplateParameterFilePath ScopeObject = $ScopeObject Status = 'success' } # Check if the resource exists $resource = Get-AzOpsResource -ScopeObject $ScopeObject -ErrorAction SilentlyContinue # Remove the resource if it exists if ($resource) { try { # Set Azure context for removal operation Set-AzOpsContext -ScopeObject $ScopeObject $null = Remove-AzResource -ResourceId $ScopeObject.Scope -Force -ErrorAction Stop $maxAttempts = 4 $attempt = 1 $gone = $false while ($gone -eq $false -and $attempt -le $maxAttempts) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzResourceRaw.Resource.CheckExistence' -LogStringValues $ScopeObject.Scope Start-Sleep -Seconds 10 $tryResource = Get-AzOpsResource -ScopeObject $ScopeObject -ErrorAction SilentlyContinue if (-not $tryResource) { $gone = $true } $attempt++ } } catch { # Log failure message Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzResourceRaw.Resource.Failed' -LogStringValues $ScopeObject.Resource, $ScopeObject.Scope $result.Status = 'failed' } } else { # Log not found message $result.Status = 'notfound' } # Return result object return $result } } } function Save-AzOpsManagementGroupChild { <# .SYNOPSIS Recursively build/change Management Group hierarchy in file system from provided scope. .DESCRIPTION Recursively build/change Management Group hierarchy in file system from provided scope. .PARAMETER Scope Scope to discover - assumes [AzOpsScope] object .PARAMETER StatePath The root path to where the entire state is being built in. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE > Save-AzOpsManagementGroupChild -Scope (New-AzOpsScope -scope /providers/Microsoft.Management/managementGroups/contoso) Discover Management Group hierarchy from scope .INPUTS AzOpsScope .OUTPUTS Management Group hierarchy in file system #> [CmdletBinding(SupportsShouldProcess = $true)] [OutputType()] param ( [Parameter(Mandatory = $true)] $Scope, [string] $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State') ) process { Write-AzOpsMessage -LogLevel Debug -LogString 'Save-AzOpsManagementGroupChild.Starting' Invoke-PSFProtectedCommand -ActionString 'Save-AzOpsManagementGroupChild.Creating.Scope' -Target $Scope -ScriptBlock { $scopeObject = New-AzOpsScope -Scope $Scope -StatePath $StatePath -ErrorAction SilentlyContinue -Confirm:$false } -EnableException $true -PSCmdlet $PSCmdlet if (-not $scopeObject) { return } # In case -WhatIf is used Write-AzOpsMessage -LogLevel Debug -LogString 'Save-AzOpsManagementGroupChild.Processing' -LogStringValues $scopeObject.Scope # Construct all file paths for scope $scopeStatepath = $scopeObject.StatePath $statepathFileName = [IO.Path]::GetFileName($scopeStatepath) $statepathDirectory = [IO.Path]::GetDirectoryName($scopeStatepath) $statepathScopeDirectory = [IO.Directory]::GetParent($statepathDirectory).ToString() $statepathScopeDirectoryParent = [IO.Directory]::GetParent($statepathScopeDirectory).ToString() Write-AzOpsMessage -LogLevel Debug -LogString 'Save-AzOpsManagementGroupChild.Data.StatePath' -LogStringValues $scopeStatepath Write-AzOpsMessage -LogLevel Debug -LogString 'Save-AzOpsManagementGroupChild.Data.FileName' -LogStringValues $statepathFileName Write-AzOpsMessage -LogLevel Debug -LogString 'Save-AzOpsManagementGroupChild.Data.Directory' -LogStringValues $statepathDirectory Write-AzOpsMessage -LogLevel Debug -LogString 'Save-AzOpsManagementGroupChild.Data.ScopeDirectory' -LogStringValues $statepathScopeDirectory Write-AzOpsMessage -LogLevel Debug -LogString 'Save-AzOpsManagementGroupChild.Data.ScopeDirectoryParent' -LogStringValues $statepathScopeDirectoryParent # If file is found anywhere in "AzOps.Core.State", ensure that it is at the right scope or else it doesn't matter if (Get-ChildItem -Path $Statepath -File -Recurse -Force | Where-Object Name -eq $statepathFileName) { # If the file is found in AzOps State $exisitingScopePath = (Get-ChildItem -Path $Statepath -File -Recurse -Force | Where-Object Name -eq $statepathFileName).Directory # Looking at parent of parent if AutoGeneratedTemplateFolderPath is sub-directory, looking for parent (scope folder) of parent (actual parent in Azure) if ( ((Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath') -notin '.') -and $exisitingScopePath.Parent.Parent.FullName -ne $statepathScopeDirectoryParent) { if ($exisitingScopePath.Parent.FullName -ne $statepathScopeDirectoryParent) { Write-AzOpsMessage -LogLevel Important -LogString 'Save-AzOpsManagementGroupChild.Moving.Source' -LogStringValues $exisitingScopePath Move-Item -Path $exisitingScopePath.Parent -Destination $statepathScopeDirectoryParent -Force Write-AzOpsMessage -LogLevel Important -LogString 'Save-AzOpsManagementGroupChild.Moving.Destination' -LogStringValues $statepathScopeDirectoryParent } } # Files might be at the right scope but not in right AutoGeneratedTemplateFolderPath e.g. when AutoGeneratedTemplateFolderPath is changed. if (-not (Test-Path $statepathDirectory)) { New-Item -Path $statepathDirectory -ItemType Directory -Force | out-null } # For all the files in AutoGeneratedTemplateFolderPath directory, only moving files that are auto generated Get-ChildItem -Path (Get-ChildItem -Path $Statepath -File -Recurse -Force | Where-Object Name -eq $statepathFileName).Directory -File -Filter 'Microsoft.*' | Move-Item -Destination $statepathDirectory -Force } # Create empty object for any discovered subscriptions below $subscriptions = @() # Based on $scopeObject perform unique logic switch ($scopeObject.Type) { managementGroups { ConvertTo-AzOpsState -Resource ($script:AzOpsAzManagementGroup | Where-Object { $_.Name -eq $scopeObject.ManagementGroup }) -ExportPath $scopeObject.StatePath -StatePath $StatePath foreach ($child in $script:AzOpsAzManagementGroup.Where{ $_.Name -eq $scopeObject.ManagementGroup }.Children) { if ($child.Type -eq '/subscriptions') { if ($script:AzOpsSubscriptions.Id -contains $child.Id) { # Subscription discovered at ManagementGroup scope, collect subscription information based on $child object and store information in $subscriptions for later $subscriptions += Save-AzOpsManagementGroupChild -Scope $child.Id -StatePath $StatePath } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Save-AzOpsManagementGroupChild.Subscription.NotFound' -LogStringValues $child.Name } } else { Save-AzOpsManagementGroupChild -Scope $child.Id -StatePath $StatePath } } } subscriptions { if (($script:AzOpsSubscriptions.Id -contains $scopeObject.Scope) -and ($script:AzOpsAzManagementGroup.Children | Where-Object Name -eq $scopeObject.Name)) { # Subscription matching conditions found, construct subscription and object statepath into $return and output information to caller $return = [PSCustomObject]@{ Subscription = ($script:AzOpsAzManagementGroup.Children | Where-Object Name -eq $scopeObject.Name) Path = $scopeObject.StatePath } return $return } } } # If $subscriptions exists process all subscriptions with parallel to increase performance during folder/file creation if ($subscriptions) { # Prepare Input Data for parallel processing $runspaceData = @{ AzOpsPath = "$($script:ModuleRoot)\AzOps.psd1" StatePath = $StatePath runspace_AzOpsAzManagementGroup = $script:AzOpsAzManagementGroup runspace_AzOpsSubscriptions = $script:AzOpsSubscriptions runspace_AzOpsPartialRoot = $script:AzOpsPartialRoot runspace_AzOpsResourceProvider = $script:AzOpsResourceProvider } $subscriptions | Foreach-Object -ThrottleLimit (Get-PSFConfigValue -FullName 'AzOps.Core.ThrottleLimit') -Parallel { $subscription = $_ $runspaceData = $using:runspaceData Import-Module "$([PSFramework.PSFCore.PSFCoreHost]::ModuleRoot)/PSFramework.psd1" $azOps = Import-Module $runspaceData.AzOpsPath -Force -PassThru & $azOps { $script:AzOpsAzManagementGroup = $runspaceData.runspace_AzOpsAzManagementGroup $script:AzOpsSubscriptions = $runspaceData.runspace_AzOpsSubscriptions $script:AzOpsPartialRoot = $runspaceData.runspace_AzOpsPartialRoot $script:AzOpsResourceProvider = $runspaceData.runspace_AzOpsResourceProvider } & $azOps { ConvertTo-AzOpsState -Resource $subscription.Subscription -ExportPath $subscription.Path -StatePath $runspaceData.StatePath } } Clear-PSFMessage } } } function Search-AzOpsAzGraph { <# .SYNOPSIS Search Graph based on input query combined with scope ManagementGroupName or Subscription Id. Manages paging of results, ensuring completeness of results. .PARAMETER UseTenantScope Use Tenant as Scope true or false .PARAMETER ManagementGroupName ManagementGroup Name .PARAMETER Subscription Subscription Id's .PARAMETER Query AzureResourceGraph-Query .EXAMPLE > Search-AzOpsAzGraph -ManagementGroupName "5663f39e-feb1-4303-a1f9-cf20b702de61" -Query "policyresources | where type == 'microsoft.authorization/policyassignments'" Discover all policy assignments deployed at Management Group scope and below #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [switch] $UseTenantScope, [Parameter(Mandatory = $false)] [string] $ManagementGroupName, [Parameter(Mandatory = $false)] [object] $Subscription, [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [object] $Query ) process { Write-AzOpsMessage -LogLevel Verbose -LogString 'Search-AzOpsAzGraph.Processing' -LogStringValues $Query $results = @() if ($UseTenantScope) { do { $processing = Search-AzGraph -UseTenantScope -Query $Query -AllowPartialScope -SkipToken $processing.SkipToken -ErrorAction Stop $results += $processing } while ($processing.SkipToken) } if ($ManagementGroupName) { do { $processing = Search-AzGraph -ManagementGroup $ManagementGroupName -Query $Query -AllowPartialScope -SkipToken $processing.SkipToken -ErrorAction Stop $results += $processing } while ($processing.SkipToken) } if ($Subscription) { # Create a counter, set the batch size, and prepare a variable for the results $counter = [PSCustomObject] @{ Value = 0 } $batchSize = 1000 # Group subscriptions into batches to conform with graph limits $subscriptionBatch = $Subscription | Group-Object -Property { [math]::Floor($counter.Value++ / $batchSize) } foreach ($group in $subscriptionBatch) { do { $processing = Search-AzGraph -Subscription ($group.Group).Id -Query $Query -SkipToken $processing.SkipToken -ErrorAction Stop $results += $processing } while ($processing.SkipToken) } } if ($results) { $resultsType = @() foreach ($result in $results) { # Process each graph result and normalize ProviderNamespace casing foreach ($ResourceProvider in $script:AzOpsResourceProvider) { if ($ResourceProvider.ProviderNamespace -eq $result.type.Split('/')[0]) { foreach ($ResourceTypeName in $ResourceProvider.ResourceTypes.ResourceTypeName) { if ($ResourceTypeName -eq $result.type.Split('/')[1]) { $result.type = ($result.type).replace($result.type.Split('/')[0],$ResourceProvider.ProviderNamespace) $result.type = ($result.type).replace($result.type.Split('/')[1],$ResourceTypeName) $resultsType += $result break } } break } } } Write-AzOpsMessage -LogLevel Debug -LogString 'Search-AzOpsAzGraph.Processing.Done' -LogStringValues $Query return $resultsType } else { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Search-AzOpsAzGraph.Processing.NoResult' -LogStringValues $Query } } } function Set-AzOpsContext { <# .SYNOPSIS Changes the currently active azure context to the subscription of the specified scope object. .DESCRIPTION Changes the currently active azure context to the subscription of the specified scope object. .PARAMETER ScopeObject The scope object [AzOpsScope] into which context to change. .EXAMPLE > Set-AzOpsContext -ScopeObject $scopeObject Changes the current context to the subscription of $scopeObject. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $ScopeObject ) begin { $context = Get-AzContext } process { if (-not $ScopeObject.Subscription) { return } if ($context.Subscription.Id -ne $ScopeObject.Subscription) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsContext.Change' -LogStringValues $context.Subscription.Name, $ScopeObject.SubscriptionDisplayName, $ScopeObject.Subscription $null = Set-AzContext -SubscriptionId $scopeObject.Subscription -WhatIf:$false } } } function Set-AzOpsRemoveOrder { <# .SYNOPSIS Sorts a custom object list based on a specified priority order using a user-defined index. .DESCRIPTION Used to sort deletion priority, aka locks are removed prior to resource deletion attempts. .PARAMETER DeletionList Custom object list to be sorted based on the defined priority. .PARAMETER Index Script block that determines the index used for sorting the deletion list. .PARAMETER Priority Optional array of strings representing the priority order. Defaults to a predefined order if not provided. .EXAMPLE > $sortedList = Set-AzOpsRemoveOrder -DeletionList $myCustomObjectList -Index { $_.SomeProperty } #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] $DeletionList, [Parameter(Mandatory = $true)] [scriptblock] $Index, [string[]] $Priority = @( "locks", "policyExemptions", "policyAssignments", "policySetDefinitions", "policyDefinitions", "resourceGroups", "managementGroups" ) ) process { #Sort 'DeletionList' based on 'Priority' $deletionListSorted = $DeletionList | Sort-Object -Property { $resolvedIndex = & $Index $priorityIndex = $Priority.IndexOf($resolvedIndex) if ($priorityIndex -eq -1) { # Set a default priority for items not found in Priority return [int]::MaxValue } else { return $priorityIndex } } # Return processed list return $deletionListSorted } } function Set-AzOpsStringLength { <# .SYNOPSIS Takes string input and shortens it according to maximum length. .DESCRIPTION Takes string input and shortens it according to maximum length. .PARAMETER String String to shorten. .PARAMETER MaxStringLength Set the maximum length for returned string, default is 255 characters. Maximum filename length is based on underlying execution environments maximum allowed filename character limit of 255 and the additional characters added by AzOps for files measured by buffer <.parameters> <.json> or <.bicep>. .EXAMPLE > Set-AzOpsStringLength -String "microsoft.recoveryservices_vaults_replicationfabrics_replicationprotectioncontainers_replicationprotectioncontainermappings-test1-migratevault-1470815024_1541289ea1c5c535f89c0788063b3f5af00e91a2c63438851d90ef7143747149_cloud_308af796-701f-4d5d-ba68-a2434abb3c84_defaultrecplicationvm-containermapping" Setting the string length for the above example with default character limit returns the following: microsoft.recoveryservices_vaults_replicationfabrics_replicationprotectioncontainers_replicationprotectioncontainermappings-test1-migratevault-1470815024_1541289ea1c5c535f89c-BD89FDDC1A27FAD7C0E62CE2AA0A4513193D4F13907CDCF08E540BB5EFBBA9BA #> [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string] $String, [Parameter(Mandatory = $false)] [ValidateRange(155,255)] [int] $MaxStringLength = "255" ) process { # Determine required buffer for ending, set buffer according to space required $buffer = ".parameters".Length + $(Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix').Length # Check if string exceed maximum length if (($String.Length + $buffer) -gt $MaxStringLength){ $overSize = $String.Length + $buffer - $MaxStringLength Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsStringLength.ToLong' -LogStringValues $String,$MaxStringLength,$overSize # Generate 64-character hash based on input string $stringStream = [IO.MemoryStream]::new([byte[]][char[]]$String) $stringHash = Get-FileHash -InputStream $stringStream -Algorithm SHA256 $startOfNameLength = $MaxStringLength - $stringHash.Hash.Length - $buffer - 1 # Process new name if ($startOfNameLength -gt 1) { $startOfName = $String.Substring(0,($startOfNameLength)) $newName = $startofName + '-' + $stringHash.Hash } else { $newName = $stringHash.Hash + $endofName } # Construct new string with modified name $String = $String.Replace($String,$newName) Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsStringLength.Shortened' -LogStringValues $String,$MaxStringLength return $String } else { # Return original string, it is within limit Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsStringLength.WithInLimit' -LogStringValues $String,$MaxStringLength return $String } } } function Set-AzOpsWhatIfOutput { <# .SYNOPSIS Logs the output from a What-If deployment .DESCRIPTION Logs the output from a What-If deployment .PARAMETER Results The WhatIf result from a deployment .PARAMETER RemoveAzOpsFlag RemoveAzOpsFlag is set to true when a need to push content about deletion is required .PARAMETER ResultSizeLimit The character limit allowed for comments 64,000 .PARAMETER ResultSizeMaxLimit The maximum upper character limit allowed for comments 64,600 .PARAMETER FilePath Template File in scope of WhatIf .PARAMETER ParameterFilePath Parameter File in scope of WhatIf .EXAMPLE > Set-AzOpsWhatIfOutput -Results $results > Set-AzOpsWhatIfOutput -Results $results -RemoveAzOpsFlag $true > Set-AzOpsWhatIfOutput -FilePath '/templates/root/myresource.bicep' -Results $results -RemoveAzOpsFlag $true #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $Results, [Parameter(Mandatory = $false)] $RemoveAzOpsFlag = $false, [Parameter(Mandatory = $false)] $ResultSizeLimit = "64000", [Parameter(Mandatory = $false)] $ResultSizeMaxLimit = "64600", [Parameter(Mandatory = $true)] $FilePath, [Parameter(Mandatory = $false)] $ParameterFilePath ) process { $tempPath = [System.IO.Path]::GetTempPath() if ((-not (Test-Path -Path ($tempPath + 'OUTPUT.md'))) -or (-not (Test-Path -Path ($tempPath + 'OUTPUT.json')))) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' New-Item -Path ($tempPath + 'OUTPUT.md') -WhatIf:$false | Out-Null New-Item -Path ($tempPath + 'OUTPUT.json') -WhatIf:$false | Out-Null } if ($ParameterFilePath) { $resultHeadline = "$($FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1]) with $($ParameterFilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1])" } else { $resultHeadline = $FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1] } # Measure input $Results.Changes content $resultString = $Results | Out-String $resultStringMeasure = $resultString | Measure-Object -Line -Character -Word # Measure current OUTPUT.md content $existingContentMd = Get-Content -Path ($tempPath + 'OUTPUT.md') -Raw $existingContentStringMd = $existingContentMd | Out-String $existingContentStringMeasureMd = $existingContentStringMd | Measure-Object -Line -Character -Word # Gather current OUTPUT.json content $existingContent = @(Get-Content -Path ($tempPath + 'OUTPUT.json') -Raw | ConvertFrom-Json -Depth 100) # Export results to json file Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFileAdding' -LogStringValues 'json', $FilePath, $ParameterFilePath if ($RemoveAzOpsFlag) { $resultJson = [PSCustomObject]@{ WhatIfResult = $Results TemplateFile = $resultHeadline } } else { $resultJson = $results.Changes $resultJson | Add-Member -Name "TemplateFile" -Value $resultHeadline -MemberType NoteProperty -Force } $existingContent += $resultJson $existingContent = $existingContent | ConvertTo-Json -Depth 100 Set-Content -Path ($tempPath + 'OUTPUT.json') -Value $existingContent -WhatIf:$false # Check if $existingContentStringMeasureMd and $resultStringMeasure exceed allowed size in $ResultSizeLimit if (($existingContentStringMeasureMd.Characters + $resultStringMeasure.Characters) -gt $ResultSizeLimit) { Write-AzOpsMessage -LogLevel Warning -LogString 'Set-AzOpsWhatIfOutput.WhatIfFileMax' -LogStringValues $ResultSizeLimit $mdOutput = 'WhatIf Results for {1}:{0} WhatIf is too large for comment field, for more details look at PR files to determine changes.' -f [environment]::NewLine, $resultHeadline } else { if ($RemoveAzOpsFlag) { if ($Results -match 'Missing resource dependency' ) { $mdOutput = ':x: **Action Required**{0}WhatIf Results for Resource Deletion of {2}:{0}```{0}{1}{0}```' -f [environment]::NewLine, $resultString, $resultHeadline } elseif ($Results -match 'What if operation failed' -or $Results -match 'Missing template and parameter file association' -or $Results -match 'Missing template file association' -or $Results -match 'Missing parameter file association') { $mdOutput = ':warning: WhatIf Results for Resource Deletion of {2}:{0}```{0}{1}{0}```' -f [environment]::NewLine, $resultString, $resultHeadline } else { $mdOutput = ':white_check_mark: WhatIf Results for Resource Deletion of {2}:{0}```{0}{1}{0}```' -f [environment]::NewLine, $resultString, $resultHeadline } } else { $mdOutput = 'WhatIf Results for {2}:{0}```{0}{1}{0}```{0}' -f [environment]::NewLine, $resultString, $resultHeadline } } if ((($mdOutput | Measure-Object -Line -Character -Word).Characters + $existingContentStringMeasureMd.Characters) -le $ResultSizeMaxLimit) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFileAdding' -LogStringValues 'markdown', $FilePath, $ParameterFilePath Add-Content -Path ($tempPath + 'OUTPUT.md') -Value $mdOutput -WhatIf:$false } else { Write-AzOpsMessage -LogLevel Warning -LogString 'Set-AzOpsWhatIfOutput.WhatIfMessageMax' -LogStringValues $ResultSizeMaxLimit } } } function Write-AzOpsMessage { <# .SYNOPSIS Wrapper function to emit logs with Write-PSFMessage and ApplicationInsights. .PARAMETER ApplicationInsights Boolean to indicate if function should emit logs to ApplicationInsights. .PARAMETER Data Additional data points. .PARAMETER ErrorRecord Additional exception information from catch. .PARAMETER LogLevel Set level of message severity. .PARAMETER LogString String used to construct message. .PARAMETER LogStringValues String array used to enrich to message. .PARAMETER Metric Used to output metric. .PARAMETER MetricName Override FunctionName as MetricName. .PARAMETER FunctionName Static set FunctionName in log, otherwise Write-AzOpsMessage collects this from callStack. .PARAMETER ModuleName Static set ModuleName in log, otherwise Write-AzOpsMessage collects this from callStack. .PARAMETER Target The object that was processed when invoked. .EXAMPLE Write-AzOpsMessage -LogLevel Verbose #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [bool] $ApplicationInsights = (Get-PSFConfigValue -FullName 'AzOps.Core.ApplicationInsights'), [Parameter(Mandatory = $false)] [hashtable] $Data, [Parameter(Mandatory = $false)] [System.Management.Automation.ErrorRecord] $ErrorRecord, [Parameter(Mandatory = $true)] [ValidateSet("Critical", "Debug", "Error", "Host", "Important", "InternalComment", "Output", "Significant", "SomewhatVerbose", "System", "Verbose", "VeryVerbose", "Warning")] [string] $LogLevel, [Parameter(Mandatory = $true)] [string] $LogString, [Parameter(Mandatory = $false)] [string[]] $LogStringValues, [Parameter(Mandatory = $false)] [int] $Metric, [Parameter(Mandatory = $false)] [string] $MetricName, [Parameter(Mandatory = $false)] [string] $FunctionName, [Parameter(Mandatory = $false)] [string] $ModuleName, [Parameter(Mandatory = $false)] [string] $Target ) begin { # Collect callStack information to enrich logs with accurate caller information $callStack = (Get-PSCallStack)[1] if (-not $FunctionName) { $FunctionName = $callStack.Command } if (-not $ModuleName) { $ModuleName = $callstack.InvocationInfo.MyCommand.ModuleName } if (-not $ModuleName) { $ModuleName = "<Unknown>" } $File = $callStack.Position.File $Line = $callStack.Position.StartLineNumber } process { # Evaluate message verbosity $logLevels = @{ "Critical" = 1 "Debug" = 8 "Error" = 667 "Host" = 2 "Important" = 2 "InternalComment" = 9 "Output" = 2 "Significant" = 3 "SomewhatVerbose" = 6 "System" = 7 "Verbose" = 5 "VeryVerbose" = 4 "Warning" = 666 } $intLevel = $logLevels[$LogLevel] [int]$messageInfo = Get-PSFConfigValue -FullName 'PSFramework.Message.Info.Maximum' if (($messageInfo -lt $intLevel) -or ([PSFramework.Message.MessageHost]::MinimumInformation -gt $intLevel) -and ($intLevel -notin 666..667)) { # Message is below desired log verbosity, skip return } # Generate unique logTag information, used to identify each log entry $logTag = (New-Guid).Guid + '-' + (Get-Date).TimeOfDay.TotalMilliseconds # Pass information to Write-PSFMessage, to emit local log $params = @{ Level = $LogLevel String = $LogString StringValues = $LogStringValues Tag = $logTag ModuleName = $ModuleName FunctionName = $FunctionName File = $File Line = $Line Target = $Target ErrorRecord = $ErrorRecord Data = $Data } Write-PSFMessage @params if ($env:APPLICATIONINSIGHTS_CONNECTION_STRING -ne '' -and $ApplicationInsights -eq $true) { # Initiate export of log to ApplicationInsights try { # Gather log generated by Write-PSFMessage with retry/backoff logic $logMessage = Invoke-AzOpsScriptBlock -ArgumentList $FunctionName, $LogTag -ScriptBlock { Get-PSFMessage -FunctionName $FunctionName | Where-Object { $_.Tags -eq $LogTag } -ErrorAction Stop } -RetryCount 5 -RetryWait 1 -RetryType Exponential -ErrorAction Stop } catch { Write-PSFMessage -Level Warning -Message 'Get-PSFMessage failing: {0}' -StringValues $_ } if (-not $logMessage) { # No log message, return return } if ($logMessage.Count -gt 1) { # Log message has duplicate, return Write-PSFMessage -Level Warning -Message 'Get-PSFMessage has duplicate entires for {0} with tag {1}' -StringValues $LogString, $logTag return } # Construct ApplicationInsights object $azOpsMessage = [Microsoft.ApplicationInsights.TelemetryClient]::new() # Set ApplicationInsights connectionstring $azOpsMessage.TelemetryConfiguration.ConnectionString = $env:APPLICATIONINSIGHTS_CONNECTIONSTRING $azOpsMessage.Context.Session.Id = $PID # Adjust logMessage.Level to align with ApplicationInsights switch ($logMessage.Level) { { ($_ -eq "SomewhatVerbose") -or ($_ -eq "System") -or ($_ -eq "Debug") -or ($_ -eq "InternalComment") } { $level = "Verbose" break } { ($_ -eq "Important") -or ($_ -eq "Output") -or ($_ -eq "Host") -or ($_ -eq "Significant") -or ($_ -eq "VeryVerbose") } { $level = "Information" break } default { $level = ($logMessage.Level).ToString() break } } # Create ApplicationInsights customDimensions $logProperties = [System.Collections.Generic.Dictionary[string, string]]::new() $logProperties.Add("Timestamp", $logMessage.Timestamp) $logProperties.Add("ModuleName", $logMessage.ModuleName) $logProperties.Add("FunctionName", $logMessage.FunctionName) $logProperties.Add("TargetObject", $logMessage.TargetObject) $logProperties.Add("Data", $logmessage.Data) $logProperties.Add("Runspace", $logMessage.Runspace) $logProperties.Add("ComputerName", $logMessage.ComputerName) $logProperties.Add("File", $logMessage.File) $logProperties.Add("Line", $logMessage.Line) $logProperties.Add("CallStack", $logMessage.CallStack) $logProperties.Add("ErrorRecord", $logMessage.ErrorRecord) $logProperties.Add("String", $logMessage.String) # Create TrackTrace $azOpsMessage.TrackTrace($logMessage.Message, $level, $logProperties) # Create TrackEvent $azOpsMessage.TrackEvent($logMessage.FunctionName) if ($Metric) { # Create TrackMetric if (-not $MetricName) { $azOpsMessage.TrackMetric($logMessage.FunctionName, $Metric) } else { $azOpsMessage.TrackMetric($MetricName, $Metric) } } if ($level -eq "Critical" -or $level -eq "Error" -or $level -eq "Warning") { # Create TrackException $azOpsMessage.TrackException($logMessage.Message, $logProperties) } # Immediately emit log to ApplicationInsights $azOpsMessage.Flush() } } } function Initialize-AzOpsEnvironment { <# .SYNOPSIS Initializes the execution context of the module. .DESCRIPTION Initializes the execution context of the module. This is used by all other commands. It prepares / caches tenant, subscription and management group data. .PARAMETER IgnoreContextCheck Whether it should validate the azure contexts available or not. .PARAMETER InvalidateCache If data was already cached from a previous execution, execute again anyway? .PARAMETER ExcludedSubOffer Subscription filter. Subscriptions from the listed offerings will be ignored. Generally used to prevent using trial subscriptions, but can be adapted for other limitations. .PARAMETER ExcludedSubState Subscription filter. Subscriptions in the listed states will be ignored. For example, by default, disabled subscriptions will not be processed. .PARAMETER PartialMgDiscoveryRoot Custom search roots under which to detect management groups. Used for partial management group discovery. Must be used in combination with -PartialMgDiscovery .EXAMPLE > Initialize-AzOpsEnvironment Initializes the default execution context of the module. #> [CmdletBinding()] param ( [switch] $IgnoreContextCheck = (Get-PSFConfigValue -FullName 'AzOps.Core.IgnoreContextCheck'), [switch] $InvalidateCache = (Get-PSFConfigValue -FullName 'AzOps.Core.InvalidateCache'), [string[]] $ExcludedSubOffer = (Get-PSFConfigValue -FullName 'AzOps.Core.ExcludedSubOffer'), [string[]] $ExcludedSubState = (Get-PSFConfigValue -FullName 'AzOps.Core.ExcludedSubState'), [string[]] $PartialMgDiscoveryRoot = (Get-PSFConfigValue -FullName 'AzOps.Core.PartialMgDiscoveryRoot') ) begin { # Change PSSStyle output rendering to Host to remove escape sequences are removed in redirected or piped output. $PSStyle.OutputRendering = [System.Management.Automation.OutputRendering]::Host # Assert dependencies Assert-AzOpsWindowsLongPath -Cmdlet $PSCmdlet Assert-AzOpsJqDependency -Cmdlet $PSCmdlet $allAzContext = Get-AzContext -ListAvailable if (-not $allAzContext) { Stop-PSFFunction -String 'Initialize-AzOpsEnvironment.AzureContext.No' -EnableException $true -Cmdlet $PSCmdlet } $azContextTenants = @($AllAzContext.Tenant.Id | Sort-Object -Unique) if (-not $IgnoreContextCheck -and $azContextTenants.Count -gt 1) { Stop-PSFFunction -String 'Initialize-AzOpsEnvironment.AzureContext.TooMany' -StringValues $azContextTenants.Count, ($azContextTenants -join ',') -EnableException $true -Cmdlet $PSCmdlet } # Adjust MultipleTemplateParameterFileSuffix if incorrect MultipleTemplateParameterFileSuffix is set and log warning if (-not $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').StartsWith('.')) { $updateMultipleTemplateParameterFileSuffix = ".$(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix')" Write-AzOpsMessage -LogLevel Warning -LogString 'Initialize-AzOpsEnvironment.MultipleTemplateParameterFileSuffix.Adjustment' -LogStringValues (Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix'), $updateMultipleTemplateParameterFileSuffix Set-PSFConfig -Module AzOps -Name Core.MultipleTemplateParameterFileSuffix -Value $updateMultipleTemplateParameterFileSuffix } # Adjust ThrottleLimit from previously default 10 to 5 if system has less than 2 cores [int]$cpuCores = if ($IsWindows) { $env:NUMBER_OF_PROCESSORS } else { Invoke-AzOpsNativeCommand -ScriptBlock { nproc --all } -IgnoreExitcode } $throttleLimit = (Get-PSFConfig -Module AzOps -Name Core.ThrottleLimit).Value if (-not[string]::IsNullOrEmpty($cpuCores) -and $cpuCores -le 2 -and $throttleLimit -gt 5) { Write-AzOpsMessage -LogLevel Warning -LogString 'Initialize-AzOpsEnvironment.ThrottleLimit.Adjustment' -LogStringValues $throttleLimit, $cpuCores Set-PSFConfig -Module AzOps -Name Core.ThrottleLimit -Value 5 } # Validate optional custom path for custom jq template if ((Get-PSFConfig -Module AzOps -Name Core.SkipCustomJqTemplate).Value) { Write-AzOpsMessage -LogLevel Debug -LogString 'Initialize-AzOpsEnvironment.SkipCustomJqTemplate.True' } else { $customJqTemplatePath = (Get-PSFConfig -Module AzOps -Name Core.CustomJqTemplatePath).Value if (Test-Path -Path $customJqTemplatePath) { Write-AzOpsMessage -LogLevel Debug -LogString 'Initialize-AzOpsEnvironment.CustomJqTemplatePath' -LogStringValues $customJqTemplatePath } else { Write-AzOpsMessage -LogLevel Warning -LogString 'Initialize-AzOpsEnvironment.CustomJqTemplatePath.PathNotFound' -LogStringValues $customJqTemplatePath Set-PSFConfig -Module AzOps -Name Core.SkipCustomJqTemplate -Value $true } } } process { # If data exists and we don't want to rebuild the data cache, no point in continuing if (-not $InvalidateCache -and $script:AzOpsAzManagementGroup -and $script:AzOpsSubscriptions) { Write-AzOpsMessage -LogLevel Important -LogString 'Initialize-AzOpsEnvironment.UsingCache' return } #region Initialize & Prepare Write-AzOpsMessage -LogLevel Important -LogString 'Initialize-AzOpsEnvironment.Processing' $currentAzContext = Get-AzContext $tenantId = $currentAzContext.Tenant.Id Write-AzOpsMessage -LogLevel Important -LogString 'Initialize-AzOpsEnvironment.Initializing' if (-not (Test-Path -Path (Get-PSFConfigValue -FullName 'AzOps.Core.State'))) { $null = New-Item -path (Get-PSFConfigValue -FullName 'AzOps.Core.State') -Force -ItemType directory } $script:AzOpsSubscriptions = Get-AzOpsSubscription -ExcludedOffers $ExcludedSubOffer -ExcludedStates $ExcludedSubState -TenantId $tenantId $script:AzOpsResourceProvider = Get-AzResourceProvider -ListAvailable $script:AzOpsAzManagementGroup = @() $script:AzOpsPartialRoot = @() #endregion Initialize & Prepare #region Management Group Processing try { $managementGroups = Get-AzManagementGroup -ErrorAction Stop } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'Initialize-AzOpsEnvironment.ManagementGroup.NoManagementGroupAccess' -LogStringValues $_ return } #region Validate root '/' permissions - different methods of getting current context depending on principalType try { $currentPrincipal = Get-AzOpsCurrentPrincipal -AzContext $currentAzContext -ErrorAction Stop } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'Initialize-AzOpsEnvironment.CurrentPrincipal.Fail' -LogStringValues $_ } if ($currentPrincipal.id) { try { $rootPermissions = Get-AzRoleAssignment -ObjectId $currentPrincipal.id -Scope "/" -ErrorAction Stop } catch { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Initialize-AzOpsEnvironment.CurrentPrincipal.RoleAssignmentFail' -LogStringValues $_ } } if (-not $rootPermissions) { Write-AzOpsMessage -LogLevel Important -LogString 'Initialize-AzOpsEnvironment.ManagementGroup.NoRootPermissions' -LogStringValues $currentAzContext.Account.Id $PartialMgDiscovery = $true } else { $PartialMgDiscovery = $false } #endregion Validate root '/' permissions #region Partial Discovery if ($PartialMgDiscoveryRoot) { Write-AzOpsMessage -LogLevel Important -LogString 'Initialize-AzOpsEnvironment.ManagementGroup.PartialDiscovery' $PartialMgDiscovery = $true $managementGroups = @() foreach ($managementRoot in $PartialMgDiscoveryRoot) { $managementGroups += [PSCustomObject]@{ Name = $managementRoot } $script:AzOpsPartialRoot += Get-AzManagementGroup -GroupId $managementRoot -Recurse -Expand -WarningAction SilentlyContinue } } #endregion Partial Discovery #region Management Group Resolution Write-AzOpsMessage -LogLevel Important -LogString 'Initialize-AzOpsEnvironment.ManagementGroup.Resolution' -LogStringValues $managementGroups.Count -Metric $managementGroups.Count -MetricName 'ManagementGroup Count' $tempResolved = foreach ($mgmtGroup in $managementGroups) { Write-AzOpsMessage -LogLevel Verbose -LogString 'Initialize-AzOpsEnvironment.ManagementGroup.Expanding' -LogStringValues $mgmtGroup.Name Get-AzOpsManagementGroup -ManagementGroup $mgmtGroup.Name -PartialDiscovery:$PartialMgDiscovery } $script:AzOpsAzManagementGroup = $tempResolved | Sort-Object -Property Id -Unique #endregion Management Group Resolution #endregion Management Group Processing Write-AzOpsMessage -LogLevel Important -LogString 'Initialize-AzOpsEnvironment.Processing.Completed' Clear-PSFMessage } } function Invoke-AzOpsPull { <# .SYNOPSIS Setup a repository for the AzOps workflow, based off templates and an existing Azure deployment. .DESCRIPTION Setup a repository for the AzOps workflow, based off templates and an existing Azure deployment. .PARAMETER IncludeResourcesInResourceGroup Discover only resources in these resource groups. .PARAMETER IncludeResourceType Discover only specific resource types. .PARAMETER SkipChildResource Skip childResource discovery. .PARAMETER SkipPim Skip discovery of Privileged Identity Management resources. .PARAMETER SkipLock Skip discovery of resource lock resources. .PARAMETER SkipPolicy Skip discovery of policies. .PARAMETER SkipRole Skip discovery of role. .PARAMETER SkipResourceGroup Skip discovery of resource groups .PARAMETER SkipResource Skip discovery of resources inside resource groups. .PARAMETER Rebuild Delete all AutoGeneratedTemplateFolderPath folders inside AzOpsState directory. .PARAMETER Force Delete $script:AzOpsState directory. .PARAMETER StatePath The root folder under which to write the resource json. .EXAMPLE > Invoke-AzOpsPull Setup a repository for the AzOps workflow, based off templates and an existing Azure deployment. #> [CmdletBinding()] [Alias("Initialize-AzOpsRepository")] param ( [string[]] $IncludeResourcesInResourceGroup = (Get-PSFConfigValue -FullName 'AzOps.Core.IncludeResourcesInResourceGroup'), [string[]] $IncludeResourceType = (Get-PSFConfigValue -FullName 'AzOps.Core.IncludeResourceType'), [switch] $SkipChildResource = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipChildResource'), [switch] $SkipPim = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipPim'), [switch] $SkipLock = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipLock'), [switch] $SkipPolicy = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipPolicy'), [switch] $SkipResource = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipResource'), [switch] $SkipResourceGroup = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipResourceGroup'), [string[]] $SkipResourceType = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipResourceType'), [switch] $SkipRole = (Get-PSFConfigValue -FullName 'AzOps.Core.SkipRole'), [switch] $Rebuild, [switch] $Force, [string] $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State'), [string] $TemplateParameterFileSuffix = (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix') ) begin { #region Prepare if (-not $SkipPim) { try { Write-AzOpsMessage -LogLevel Verbose -LogString 'Invoke-AzOpsPull.Validating.UserRole' $null = Get-AzADUser -First 1 -ErrorAction Stop Write-AzOpsMessage -LogLevel Verbose -LogString 'Invoke-AzOpsPull.Validating.UserRole.Success' Write-AzOpsMessage -LogLevel Verbose -LogString 'Invoke-AzOpsPull.Validating.AADP2' $servicePlanName = "AAD_PREMIUM_P2" $subscribedSkus = Invoke-AzRestMethod -Uri https://graph.microsoft.com/v1.0/subscribedSkus -ErrorAction Stop $subscribedSkusValue = $subscribedSkus.Content | ConvertFrom-Json -Depth 100 | Select-Object value if ($servicePlanName -in $subscribedSkusValue.value.servicePlans.servicePlanName) { Write-AzOpsMessage -LogLevel Verbose -LogString 'Invoke-AzOpsPull.Validating.AADP2.Success' } else { Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPull.Validating.AADP2.Failed' $SkipPim = $true } } catch { Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPull.Validating.UserRole.Failed' $SkipPim = $true } } if ($false -eq $SkipChildResource -or $false -eq $SkipResource -and $true -eq $SkipResourceGroup) { Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPull.Validating.ResourceGroupDiscovery.Failed' -LogStringValues "`n" } $resourceTypeDiff = Compare-Object -ReferenceObject $SkipResourceType -DifferenceObject $IncludeResourceType -ExcludeDifferent if ($resourceTypeDiff) { Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPull.SkipResourceType.Failed' -LogStringValues $($resourceTypeDiff.InputObject) $IncludeResourceType = $IncludeResourceType | Where-Object { $_ -notin $resourceTypeDiff.InputObject } } Assert-AzOpsInitialization -Cmdlet $PSCmdlet -StatePath $StatePath $tenantId = (Get-AzContext).Tenant.Id Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPull.Tenant' -LogStringValues $tenantId Write-AzOpsMessage -LogLevel Verbose -LogString 'Invoke-AzOpsPull.TemplateParameterFileSuffix' -LogStringValues (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix') Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPull.Initialization.Completed' $stopWatch = [System.Diagnostics.Stopwatch]::StartNew() #endregion Initialize & Prepare } process { #region Existing Content if (Test-Path $StatePath) { $migrationRequired = (Get-ChildItem -Recurse -Force -Path $StatePath -File | Where-Object { $_.Name -like $("Microsoft.Management_managementGroups-" + $tenantId + $TemplateParameterFileSuffix) } | Select-Object -ExpandProperty FullName -First 1) -notmatch '\((.*)\)' if ($migrationRequired) { Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPull.Migration.Required' } if ($Force -or $migrationRequired) { Invoke-PSFProtectedCommand -ActionString 'Invoke-AzOpsPull.Deleting.State' -ActionStringValues $StatePath -Target $StatePath -ScriptBlock { Remove-Item -Path $StatePath -Recurse -Force -Confirm:$false -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet } if ($Rebuild) { Invoke-PSFProtectedCommand -ActionString 'Invoke-AzOpsPull.Rebuilding.State' -ActionStringValues $StatePath -Target $StatePath -ScriptBlock { Get-ChildItem -Path $StatePath -File -Recurse -Force -Filter 'Microsoft.*_*.json' | Remove-Item -Force -Recurse -Confirm:$false -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet } } #endregion Existing Content #region Root Scopes $rootScope = '/providers/Microsoft.Management/managementGroups/{0}' -f $tenantId if ($script:AzOpsPartialRoot.id) { $rootScope = $script:AzOpsPartialRoot.id | Sort-Object -Unique } # Parameters $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Inherit -Include IncludeResourcesInResourceGroup, IncludeResourceType, SkipPim, SkipLock, SkipPolicy, SkipRole, SkipResourceGroup, SkipChildResource, SkipResource, SkipResourceType, StatePath Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPull.Building.State' -LogStringValues $StatePath if ($rootScope -and $script:AzOpsAzManagementGroup) { foreach ($root in $rootScope) { # Create AzOpsState structure recursively Save-AzOpsManagementGroupChild -Scope $root -StatePath $StatePath Get-AzOpsResourceDefinition -Scope $root @parameters } } else { # If no management groups are found, iterate through each subscription foreach ($subscription in $script:AzOpsSubscriptions) { ConvertTo-AzOpsState -Resource (Get-AzSubscription -SubscriptionId $subscription.subscriptionId) -StatePath $StatePath Get-AzOpsResourceDefinition -Scope $subscription.id @parameters } } #endregion Root Scopes } end { $stopWatch.Stop() Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPull.Duration' -LogStringValues $stopWatch.Elapsed -Metric $stopWatch.Elapsed.TotalSeconds -MetricName 'AzOpsPull Time' Clear-PSFMessage } } function Invoke-AzOpsPush { <# .SYNOPSIS Applies a change to Azure from the AzOps configuration. .DESCRIPTION Applies a change to Azure from the AzOps configuration. .PARAMETER ChangeSet Set of changes from the last execution that need to be applied. .PARAMETER DeleteSetContents Set of content from the deleted files in ChangeSet. .PARAMETER StatePath The root path to where the entire state is being built in. .PARAMETER AzOpsMainTemplate Path to the main template used by AzOps .PARAMETER CustomSortOrder Switch to honor the input ordering for ChangeSet. If not used, ChangeSet will be sorted in ascending order. .EXAMPLE > Invoke-AzOpsPush -ChangeSet changeSet -StatePath $StatePath -AzOpsMainTemplate $templatePath Applies a change to Azure from the AzOps configuration. #> [CmdletBinding(SupportsShouldProcess = $true)] [Alias("Invoke-AzOpsChange")] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string[]] $ChangeSet, [Parameter(Mandatory = $false, ValueFromPipeline = $true)] [string[]] $DeleteSetContents, [string] $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State'), [string] $AzOpsMainTemplate = (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate'), [switch] $CustomSortOrder ) begin { #region Utility Functions function New-AzOpsList { [CmdletBinding()] param ( [string[]] $FileSet, [string] $FilePath, [string] $AzOpsMainTemplate, [string[]] $ConvertedTemplate, [string[]] $ConvertedParameter, [switch] $CompareDeploymentToDeletion ) # Avoid adding files destined for deletion to a deployment list if ($CompareDeploymentToDeletion) { if ($FilePath -in $deleteSet -or $FilePath -in ($deleteSet | Resolve-Path).Path) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeployDeletionOverlap' -LogStringValues $FilePath continue } } # Avoid duplicate entries in the deployment list if ($FilePath.EndsWith(".parameters.json")) { if ($FileSet -contains $FilePath.Replace(".parameters.json", ".json") -or $FileSet -contains $FilePath.Replace(".parameters.json", ".bicep")) { continue } } if ($FilePath.EndsWith(".bicepparam")) { if ($FileSet -contains $FilePath.Replace(".bicepparam", ".bicep")) { continue } } # Handle Bicep templates if ($FilePath.EndsWith(".bicep")) { $transpiledTemplatePaths = ConvertFrom-AzOpsBicepTemplate -BicepTemplatePath $FilePath -ConvertedTemplate $ConvertedTemplate -ConvertedParameter $ConvertedParameter -CompareDeploymentToDeletion:$CompareDeploymentToDeletion if ($true -eq $transpiledTemplatePaths.transpiledTemplateNew) { $ConvertedTemplate += $transpiledTemplatePaths.transpiledTemplatePath } if ($true -eq $transpiledTemplatePaths.transpiledParametersNew) { $ConvertedParameter += $transpiledTemplatePaths.transpiledParametersPath } $FilePath = $transpiledTemplatePaths.transpiledTemplatePath } try { # Create scope object from the given file path $scopeObject = New-AzOpsScope -Path $FilePath -StatePath $StatePath -ErrorAction Stop } catch { # Log a warning message if creating the scope object fails Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPush.Scope.Failed' -LogStringValues $FilePath -Target $FilePath -ErrorRecord $_ continue } # Resolve ARM file association $resolvedArmFileAssociation = Resolve-ArmFileAssociation -ScopeObject $scopeObject -FilePath $FilePath -AzOpsMainTemplate $AzOpsMainTemplate -ConvertedTemplate $ConvertedTemplate -ConvertedParameter $ConvertedParameter -CompareDeploymentToDeletion:$CompareDeploymentToDeletion if ($resolvedArmFileAssociation) { foreach ($fileAssociation in $resolvedArmFileAssociation) { if ($true -eq $transpiledTemplatePaths.transpiledTemplateNew -and $fileAssociation.TemplateFilePath -eq $transpiledTemplatePaths.transpiledTemplatePath) { $fileAssociation.TranspiledTemplateNew = $true } if ($true -eq $transpiledTemplatePaths.TranspiledParametersNew -and $fileAssociation.TemplateParameterFilePath -eq $transpiledTemplatePaths.transpiledParametersPath) { $fileAssociation.TranspiledParametersNew = $true } } return $resolvedArmFileAssociation } } function Resolve-ArmFileAssociation { [CmdletBinding()] param ( [AzOpsScope] $ScopeObject, [string] $FilePath, [string] $AzOpsMainTemplate, [string[]] $ConvertedTemplate, [string[]] $ConvertedParameter, [switch] $CompareDeploymentToDeletion ) #region Initialization Prep $result = [PSCustomObject] @{ TemplateFilePath = $null TranspiledTemplateNew = $false TemplateParameterFilePath = $null TranspiledParametersNew = $false DeploymentName = $null ScopeObject = $ScopeObject Scope = $ScopeObject.Scope } $fileItem = Get-Item -Path $FilePath if ($fileItem.Extension -notin '.json' , '.bicep', '.bicepparam') { Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPush.Resolve.NoJson' -LogStringValues $fileItem.FullName -Target $ScopeObject return } # Generate deterministic id for DefaultDeploymentRegion to overcome deployment issues when changing DefaultDeploymentRegion $deploymentRegionId = (Get-FileHash -Algorithm SHA256 -InputStream ([IO.MemoryStream]::new([byte[]][char[]](Get-PSFConfigValue -FullName 'AzOps.Core.DefaultDeploymentRegion')))).Hash.Substring(0, 4) #endregion Initialization Prep #region Case: Parameters File if (($fileItem.Name.EndsWith('.parameters.json')) -or ($fileItem.Name.EndsWith('.bicepparam'))) { $result.TemplateParameterFilePath = $fileItem.FullName $deploymentName = $fileItem.Name -replace "\.parameters\$(Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix')$", '' -replace ' ', '_' -replace '\.bicepparam$', '' if ($deploymentName.Length -gt 53) { $deploymentName = $deploymentName.SubString(0, 53) } $result.DeploymentName = 'AzOps-{0}-{1}' -f $deploymentName, $deploymentRegionId #region Directly Associated Template file exists switch ($fileItem.Name) { { $_.EndsWith('.parameters.json') } { if ((Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $true -and $fileItem.FullName.Split('.')[-3] -match $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').Replace('.','')) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.MultipleTemplateParameterFile' -LogStringValues $FilePath $templatePath = $fileItem.FullName -replace "\.$($fileItem.FullName.Split('.')[-3])\.parameters\.json$", '.json' $bicepTemplatePath = $fileItem.FullName -replace "\.$($fileItem.FullName.Split('.')[-3])\.parameters\.json$", '.bicep' } else { $templatePath = $fileItem.FullName -replace '\.parameters\.json$', (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix') $bicepTemplatePath = $fileItem.FullName -replace '\.parameters\.json$', '.bicep' } if (Test-Path $templatePath) { if ($CompareDeploymentToDeletion) { # Avoid adding files destined for deletion to a deployment list if ($templatePath -in $deleteSet -or $templatePath -in ($deleteSet | Resolve-Path).Path) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeployDeletionOverlap' -LogStringValues $templatePath return } } Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.FoundTemplate' -LogStringValues $FilePath, $templatePath $result.TemplateFilePath = $templatePath $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope return $result } elseif (Test-Path $bicepTemplatePath) { if ($CompareDeploymentToDeletion) { # Avoid adding files destined for deletion to a deployment list if ($bicepTemplatePath -in $deleteSet -or $bicepTemplatePath -in ($deleteSet | Resolve-Path).Path) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeployDeletionOverlap' -LogStringValues $bicepTemplatePath return } } Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.FoundBicepTemplate' -LogStringValues $FilePath, $bicepTemplatePath $transpiledTemplatePaths = ConvertFrom-AzOpsBicepTemplate -BicepTemplatePath $bicepTemplatePath -SkipParam -ConvertedTemplate $ConvertedTemplate -CompareDeploymentToDeletion:$CompareDeploymentToDeletion $result.TranspiledTemplateNew = $transpiledTemplatePaths.transpiledTemplateNew $result.TemplateFilePath = $transpiledTemplatePaths.transpiledTemplatePath $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope return $result } } { $_.EndsWith('.bicepparam') } { if ((Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $true -and $fileItem.FullName.Split('.')[-2] -match $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').Replace('.','')) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.MultipleTemplateParameterFile' -LogStringValues $FilePath $bicepTemplatePath = $fileItem.FullName -replace "\.$($fileItem.FullName.Split('.')[-2])\.bicepparam$", '.bicep' } else { $bicepTemplatePath = $fileItem.FullName -replace '\.bicepparam$', '.bicep' } if (Test-Path $bicepTemplatePath) { if ($CompareDeploymentToDeletion) { # Avoid adding files destined for deletion to a deployment list if ($bicepTemplatePath -in $deleteSet -or $bicepTemplatePath -in ($deleteSet | Resolve-Path).Path) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeployDeletionOverlap' -LogStringValues $bicepTemplatePath return } } Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.FoundBicepTemplate' -LogStringValues $FilePath, $bicepTemplatePath $transpiledTemplatePaths = ConvertFrom-AzOpsBicepTemplate -BicepTemplatePath $bicepTemplatePath -BicepParamTemplatePath $fileItem.FullName -ConvertedTemplate $ConvertedTemplate -ConvertedParameter $ConvertedParameter -CompareDeploymentToDeletion:$CompareDeploymentToDeletion $result.TranspiledTemplateNew = $transpiledTemplatePaths.transpiledTemplateNew $result.TranspiledParametersNew = $transpiledTemplatePaths.transpiledParametersNew $result.TemplateFilePath = $transpiledTemplatePaths.transpiledTemplatePath $result.TemplateParameterFilePath = $transpiledTemplatePaths.transpiledParametersPath $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope return $result } } } #endregion Directly Associated Template file exists #region Check in the main template file for a match Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Resolve.NotFoundTemplate' -LogStringValues $FilePath, $templatePath $mainTemplateItem = Get-Item $AzOpsMainTemplate Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.FromMainTemplate' -LogStringValues $mainTemplateItem.FullName # Determine Resource Type in Parameter file $templateParameterFileHashtable = Get-Content -Path $fileItem.FullName | ConvertFrom-Json -AsHashtable $effectiveResourceType = $null if ($templateParameterFileHashtable.Keys -contains "`$schema") { if ($templateParameterFileHashtable.parameters.input.value.Keys -ccontains "Type") { # ManagementGroup and Subscription $effectiveResourceType = $templateParameterFileHashtable.parameters.input.value.Type } elseif ($templateParameterFileHashtable.parameters.input.value.Keys -ccontains "type") { # ManagementGroup and Subscription $effectiveResourceType = $templateParameterFileHashtable.parameters.input.value.type } elseif ($templateParameterFileHashtable.parameters.input.value.Keys -contains "ResourceType") { # Resource $effectiveResourceType = $templateParameterFileHashtable.parameters.input.value.ResourceType } } # Check if generic template is supporting the resource type for the deployment. if ($effectiveResourceType -and (Get-Content $mainTemplateItem.FullName | ConvertFrom-Json -AsHashtable).variables.apiVersionLookup.Keys -contains $effectiveResourceType) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.MainTemplate.Supported' -LogStringValues $effectiveResourceType, $mainTemplateItem.FullName $result.TemplateFilePath = $mainTemplateItem.FullName return $result } Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPush.Resolve.MainTemplate.NotSupported' -LogStringValues $effectiveResourceType, $mainTemplateItem.FullName -Target $ScopeObject return #endregion Check in the main template file for a match # All Code paths end the command } #endregion Case: Parameters File #region Case: Template File $result.TemplateFilePath = $fileItem.FullName $parameterPath = Join-Path $fileItem.Directory.FullName -ChildPath ($fileItem.BaseName + '.parameters' + (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix')) if (Test-Path -Path $parameterPath) { if ($CompareDeploymentToDeletion) { # Avoid adding files destined for deletion to a deployment list if ($parameterPath -in $deleteSet -or $parameterPath -in ($deleteSet | Resolve-Path).Path) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeployDeletionOverlap' -LogStringValues $parameterPath $skipParameters = $true } } else { $skipParameters = $false } if (-not $skipParameters) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.ParameterFound' -LogStringValues $FilePath, $parameterPath $result.TemplateParameterFilePath = $parameterPath } } elseif ((Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $true -and (Get-PSFConfigValue -FullName 'AzOps.Core.DeployAllMultipleTemplateParameterFiles') -eq $true) { # Check for multiple associated template parameter files $paramFileList = Get-ChildItem -Path $fileItem.Directory | Where-Object { ($_.Name.Split('.')[-3] -match $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').Replace('.','')) -or ($_.Name.Split('.')[-2] -match $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').Replace('.','')) } if ($paramFileList) { $multiResult = @() foreach ($paramFile in $paramFileList) { # Check if the parameter file's name matches the expected pattern $escapedBaseName = $fileItem.BaseName -replace '\.', '\.' if ($paramFile.BaseName -match "^$escapedBaseName(\$(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix'))") { if ($CompareDeploymentToDeletion) { # Avoid adding files destined for deletion to a deployment list if ($paramFile.VersionInfo.FileName -in $deleteSet -or $paramFile.VersionInfo.FileName -in ($deleteSet | Resolve-Path).Path) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeployDeletionOverlap' -LogStringValues $paramFile.VersionInfo.FileName continue } } # Process parameter files for template equivalent if (($fileItem.FullName.Split('.')[-2] -eq $paramFile.FullName.Split('.')[-3]) -or ($fileItem.FullName.Split('.')[-2] -eq $paramFile.FullName.Split('.')[-4])) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.MultipleTemplateParameterFile' -LogStringValues $paramFile.FullName $multiResult += Resolve-ArmFileAssociation -ScopeObject $scopeObject -FilePath $paramFile -AzOpsMainTemplate $AzOpsMainTemplate -ConvertedTemplate $ConvertedTemplate -ConvertedParameter $ConvertedParameter -CompareDeploymentToDeletion:$CompareDeploymentToDeletion } } } if ($multiResult) { # Return completed object return $multiResult } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.ParameterNotFound' -LogStringValues $FilePath, $parameterPath if (-not (Test-TemplateDefaultParameter -FilePath $FilePath)) { continue } } } } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.ParameterNotFound' -LogStringValues $FilePath, $parameterPath if ((Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $true) { if (-not (Test-TemplateDefaultParameter -FilePath $FilePath)) { continue } } } $deploymentName = $fileItem.BaseName -replace '\.json$' -replace ' ', '_' if ($deploymentName.Length -gt 53) { $deploymentName = $deploymentName.SubString(0, 53) } $result.DeploymentName = 'AzOps-{0}-{1}' -f $deploymentName, $deploymentRegionId $result #endregion Case: Template File } function Test-TemplateDefaultParameter { param( [string]$FilePath ) # Read the template file $defaultValueContent = Get-Content $FilePath # Check for parameters without a default value using jq $missingDefaultParam = $defaultValueContent | jq '.parameters | with_entries(select(.value.defaultValue == null))' | ConvertFrom-Json -AsHashtable if ($missingDefaultParam.Count -ge 1) { # Skip template deployment when template parameters without defaultValue are found and no parameter file identified $missingString = foreach ($item in $missingDefaultParam.Keys.GetEnumerator()) {"$item,"} # Log a debug message with the missing parameters Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.NotFoundParamFileDefaultValue' -LogStringValues $FilePath, ($missingString | Out-String -NoNewline) # Missing default value were found return $false } else { # Default values found return $true } } #endregion Utility Functions $WhatIfPreferenceState = $WhatIfPreference $WhatIfPreference = $false # Create array of strings to track bicep file conversion [string[]] $AzOpsTranspiledTemplate = @() [string[]] $AzOpsTranspiledParameter = @() # Remove lingering files from previous run $tempPath = [System.IO.Path]::GetTempPath() if ((Test-Path -Path ($tempPath + 'OUTPUT.md')) -or (Test-Path -Path ($tempPath + 'OUTPUT.json'))) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile.Remove' Remove-Item -Path ($tempPath + 'OUTPUT.md') -Force -ErrorAction SilentlyContinue Remove-Item -Path ($tempPath + 'OUTPUT.json') -Force -ErrorAction SilentlyContinue } $stopWatch = [System.Diagnostics.Stopwatch]::StartNew() } process { if (-not $ChangeSet) { return } Assert-AzOpsInitialization -Cmdlet $PSCmdlet -StatePath $StatePath #region Categorize Input Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Deployment.Required' $deleteSet = @() $addModifySet = foreach ($change in $ChangeSet) { $operation, $filename = ($change -split "`t")[0, -1] if ($operation -eq 'D') { $deleteSet += $filename continue } if ($operation -in 'A', 'M') { $filename } elseif ($operation -match '^R[0-9][0-9][0-9]$') { $operation, $oldFileLocation, $newFileLocation = ($change -split "`t")[0, 1, 2] if (-not ((Split-Path -Path $oldFileLocation) -eq (Split-Path -Path $newFileLocation))) { $deleteSet += $oldFileLocation } $newFileLocation } } if ($deleteSet -and -not $CustomSortOrder) { $deleteSet = $deleteSet | Sort-Object } if ($addModifySet -and -not $CustomSortOrder) { $addModifySet = $addModifySet | Sort-Object } if ($addModifySet) { Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Change.AddModify' foreach ($item in $addModifySet) { Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Change.AddModify.File'-LogStringValues $item } } if ($DeleteSetContents -and $deleteSet) { Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Change.Delete' # Count if $DeleteSetContents contains 1 or less if ($DeleteSetContents.Count -le 1) { # DeleteSetContents has no file content or is malformed Write-AzOpsMessage -LogLevel Error -LogString 'Invoke-AzOpsPush.Change.Delete.DeleteSetContents' -LogStringValues $DeleteSetContents } else { # Iterate through each line in $DeleteSetContents for ($i = 0; $i -lt $DeleteSetContents.Count; $i++) { $line = $DeleteSetContents[$i].Trim() # Check if the line starts with '-- ' and matches any filename in $deleteSet if ($line -match '^-- (.+)$') { $fileName = $matches[1] if ($deleteSet -contains $fileName) { Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Change.Delete.File' -LogStringValues $fileName # Collect lines until the next line starting with '--' $objectLines = @($line) $i++ while ($i -lt $DeleteSetContents.Count) { $currentLine = $DeleteSetContents[$i].Trim() # Check if the line starts with '-- ' followed by any filename in $deleteSet if ($currentLine -match '^-- (.+)$' -and $deleteSet -contains $matches[1]) { $i-- Write-AzOpsMessage -LogLevel InternalComment -LogString 'Invoke-AzOpsPush.Change.Delete.NextTempFile' -LogStringValues $currentLine break # Exit the loop if the line starts with '-- ' and matches a filename in $deleteSet } $objectLines += $currentLine $i++ } # When processed as designed there is no file present in the running branch. # To run a removal AzOps re-creates the file and content based on $DeleteSetContents momentarily for processing, it is disregarded afterwards. if (-not(Test-Path -Path (Split-Path -Path $fileName))) { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Invoke-AzOpsPush.Change.Delete.TempFile' -LogStringValues $fileName New-Item -Path (Split-Path -Path $fileName) -ItemType Directory | Out-Null } # Create $fileName and set $content $objectLines = $objectLines[1..$objectLines.Count] $content = $objectLines.replace("-- $fileName", "") -join "`r`n" Write-AzOpsMessage -LogLevel InternalComment -LogString 'Invoke-AzOpsPush.Change.Delete.SetTempFileContent' -LogStringValues $fileName, $content Set-Content -Path $fileName -Value $content $i-- # Move back one step to process the next line properly } } } } } #endregion Categorize Input #region Deploy State # Nested Pipeline allows economizing on New-AzOpsStateDeployment having to run its "begin" block once only $newStateDeploymentCmd = { New-AzOpsStateDeployment -StatePath $StatePath }.GetSteppablePipeline() $newStateDeploymentCmd.Begin($true) foreach ($addition in $addModifySet) { if ($addition -notmatch '/*.subscription.json$') { continue } Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Deploy.Subscription' -LogStringValues $addition -Target $addition $newStateDeploymentCmd.Process($addition) } foreach ($addition in $addModifySet) { if ($addition -notmatch '/*.providerfeatures.json$') { continue } Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Deploy.ProviderFeature' -LogStringValues $addition -Target $addition $newStateDeploymentCmd.Process($addition) } foreach ($addition in $addModifySet) { if ($addition -notmatch '/*.resourceproviders.json$') { continue } Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Deploy.ResourceProvider' -LogStringValues $addition -Target $addition $newStateDeploymentCmd.Process($addition) } $newStateDeploymentCmd.End() #endregion Deploy State #region Create DeploymentList $deploymentList = foreach ($addition in $addModifySet | Where-Object { $_ -match ((Get-Item $StatePath).Name) }) { # Create a list of deployment file associations using the New-AzOpsList function $deployFileAssociationList = New-AzOpsList -FilePath $addition -FileSet $addModifySet -AzOpsMainTemplate $AzOpsMainTemplate -ConvertedTemplate $AzOpsTranspiledTemplate -ConvertedParameter $AzOpsTranspiledParameter -CompareDeploymentToDeletion # Iterate through each file association in the list foreach ($fileAssociation in $deployFileAssociationList) { # Check if the transpiled template is new and add it to the collection if true if ($true -eq $fileAssociation.transpiledTemplateNew) { $AzOpsTranspiledTemplate += $fileAssociation.TemplateFilePath } # Check if the transpiled parameters are new and add them to the collection if true if ($true -eq $fileAssociation.transpiledParametersNew) { $AzOpsTranspiledParameter += $fileAssociation.TemplateParameterFilePath } } # Output the list of file associations for the current addition $deployFileAssociationList } #endregion Create DeploymentList #region Create DeletionList $deletionList = foreach ($deletion in $deleteSet | Where-Object { $_ -match ((Get-Item $StatePath).Name) }) { # Create a list of deletion file associations using the New-AzOpsList function $deletionFileAssociationList = New-AzOpsList -FilePath $deletion -FileSet $deleteSet -AzOpsMainTemplate $AzOpsMainTemplate -ConvertedTemplate $AzOpsTranspiledTemplate -ConvertedParameter $AzOpsTranspiledParameter # Iterate through each file association in the list foreach ($fileAssociation in $deletionFileAssociationList) { # Check if the transpiled template is new and add it to the collection if true if ($true -eq $fileAssociation.transpiledTemplateNew) { $AzOpsTranspiledTemplate += $fileAssociation.TemplateFilePath } # Check if the transpiled parameters are new and add them to the collection if true if ($true -eq $fileAssociation.transpiledParametersNew) { $AzOpsTranspiledParameter += $fileAssociation.TemplateParameterFilePath } } # Output the list of file associations for the current deletion $deletionFileAssociationList } #endregion Create DeletionList #If addModifySet exists and no deploymentList has been generated at the same time as the StatePath root has additional directories and AllowMultipleTemplateParameterFiles is default false, exit with terminating error if (($addModifySet -and -not $deploymentList) -and (Get-ChildItem -Path $StatePath -Directory) -and ((Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $false)) { Write-AzOpsMessage -LogLevel Critical -LogString 'Invoke-AzOpsPush.DeploymentList.NotFound' throw } #Starting deployment $WhatIfPreference = $WhatIfPreferenceState $uniqueProperties = 'Scope', 'DeploymentName', 'TemplateFilePath', 'TemplateParameterFilePath' $uniqueDeployment = $deploymentList | Select-Object $uniqueProperties -Unique | ForEach-Object { $TemplateFileContent = [System.IO.File]::ReadAllText($_.TemplateFilePath) $TemplateObject = ConvertFrom-Json $TemplateFileContent -AsHashtable $_ | Add-Member -MemberType NoteProperty -Name 'TemplateObject' -Value $TemplateObject -PassThru } $deploymentResult = @() if ($uniqueDeployment) { #Determine what deployment pattern to adopt serial or parallel if ((Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $true -and (Get-PSFConfigValue -FullName 'AzOps.Core.ParallelDeployMultipleTemplateParameterFiles') -eq $true) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.ParallelCondition' # Group deployments based on TemplateFilePath $groups = $uniqueDeployment | Group-Object -Property TemplateFilePath | Where-Object { $_.Count -ge '2' -and $_.Name -ne $(Get-Item $AzOpsMainTemplate).FullName } if ($groups) { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.ParallelGroup' $processedTargets = @() # Process each deployment and evaluate serial or parallel deployment pattern foreach ($deployment in $uniqueDeployment) { if ($deployment.TemplateFilePath -in $groups.Name -and $deployment -notin $processedTargets) { # Deployment part of group association for parallel processing, process entire group as parallel deployment $targets = $($groups | Where-Object { $_.Name -eq $deployment.TemplateFilePath }).Group Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Parallel' -LogStringValues $deployment.TemplateFilePath, $targets.Count # Prepare Input Data for parallel processing $runspaceData = @{ AzOpsPath = "$($script:ModuleRoot)\AzOps.psd1" StatePath = $StatePath WhatIfPreference = $WhatIfPreference runspace_AzOpsAzManagementGroup = $script:AzOpsAzManagementGroup runspace_AzOpsSubscriptions = $script:AzOpsSubscriptions runspace_AzOpsPartialRoot = $script:AzOpsPartialRoot runspace_AzOpsResourceProvider = $script:AzOpsResourceProvider } # Pass deployment targets for parallel processing and output deployment result for later $deploymentResult += $targets | Foreach-Object -ThrottleLimit (Get-PSFConfigValue -FullName 'AzOps.Core.ThrottleLimit') -Parallel { $deployment = $_ $runspaceData = $using:runspaceData Import-Module "$([PSFramework.PSFCore.PSFCoreHost]::ModuleRoot)/PSFramework.psd1" $azOps = Import-Module $runspaceData.AzOpsPath -Force -PassThru & $azOps { $script:AzOpsAzManagementGroup = $runspaceData.runspace_AzOpsAzManagementGroup $script:AzOpsSubscriptions = $runspaceData.runspace_AzOpsSubscriptions $script:AzOpsPartialRoot = $runspaceData.runspace_AzOpsPartialRoot $script:AzOpsResourceProvider = $runspaceData.runspace_AzOpsResourceProvider } & $azOps { $deployment | New-AzOpsDeployment -WhatIf:$runspaceData.WhatIfPreference } } -UseNewRunspace Clear-PSFMessage # Add targets to processed list to avoid duplicate deployment $processedTargets += $targets } elseif ($deployment -notin $processedTargets) { # Deployment not part of group association for parallel processing, process this as serial deployment Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Serial' -LogStringValues $deployment.Count $deploymentResult += $deployment | New-AzOpsDeployment -WhatIf:$WhatIfPreference } else { # Deployment already processed by group association from parallel processing, skip this duplicate deployment Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Skip' -LogStringValues $deployment.TemplateFilePath, $deployment.TemplateParameterFilePath } } } else { # No deployments with matching TemplateFilePath identified Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Serial' -LogStringValues $deployment.Count $deploymentResult += $uniqueDeployment | New-AzOpsDeployment -WhatIf:$WhatIfPreference } } else { # Perform serial deployment only Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Serial' -LogStringValues $uniqueDeployment.Count $deploymentResult += $uniqueDeployment | New-AzOpsDeployment -WhatIf:$WhatIfPreference } if ($deploymentResult) { # Output deploymentResult outside module $deploymentResult #Process deploymentResult and output result foreach ($result in $deploymentResult) { Set-AzOpsWhatIfOutput -FilePath $result.filePath -ParameterFilePath $result.parameterFilePath -Results $result.results } } } if ($deletionList) { #Removal of Supported resourceTypes and Custom Templates $deletionList = Set-AzOpsRemoveOrder -DeletionList $deletionList -Index { $_.ScopeObject.Resource } $removalJob = $deletionList | Select-Object $uniqueProperties -Unique | Remove-AzOpsDeployment -WhatIf:$WhatIfPreference -DeleteSet (Resolve-Path -Path $deleteSet).Path if ($removalJob.ScopeObject.Scope.Count -gt 0) { Clear-PSFMessage # Identify failed removal attempts for potential retries $retry = $removalJob | Where-Object { $_.Status -eq 'failed' } # If there are retries, log and attempt them again if ($retry) { Write-AzOpsMessage -LogLevel Verbose -LogString 'Invoke-AzOpsPush.Deletion.Retry' -LogStringValues $retry.Count Start-Sleep -Seconds 30 # Reset the status of failed attempts and perform recursive removal foreach ($try in $retry) { $try.Status = $null } $removeActionRecursive = Remove-AzResourceRaw -InputObject $retry -Recursive $removeActionFail = $removeActionRecursive | Where-Object { $_.Status -eq 'failed' } # If removal fails, log and attempt to fetch the resource causing the failure if ($removeActionFail) { Start-Sleep -Seconds 90 $throwFail = $false # Check each failed removal and attempt to get the associated resource foreach ($fail in $removeActionFail) { $resource = $null $resource = Get-AzOpsResource -ScopeObject $fail.ScopeObject -ErrorAction SilentlyContinue # If the resource is found, log the failure if ($resource) { $throwFail = $true Write-AzOpsMessage -LogLevel Critical -LogString 'Invoke-AzOpsPush.Deletion.Failed' -LogStringValues $fail.ScopeObject.Scope, $fail.TemplateFilePath, $fail.TemplateParameterFilePath } } # If any failures occurred, throw an exception if ($throwFail) { throw } } } } # If there are missing dependencies, log the error and throw an exception if ($removalJob.dependencyMissing -eq $true) { Write-AzOpsMessage -LogLevel Critical -LogString 'Invoke-AzOpsPush.Dependency.Missing' throw } } $stopWatch.Stop() Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Duration' -LogStringValues $stopWatch.Elapsed -Metric $stopWatch.Elapsed.TotalSeconds -MetricName 'AzOpsPush Time' Clear-PSFMessage } } Register-PSFConfigValidation -Name "stringorempty" -ScriptBlock { param ( $Value ) $Result = New-Object PSObject -Property @{ Success = $True Value = $null Message = "" } try { [string]$data = $Value } catch { $Result.Message = "Not a string: $Value" $Result.Success = $False return $Result } if ([string]::IsNullOrEmpty($data)) { $data = "" } if ($data -eq $Value.GetType().FullName) { $Result.Message = "Is an object with no proper string representation: $Value" $Result.Success = $False return $Result } $Result.Value = $data return $Result } Set-PSFConfig -Module AzOps -Name Core.ApplicationInsights -Value $false -Initialize -Validation bool -Description 'Global flag to turn on or off logging to Application Insight' Set-PSFConfig -Module AzOps -Name Core.AutoGeneratedTemplateFolderPath -Value "." -Initialize -Validation string -Description 'Auto-Generated Template Folder Path i.e. ./Az' Set-PSFConfig -Module AzOps -Name Core.AutoInitialize -Value $false -Initialize -Validation bool -Description '-' Set-PSFConfig -Module AzOps -Name Core.CustomTemplateResourceDeletion -Value $false -Initialize -Validation bool -Description 'Global flag declaring on or off deletion of resources in custom template.' Set-PSFConfig -Module AzOps -Name Core.DeletionSupportedResourceType -Value @('Microsoft.Authorization/locks', 'locks', 'Microsoft.Authorization/policyAssignments', 'policyAssignments', 'Microsoft.Authorization/policyDefinitions', 'policyDefinitions', 'Microsoft.Authorization/policyExemptions', 'policyExemptions', 'Microsoft.Authorization/policySetDefinitions', 'policySetDefinitions', 'Microsoft.Authorization/roleAssignments', 'roleAssignments', 'Microsoft.Resources/resourceGroups', 'microsoft.resources/subscriptions/resourcegroups', 'resourceGroups') -Initialize -Validation stringarray -Description 'Global flag declaring resource types supported for deletion by AzOps.' Set-PSFConfig -Module AzOps -Name Core.DefaultDeploymentRegion -Value northeurope -Initialize -Validation string -Description 'Default deployment region for state deployments (ARM region, not region where a resource is deployed)' Set-PSFConfig -Module AzOps -Name Core.EnrollmentAccountPrincipalName -Value '' -Initialize -Validation stringorempty -Description '-' Set-PSFConfig -Module AzOps -Name Core.ExcludedSubOffer -Value 'AzurePass_2014-09-01', 'FreeTrial_2014-09-01', 'AAD_2015-09-01' -Initialize -Validation stringarray -Description 'Excluded QuotaID' Set-PSFConfig -Module AzOps -Name Core.ExcludedSubState -Value 'Disabled', 'Deleted', 'Warned', 'Expired' -Initialize -Validation stringarray -Description 'Excluded subscription states' Set-PSFConfig -Module AzOps -Name Core.IgnoreContextCheck -Value $false -Initialize -Validation bool -Description 'If set to $true, skip AAD tenant validation == 1' Set-PSFConfig -Module AzOps -Name Core.InvalidateCache -Value $true -Initialize -Validation bool -Description 'Invalidates cache and ensures that Management Groups and Subscriptions are re-discovered' Set-PSFConfig -Module AzOps -Name Core.JqTemplatePath -Value "$script:ModuleRoot\data\template" -Initialize -Validation string -Description 'Default path to search for jq template' Set-PSFConfig -Module AzOps -Name Core.CustomJqTemplatePath -Value (Join-Path $pwd -ChildPath ".customtemplates") -Initialize -Validation string -Description 'Optional custom path to search for custom jq template' Set-PSFConfig -Module AzOps -Name Core.SkipCustomJqTemplate -Value $true -Initialize -Validation bool -Description 'Controls usage of CustomJqTemplatePath to search for custom jq template' Set-PSFConfig -Module AzOps -Name Core.MainTemplate -Value "$script:ModuleRoot\data\template\template.json" -Initialize -Validation string -Description 'Main template json' Set-PSFConfig -Module AzOps -Name Core.OfferType -Value 'MS-AZR-0017P' -Initialize -Validation string -Description '-' Set-PSFConfig -Module AzOps -Name Core.PartialMgDiscoveryRoot -Value @() -Initialize -Validation stringarray -Description 'Generate folder hierachy for specific Management Groups IDs' Set-PSFConfig -Module AzOps -Name Core.IncludeResourcesInResourceGroup -Value @('*') -Initialize -Validation stringarray -Description 'Global flag to discover only resources in these resource groups.' Set-PSFConfig -Module AzOps -Name Core.IncludeResourceType -Value @('*') -Initialize -Validation stringarray -Description 'Global flag to discover only specific resource types.' Set-PSFConfig -Module AzOps -Name Core.SkipChildResource -Value $true -Initialize -Validation bool -Description 'Global flag to indicate whether child resources should be discovered or not. Requires SkipResourceGroup and SkipResource to be false.' Set-PSFConfig -Module AzOps -Name Core.SkipPim -Value $true -Initialize -Validation bool -Description 'Global flag to control discovery of Privileged Identity Management resources.' Set-PSFConfig -Module AzOps -Name Core.SkipLock -Value $true -Initialize -Validation bool -Description 'Global flag to control discovery of resource lock resources.' Set-PSFConfig -Module AzOps -Name Core.SkipPolicy -Value $false -Initialize -Validation bool -Description '-' Set-PSFConfig -Module AzOps -Name Core.SkipResource -Value $true -Initialize -Validation bool -Description 'Global flag to indicate whether resource should be discovered or not. Requires SkipResourceGroup to be false.' Set-PSFConfig -Module AzOps -Name Core.SkipResourceGroup -Value $false -Initialize -Validation bool -Description 'Global flag to indicate whether resource group should be discovered or not' Set-PSFConfig -Module AzOps -Name Core.SkipResourceType -Value @('Microsoft.VSOnline/plans', 'Microsoft.PowerPlatform/accounts', 'Microsoft.PowerPlatform/enterprisePolicies') -Initialize -Validation stringarray -Description 'Global flag to skip discovery of specific Resource types.' Set-PSFConfig -Module AzOps -Name Core.SkipRole -Value $false -Initialize -Validation bool -Description '-' Set-PSFConfig -Module AzOps -Name Core.State -Value (Join-Path $pwd -ChildPath "root") -Initialize -Validation string -Description 'Folder to store AzOpsState artefact' Set-PSFConfig -Module AzOps -Name Core.SubscriptionsToIncludeChildResource -Value @('*') -Initialize -Validation stringarray -Description 'Requires SkipResourceGroup, SkipResource and SkipChildResource to be false. Subscription ID that matches the filter.' Set-PSFConfig -Module AzOps -Name Core.SubscriptionsToIncludeResourceGroups -Value @('*') -Initialize -Validation stringarray -Description 'Requires SkipResourceGroup to be false. Subscription ID that matches the filter.' Set-PSFConfig -Module AzOps -Name Core.TemplateParameterFileSuffix -Value '.json' -Initialize -Validation string -Description 'Parameter file suffix identifier' Set-PSFConfig -Module AzOps -Name Core.AllowMultipleTemplateParameterFiles -Value $false -Initialize -Validation string -Description 'Global flag to control multiple parameter file behaviour' Set-PSFConfig -Module AzOps -Name Core.DeployAllMultipleTemplateParameterFiles -Value $false -Initialize -Validation string -Description 'Global flag to control base template deployment behaviour with changes and un-changed multiple corresponding parameter files' Set-PSFConfig -Module AzOps -Name Core.MultipleTemplateParameterFileSuffix -Value '.x' -Initialize -Validation string -Description 'Multiple parameter file suffix identifier' Set-PSFConfig -Module AzOps -Name Core.ParallelDeployMultipleTemplateParameterFiles -Value $false -Initialize -Validation string -Description 'Global flag to control parallel deployment of MultipleTemplateParameterFiles behaviour' Set-PSFConfig -Module AzOps -Name Core.ThrottleLimit -Value 5 -Initialize -Validation integer -Description 'Throttle limit used in Foreach-Object -Parallel for resource/subscription discovery' Set-PSFConfig -Module AzOps -Name Core.WhatifExcludedChangeTypes -Value @('NoChange', 'Ignore') -Initialize -Validation stringarray -Description 'Exclude specific change types from WhatIf operations.' <# Stored scriptblocks are available in [PsfValidateScript()] attributes. This makes it easier to centrally provide the same scriptblock multiple times, without having to maintain it in separate locations. It also prevents lengthy validation scriptblocks from making your parameter block hard to read. Set-PSFScriptblock -Name 'AzOps.ScriptBlockName' -Scriptblock { } #> <# # Example: Register-PSFTeppScriptblock -Name "AzOps.Test" -ScriptBlock { 'Test' } #> <# # Example: Register-PSFTeppArgumentCompleter -Command Get-Test -Parameter Type -Name AzOps.x #> # Module Cache for Subscriptions accessible for the current account $script:AzOpsSubscriptions = @() # Module Cache for Management Groups that are in scope for this module $script:AzOpsAzManagementGroup = @() # Module Cache for Management Group Roots that are in scope for this module, when accepting partial processing $script:AzOpsPartialRoot = @() # Module cache to load resource provider version $script:AzOpsResourceProvider = $null Set-PSFFeature -Name PSFramework.Stop-PSFFunction.ShowWarning -Value $true -ModuleName AzOps if ([runspace]::DefaultRunspace.Id -eq 1) { Initialize-AzOpsEnvironment } New-PSFLicense -Product 'AzOps' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2020-11-07") -Text @" Copyright (c) 2020 Friedrich Weinmann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "@ #endregion Load compiled code |