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 = $roleEligibilityScheduleRequest.Id $this.Name = $roleEligibilitySchedule.Name $this.ResourceType = $roleEligibilityScheduleRequest.Type } } 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-PSFMessage -Level Verbose -String 'AzOpsScope.Constructor' -StringValues $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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromFile' -StringValues $scope -FunctionName AzOpsScope -ModuleName AzOps $this.InitializeMemberVariablesFromFile($Scope) } else { # Weak confidence based on metadata at scope - directory Write-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromDirectory' -StringValues $scope -FunctionName AzOpsScope -ModuleName AzOps $this.InitializeMemberVariablesFromDirectory($Scope) } } else { Write-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariables' -StringValues $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-PSFMessage -Level Verbose -String 'AzOpsScope.ChildResource.InitializeMemberVariables' -StringValues $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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromDirectory.RootTenant' -StringValues $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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromDirectory.AutoGeneratedFolderPath' -StringValues $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-PSFMessage -Level Verbose -String 'AzOpsScope.Input.FromFileName.ManagementGroup' -StringValues $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-PSFMessage -Level Verbose -String 'AzOpsScope.Input.FromFileName.Subscription' -StringValues $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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromDirectory.ParentSubscription' -StringValues $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-PSFMessage -Level Warning -Tag error -String 'AzOpsScope.Input.BadData.UnknownType' -StringValues $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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromFile.NotJson' -StringValues $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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromFile.ResourceId' -StringValues $($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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromFile.Id' -StringValues $($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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromFile.Type' -StringValues ("$($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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromFile.ResourceType' -StringValues ($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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromFile.ResourceType' -StringValues ($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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromFile.managementgroups' -StringValues ($_.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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromFile.subscriptions' -StringValues ($_.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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromFile.resourceGroups' -StringValues ($_.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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromFile.resource' -StringValues ($_.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-PSFMessage -Level Warning -String 'AzOpsScope.Input.BadData.TemplateParameterFile' -StringValues $Path -FunctionName AzOpsScope -ModuleName AzOps } Write-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariablesFromDirectory' -StringValues $Path -FunctionName AzOpsScope -ModuleName AzOps $this.InitializeMemberVariablesFromDirectory($Path.Directory) } } } } #endregion Constructors hidden [void] InitializeMemberVariables([string]$Scope) { Write-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariables.Start' -StringValues ($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-PSFMessage -Level Verbose -String 'AzOpsScope.InitializeMemberVariables.End' -StringValues ($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-PSFMessage -Level Debug -String 'AzOpsScope.GetManagementGroup.NotFound' -StringValues $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-PSFMessage -Level Warning -Tag error -String 'AzOpsScope.GetAzOpsManagementGroupPath.NotFound' -StringValues $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-PSFMessage -Level Debug -String 'AzOpsScope.GetManagementGroupName.Found.Azure' -StringValues $mgName -FunctionName AzOpsScope -ModuleName AzOps return $mgName } else { Write-PSFMessage -Level Debug -String 'AzOpsScope.GetManagementGroupName.NotFound' -StringValues $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-PSFMessage -Level Debug -String 'AzOpsScope.GetSubscription.Found' -StringValues $sub.Id -FunctionName AzOpsScope -ModuleName AzOps return $sub.subscriptionId } else { Write-PSFMessage -Level Debug -String 'AzOpsScope.GetSubscription.NotFound' -StringValues $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-PSFMessage -Level Debug -String 'AzOpsScope.GetSubscriptionDisplayName.Found' -StringValues $sub.displayName -FunctionName AzOpsScope -ModuleName AzOps return $sub.displayName } else { Write-PSFMessage -Level Debug -String 'AzOpsScope.GetSubscriptionDisplayName.NotFound' -StringValues $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-PSFMessage -Level Debug -String 'AzOpsScope.GetAzOpsResourcePath.Retrieving' -StringValues $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-PSFMessage -Level Warning -Tag error -String 'AzOpsScope.GetAzOpsResourcePath.NotFound' -StringValues $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-PSFMessage -Level InternalComment -String 'Assert-AzOpsBicepDependency.Validating' $result = (Invoke-AzOpsNativeCommand -ScriptBlock { bicep --version } -IgnoreExitcode) $installed = $result -as [bool] if ($installed) { Write-PSFMessage -Level InternalComment -String 'Assert-AzOpsBicepDependency.Success' } else { $exception = [System.InvalidOperationException]::new('Unable to locate bicep installation') $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, "ConfigurationError", 'InvalidOperation', $null) Write-PSFMessage -Level Warning -String 'Assert-AzOpsBicepDependency.NotFound' -Tag error $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-PSFMessage -Level Warning -String 'Assert-AzOpsInitialization.StateError' -Tag error $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-PSFMessage -Level Warning -String 'Assert-AzOpsInitialization.NoCache' -Tag error $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-PSFMessage -Level InternalComment -String 'Assert-AzOpsJqDependency.Validating' $result = (Invoke-AzOpsNativeCommand -ScriptBlock { jq --version } -IgnoreExitcode) $installed = $result -as [bool] if ($installed) { [double]$version = ($result).Split("-")[1] if ($version -ge 1.6) { Write-PSFMessage -Level InternalComment -String '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-PSFMessage -Level Warning -String 'Assert-AzOpsJqDependency.Failed' -Tag error $Cmdlet.ThrowTerminatingError($errorRecord) } } $exception = [System.InvalidOperationException]::new('Unable to locate jq installation') $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, "ConfigurationError", 'InvalidOperation', $null) Write-PSFMessage -Level Warning -String 'Assert-AzOpsJqDependency.Failed' -Tag error $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-PSFMessage -Level InternalComment -String '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-PSFMessage -Level Warning -String 'Assert-AzOpsWindowsLongPath.No.Registry' } if (-not $hasGitConfig) { Write-PSFMessage -Level Warning -String '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-PSFMessage -Level Warning -String 'Assert-AzOpsWindowsLongPath.Failed' -Tag error $Cmdlet.ThrowTerminatingError($errorRecord) } } function ConvertFrom-AzOpsBicepTemplate { <# .SYNOPSIS Transpiles bicep template to Azure Resource Manager (ARM) template. The json file will be created in the same folder as the bicep file. .PARAMETER BicepTemplatePath BicepTemplatePath #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $BicepTemplatePath ) begin { # Assert bicep binaries Assert-AzOpsBicepDependency -Cmdlet $PSCmdlet } process { # Convert bicep template $transpiledTemplatePath = $BicepTemplatePath -replace '\.bicep', '.json' Write-PSFMessage -Level Verbose -String 'ConvertFrom-AzOpsBicepTemplate.Resolve.ConvertBicepTemplate' -StringValues $BicepTemplatePath, $transpiledTemplatePath Invoke-AzOpsNativeCommand -ScriptBlock { bicep build $bicepTemplatePath --outfile $transpiledTemplatePath } # Return transpiled ARM json path return $transpiledTemplatePath } } 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-PSFMessage -Level Debug -String 'ConvertTo-AzOpsState.Starting' } process { Write-PSFMessage -Level Debug -String 'ConvertTo-AzOpsState.Processing' -StringValues $Resource.Id if ($ChildResource) { $objectFilePath = (New-AzOpsScope -scope $ChildResource.parentResourceId -ChildResource $ChildResource -StatePath $Statepath).statepath $jqJsonTemplate = Join-Path $JqTemplatePath -ChildPath "templateChildResource.jq" Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.Subscription.ChildResource.Jq.Template' -StringValues $jqJsonTemplate $object = ($Resource | ConvertTo-Json -Depth 100 -EnumsAsStrings | jq -r '--sort-keys' | jq -r -f $jqJsonTemplate | ConvertFrom-Json) Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.Subscription.ChildResource.Exporting' -StringValues $objectFilePath ConvertTo-Json -InputObject $object -Depth 100 -EnumsAsStrings | Set-Content -Path $objectFilePath -Encoding UTF8 -Force return } 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-PSFMessage -Level Error -String "ConvertTo-AzOpsState.NoExportPath" -StringValues $Resource.GetType() } } else { $objectFilePath = $ExportPath } # Create folder structure if it doesn't exist if (-not (Test-Path -Path $objectFilePath)) { Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.File.Create' -StringValues $objectFilePath $null = New-Item -Path $objectFilePath -ItemType "file" -Force } else { Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.File.UseExisting' -StringValues $objectFilePath } # If export file path ends with parameter $generateTemplateParameter = $objectFilePath.EndsWith('.parameters.json') ? $true : $false Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.GenerateTemplateParameter' -StringValues "$generateTemplateParameter" -FunctionName 'ConvertTo-AzOpsState' $resourceType = $null switch ($Resource) { { $_.ResourceType } { Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.ObjectType.Resolved.ResourceType' -StringValues "$($Resource.ResourceType)" -FunctionName 'ConvertTo-AzOpsState' $resourceType = $_.ResourceType break } # Management Groups { $_ -is [Microsoft.Azure.Commands.Resources.Models.ManagementGroups.PSManagementGroup] -or $_ -is [Microsoft.Azure.Commands.Resources.Models.ManagementGroups.PSManagementGroupChildInfo] } { Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.ObjectType.Resolved.PSObject' -StringValues "$($_.GetType())" -FunctionName 'ConvertTo-AzOpsState' 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-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.ObjectType.Resolved.PSObject' -StringValues "$($_.GetType())" -FunctionName 'ConvertTo-AzOpsState' $resourceType = 'Microsoft.Subscription/subscriptions' break } # Resource Groups { $_ -is [Microsoft.Azure.Commands.ResourceManager.Cmdlets.SdkModels.PSResourceGroup] } { Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.ObjectType.Resolved.PSObject' -StringValues "$($_.GetType())" -FunctionName 'ConvertTo-AzOpsState' $resourceType = 'Microsoft.Resources/resourceGroups' break } # Resources - Controlled group for raw objects { $_ -is [Microsoft.Azure.Commands.Profile.Models.PSAzureTenant] } { Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.ObjectType.Resolved.PSObject' -StringValues "$($_.GetType())" -FunctionName 'ConvertTo-AzOpsState' break } { $_.type } { if ( $_.type -eq 'Microsoft.Resources/subscriptions/resourceGroups') { $resourceType = 'Microsoft.Resources/resourceGroups' } else { $resourceType = $_.type } Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.ObjectType.Resolved.ResourceType' -StringValues $resourceType -FunctionName 'ConvertTo-AzOpsState' break } Default { Write-PSFMessage -Level Warning -String 'ConvertTo-AzOpsState.ObjectType.Resolved.Generic' -StringValues "$($_.GetType())" -FunctionName 'ConvertTo-AzOpsState' break } } if ($resourceType) { $providerNamespace = ($resourceType -split '/' | Select-Object -First 1) Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.GenerateTemplate.ProviderNamespace' -StringValues $providerNamespace -FunctionName 'ConvertTo-AzOpsState' if (($resourceType -split '/').Count -eq 2) { $resourceTypeName = (($resourceType -split '/', 2) | Select-Object -Last 1) Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.GenerateTemplate.ResourceTypeName' -StringValues $resourceTypeName -FunctionName 'ConvertTo-AzOpsState' $resourceApiTypeName = (($resourceType -split '/', 2) | Select-Object -Last 1) Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.GenerateTemplate.ResourceApiTypeName' -StringValues $resourceApiTypeName -FunctionName 'ConvertTo-AzOpsState' } if (($resourceType -split '/').Count -eq 3) { $resourceTypeName = ((($resourceType -split '/', 3) | Select-Object -Last 2) -join '/') Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.GenerateTemplate.ResourceTypeName' -StringValues $resourceTypeName -FunctionName 'ConvertTo-AzOpsState' $resourceApiTypeName = (($resourceType -split '/', 3) | Select-Object -Index 1) Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.GenerateTemplate.ResourceApiTypeName' -StringValues $resourceApiTypeName -FunctionName 'ConvertTo-AzOpsState' } $jqRemoveTemplate = ( (Test-Path (Join-Path $JqTemplatePath -ChildPath (Join-Path $providerNamespace -ChildPath "$resourceTypeName.jq"))) ? (Join-Path $JqTemplatePath -ChildPath (Join-Path $providerNamespace -ChildPath "$resourceTypeName.jq")): (Join-Path $JqTemplatePath -ChildPath "generic.jq") ) Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.Jq.Remove' -StringValues $jqRemoveTemplate -FunctionName 'ConvertTo-AzOpsState' # 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-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.GenerateTemplateParameter' -FunctionName 'ConvertTo-AzOpsState' $jqJsonTemplate = (Test-Path (Join-Path $JqTemplatePath -ChildPath (Join-Path $providerNamespace -ChildPath "$resourceTypeName.parameters.jq"))) ? (Join-Path $JqTemplatePath -ChildPath (Join-Path $providerNamespace -ChildPath "$resourceTypeName.parameters.jq")): (Join-Path $JqTemplatePath -ChildPath "template.parameters.jq") Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.Jq.Template' -StringValues $jqJsonTemplate -FunctionName 'ConvertTo-AzOpsState' $object = ($object | ConvertTo-Json -Depth 100 -EnumsAsStrings | jq -r '--sort-keys' | jq -r -f $jqJsonTemplate | ConvertFrom-Json) #endregion } else { #region Generating Template Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.GenerateTemplate' -StringValues "$true" -FunctionName 'ConvertTo-AzOpsState' $jqJsonTemplate = (Test-Path (Join-Path $JqTemplatePath -ChildPath (Join-Path $providerNamespace -ChildPath "$resourceTypeName.template.jq"))) ? (Join-Path $JqTemplatePath -ChildPath (Join-Path $providerNamespace -ChildPath "$resourceTypeName.template.jq")): (Join-Path $JqTemplatePath -ChildPath "template.jq") Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.Jq.Template' -StringValues $jqJsonTemplate -FunctionName 'ConvertTo-AzOpsState' $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[0] Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.GenerateTemplate.ApiVersion' -StringValues $resourceType, $apiVersions -FunctionName 'ConvertTo-AzOpsState' $object.resources[0].apiVersion = $apiVersions $object.resources[0].type = $resourceType } else { Write-PSFMessage -Level Warning -String 'ConvertTo-AzOpsState.GenerateTemplate.NoApiVersion' -StringValues $resourceType -FunctionName 'ConvertTo-AzOpsState' } #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-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.GenerateTemplate.ChildResource' -StringValues $resourceName -FunctionName 'ConvertTo-AzOpsState' } #endregion } Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.Exporting' -StringValues $objectFilePath -FunctionName 'ConvertTo-AzOpsState' ConvertTo-Json -InputObject $object -Depth 100 -EnumsAsStrings | Set-Content -Path ([WildcardPattern]::Escape($objectFilePath)) -Encoding UTF8 -Force } } else { Write-PSFMessage -Level Verbose -String 'ConvertTo-AzOpsState.Exporting.Default' -StringValues $objectFilePath -FunctionName 'ConvertTo-AzOpsState' 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-PSFMessage -Level InternalComment -String 'Get-AzOpsCurrentPrincipal.AccountType' -StringValues $AzContext.Account.Type switch ($AzContext.Account.Type) { 'User' { $principalObject = (Invoke-AzRestMethod -Uri https://graph.microsoft.com/v1.0/me).Content | ConvertFrom-Json } '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) $applicationId = (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 }).client_id $principalObject = Get-AzADServicePrincipal -ApplicationId $applicationId } default { $principalObject = Get-AzADServicePrincipal -ApplicationId $AzContext.Account.Id } } Write-PSFMessage -Level InternalComment -String 'Get-AzOpsCurrentPrincipal.PrincipalId' -StringValues $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)] [ValidateScript( { Get-AzManagementGroup -GroupId $_ -WarningAction SilentlyContinue })] $ManagementGroup, [switch] $PartialDiscovery ) process { $groupObject = Get-AzManagementGroup -GroupId $ManagementGroup -Expand -WarningAction SilentlyContinue 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 } } } $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-PSFMessage -Level Verbose -String 'Get-AzOpsResourceDefinition.Processing.Detail' -StringValues '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-PSFMessage -Level Verbose -String 'Get-AzOpsResourceDefinition.Processing.Detail' -StringValues '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-PSFMessage -Level Verbose -String 'Get-AzOpsResourceDefinition.Processing.Detail' -StringValues 'Policy Set Definitions', $ScopeObject.Scope $policySetDefinitions = Get-AzOpsPolicySetDefinition -ScopeObject $ScopeObject -Subscription $Subscription $policySetDefinitions | ConvertTo-AzOpsState -StatePath $StatePath } # Process policy assignments Write-PSFMessage -Level Verbose -String 'Get-AzOpsResourceDefinition.Processing.Detail' -StringValues 'Policy Assignments', $ScopeObject.Scope $policyAssignments = Get-AzOpsPolicyAssignment -ScopeObject $ScopeObject -Subscription $Subscription -SubscriptionsToIncludeResourceGroups $SubscriptionsToIncludeResourceGroups -ResourceGroup $ResourceGroup $policyAssignments | 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.Commands.ResourceManager.Cmdlets.Implementation.Policy.PsPolicyAssignment])] [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-PSFMessage -Level Debug -String 'Get-AzOpsPolicyAssignment.ManagementGroup' -StringValues $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-PSFMessage -Level Debug -String 'Get-AzOpsPolicyAssignment.Subscription' -StringValues $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-PSFMessage -Level Debug -String 'Get-AzOpsPolicyAssignment.ResourceGroup' -StringValues $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-PSFMessage -Level Debug -String 'Get-AzOpsPolicyAssignment.Subscription' -StringValues $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.Commands.ResourceManager.Cmdlets.Implementation.Policy.PsPolicyDefinition])] [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-PSFMessage -Level Debug -String 'Get-AzOpsPolicyDefinition.ManagementGroup' -StringValues $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-PSFMessage -Level Debug -String 'Get-AzOpsPolicyDefinition.Subscription' -StringValues $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. .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.Commands.ResourceManager.Cmdlets.Implementation.Policy.PsPolicyExemption])] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Object] $ScopeObject ) process { if ($ScopeObject.Type -notin 'resourceGroups', 'subscriptions', 'managementGroups') { return } switch ($ScopeObject.Type) { managementGroups { Write-PSFMessage -Level Debug -String 'Get-AzOpsPolicyExemption.ManagementGroup' -StringValues $ScopeObject.ManagementGroupDisplayName, $ScopeObject.ManagementGroup -Target $ScopeObject } subscriptions { Write-PSFMessage -Level Debug -String 'Get-AzOpsPolicyExemption.Subscription' -StringValues $ScopeObject.SubscriptionDisplayName, $ScopeObject.Subscription -Target $ScopeObject } resourcegroups { Write-PSFMessage -Level Debug -String 'Get-AzOpsPolicyExemption.ResourceGroup' -StringValues $ScopeObject.ResourceGroup -Target $ScopeObject } } try { $parameters = @{ Scope = $ScopeObject.Scope } # Gather policyExemption with retry and backoff support from Invoke-AzOpsScriptBlock Invoke-AzOpsScriptBlock -ArgumentList $parameters -ScriptBlock { Get-AzPolicyExemption @parameters -WarningAction SilentlyContinue -ErrorAction Stop | Where-Object ResourceId -match $parameters.Scope } -RetryCount 3 -RetryWait 5 -RetryType Exponential -ErrorAction Stop } catch { Write-PSFMessage -Level Warning -Message $_ -FunctionName "Get-AzOpsPolicyExemption" } } } 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.Commands.ResourceManager.Cmdlets.Implementation.Policy.PsPolicySetDefinition])] [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-PSFMessage -Level Debug -String 'Get-AzOpsPolicySetDefinition.ManagementGroup' -StringValues $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-PSFMessage -Level Debug -String 'Get-AzOpsPolicySetDefinition.Subscription' -StringValues $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-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. .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') ) 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-PSFMessage -Level Important -String 'Get-AzOpsResourceDefinition.Processing' -StringValues $Scope try { $scopeObject = New-AzOpsScope -Scope $Scope -StatePath $StatePath -ErrorAction Stop # Logging output metadata $msgCommon = @{ FunctionName = 'Get-AzOpsResourceDefinition' Target = $ScopeObject } } catch { Write-PSFMessage -Level Warning -String 'Get-AzOpsResourceDefinition.Processing.NotFound' -StringValues $Scope return } if ($scopeObject.Type -notin 'subscriptions', 'managementGroups') { Write-PSFMessage -Level Verbose -String 'Get-AzOpsResourceDefinition.Finished' -StringValues $scopeObject.Scope return } switch ($scopeObject.Type) { subscriptions { Write-PSFMessage -Level Important @msgCommon -String 'Get-AzOpsResourceDefinition.Subscription.Processing' -StringValues $ScopeObject.SubscriptionDisplayName, $ScopeObject.Subscription $subscriptions = Get-AzSubscription -SubscriptionId $scopeObject.Name | Where-Object { "/subscriptions/$($_.Id)" -in $script:AzOpsSubscriptions.id } } managementGroups { Write-PSFMessage -Level Important @msgCommon -String 'Get-AzOpsResourceDefinition.ManagementGroup.Processing' -StringValues $ScopeObject.ManagementGroupDisplayName, $ScopeObject.ManagementGroup $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, Policies and Roles at managementGroup scope if ((-not $using:SkipPim) -or (-not $using:SkipPolicy) -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:SkipPolicy) { $policyExemptions = Get-AzOpsPolicyExemption -ScopeObject $ScopeObject if ($policyExemptions) { $policyExemptions | ConvertTo-AzOpsState -StatePath $runspaceData.Statepath } } if (-not $using:SkipRole) { Get-AzOpsRole -ScopeObject $ScopeObject -StatePath $runspaceData.Statepath } } } } } } } #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, Policies, Locks and Roles at subscription scope if ((-not $using:SkipPim) -or (-not $using:SkipPolicy) -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:SkipPolicy) { $policyExemptions = Get-AzOpsPolicyExemption -ScopeObject $scopeObject if ($policyExemptions) { $policyExemptions | ConvertTo-AzOpsState -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 } } } } } #endregion Process subscription scope in parallel #region Process Resource Groups if ($SkipResourceGroup -or (-not $subscriptions)) { if ($SkipResourceGroup) { Write-PSFMessage -Level Verbose @msgCommon -String 'Get-AzOpsResourceDefinition.SkippingResourceGroup' } else { Write-PSFMessage -Level Verbose @msgCommon -String 'Get-AzOpsResourceDefinition.Subscription.NotFound' } } else { if ((Get-PSFConfigValue -FullName 'AzOps.Core.SubscriptionsToIncludeResourceGroups') -ne '*') { $subscriptionsToIncludeResourceGroups = $subscriptions | Where-Object { $_.Id -in (Get-PSFConfigValue -FullName 'AzOps.Core.SubscriptionsToIncludeResourceGroups') } } $query = "resourcecontainers | where type == 'microsoft.resources/subscriptions/resourcegroups' | where managedBy == '' | order by ['id'] asc" if ($subscriptionsToIncludeResourceGroups) { $resourceGroups = Search-AzOpsAzGraph -Subscription $subscriptionsToIncludeResourceGroups -Query $query -ErrorAction Stop } 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, Policies, Locks and Roles at resource group scope if ((-not $using:SkipPim) -or (-not $using:SkipPolicy) -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:SkipPolicy) { $policyExemptions = Get-AzOpsPolicyExemption -ScopeObject $rgScopeObject if ($policyExemptions) { $policyExemptions | ConvertTo-AzOpsState -StatePath $runspaceData.Statepath } } if (-not $using:SkipRole) { Get-AzOpsRole -ScopeObject $rgScopeObject -StatePath $runspaceData.Statepath } } } } } else { Write-PSFMessage -Level Verbose @msgCommon -String 'Get-AzOpsResourceDefinition.NoResourceGroup' -StringValues $scopeObject.Name } # Process Policies at Resource Group scope if (-not $SkipPolicy) { if ($subscriptionsToIncludeResourceGroups) { Get-AzOpsPolicy -ScopeObject $scopeObject -Subscription $subscriptions -SubscriptionsToIncludeResourceGroups $subscriptionsToIncludeResourceGroups -ResourceGroup -StatePath $StatePath } else { Get-AzOpsPolicy -ScopeObject $scopeObject -Subscription $subscriptions -ResourceGroup -StatePath $StatePath } } # Process Resources at Resource Group scope if (-not $SkipResource) { Write-PSFMessage -Level Verbose @msgCommon -String 'Get-AzOpsResourceDefinition.Processing.Resource.Discovery' -StringValues $scopeObject.Name 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-PSFMessage -Level Warning @msgCommon -String 'Get-AzOpsResourceDefinition.Processing.Resource.Warning' -StringValues $scopeObject.Name } if ($resourcesBase) { $resources = @() foreach ($resource in $resourcesBase) { if ($resourceGroups | Where-Object { $_.name -eq $resource.resourceGroup -and $_.subscriptionId -eq $resource.subscriptionId }) { Write-PSFMessage -Level Verbose @msgCommon -String 'Get-AzOpsResourceDefinition.Processing.Resource' -StringValues $resource.name, $resource.resourcegroup -Target $resource $resources += $resource ConvertTo-AzOpsState -Resource $resource -StatePath $Statepath } } } else { Write-PSFMessage -Level Verbose @msgCommon -String 'Get-AzOpsResourceDefinition.Processing.Resource.Discovery.NotFound' -StringValues $scopeObject.Name } } else { Write-PSFMessage -Level Verbose @msgCommon -String 'Get-AzOpsResourceDefinition.SkippingResources' } # Process resources as scope in parallel, look for childResource if (-not $SkipResource -and -not $SkipChildResource) { $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))) { Write-PSFMessage -Level Verbose -String 'Get-AzOpsResourceDefinition.Processing.ChildResource' -StringValues $exportResource.name, $resource.resourceGroup -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 { Write-PSFMessage -Level Warning -String 'Get-AzOpsResourceDefinition.ChildResource.Warning' -StringValues $resource.resourceGroup, $_ } if (Test-Path -Path $tempExportPath) { Remove-Item -Path $tempExportPath } } } else { Write-PSFMessage -Level Verbose @msgCommon -String 'Get-AzOpsResourceDefinition.SkippingChildResources' } } #endregion Process Resource Groups Write-PSFMessage -Level Verbose @msgCommon -String 'Get-AzOpsResourceDefinition.Finished' -StringValues $scopeObject.Scope } } 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-PSFMessage -Level Verbose -String 'Get-AzOpsResourceLock.Subscription' -StringValues $ScopeObject.SubscriptionDisplayName, $ScopeObject.Subscription -Target $ScopeObject } resourcegroups { # ScopeObject is a resourcegroup Write-PSFMessage -Level Verbose -String 'Get-AzOpsResourceLock.ResourceGroup' -StringValues $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-PSFMessage -Level Warning -Message $_ -FunctionName "Get-AzOpsResourceLock" } 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-PSFMessage -Level Verbose -String 'Get-AzOpsResourceDefinition.Processing.Detail' -StringValues 'Role Definitions', $ScopeObject.Scope $roleDefinitions = Get-AzOpsRoleDefinition -ScopeObject $ScopeObject if ($roleDefinitions) { $roleDefinitions | ConvertTo-AzOpsState -StatePath $StatePath } # Process role assignments Write-PSFMessage -Level Verbose -String 'Get-AzOpsResourceDefinition.Processing.Detail' -StringValues '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-PSFMessage -Level Debug -String 'Get-AzOpsRoleAssignment.Processing' -StringValues $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-PSFMessage -Level Warning -Message $_ -FunctionName "Get-AzOpsRoleAssignment" } if ($roleAssignments) { $roleAssignmentMatch = @() foreach ($roleAssignment in $roleAssignments) { if ($roleAssignment.properties.scope -eq $ScopeObject.Scope) { Write-PSFMessage -Level Debug -String 'Get-AzOpsRoleAssignment.Assignment' -StringValues $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-PSFMessage -Level Debug -String 'Get-AzOpsRoleDefinition.Processing' -StringValues $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-PSFMessage -Level Warning -Message $_ -FunctionName "Get-AzOpsRoleDefinition" } if ($roleDefinitions) { $roleDefinitionsMatch = @() foreach ($roleDefinition in $roleDefinitions) { if ($roleDefinition.properties.assignableScopes -eq $ScopeObject.Scope) { Write-PSFMessage -Level Debug -String 'Get-AzOpsRoleDefinition.Definition' -StringValues $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-PSFMessage -Level Debug -String 'Get-AzOpsRoleEligibilityScheduleRequest.Processing' -StringValues $ScopeObject.Scope -Target $ScopeObject $roleEligibilitySchedules = Get-AzRoleEligibilitySchedule -Scope $ScopeObject.Scope -WarningAction SilentlyContinue | Where-Object {$_.Scope -eq $ScopeObject.Scope} if ($roleEligibilitySchedules) { foreach ($roleEligibilitySchedule in $roleEligibilitySchedules) { # Process roleEligibilitySchedule together with RoleEligibilityScheduleRequest $roleEligibilityScheduleRequest = Get-AzRoleEligibilityScheduleRequest -Scope $ScopeObject.Scope -Name $roleEligibilitySchedule.Name -ErrorAction SilentlyContinue if ($roleEligibilityScheduleRequest) { Write-PSFMessage -Level Debug -String 'Get-AzOpsRoleEligibilityScheduleRequest.Assignment' -StringValues $roleEligibilitySchedule.Name -Target $ScopeObject # Construct AzOpsRoleEligibilityScheduleRequest by combining information from roleEligibilitySchedule and roleEligibilityScheduleRequest [AzOpsRoleEligibilityScheduleRequest]::new($roleEligibilitySchedule, $roleEligibilityScheduleRequest) } } } } } 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 = '2020-01-01' ) process { Write-PSFMessage -Level Important -String 'Get-AzOpsSubscription.Excluded.States' -StringValues ($ExcludedStates -join ',') Write-PSFMessage -Level Important -String 'Get-AzOpsSubscription.Excluded.Offers' -StringValues ($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-PSFMessage -Level Warning -String 'Get-AzOpsSubscription.NoSubscriptions' -Tag failed return } Write-PSFMessage -Level Important -String 'Get-AzOpsSubscription.Subscriptions.Found' -StringValues $allSubscriptionsResults.Count if ($allSubscriptionsResults.Count -gt $includedSubscriptions.Count) { Write-PSFMessage -Level Important -String 'Get-AzOpsSubscription.Subscriptions.Excluded' -StringValues ($allSubscriptionsResults.Count - $includedSubscriptions.Count) } if ($includedSubscriptions | Where-Object State -EQ PastDue) { Write-PSFMessage -Level Warning -String 'Get-AzOpsSubscription.Subscriptions.PastDue' -StringValues ($includedSubscriptions | Where-Object State -EQ PastDue).Count } Write-PSFMessage -Level Important -String 'Get-AzOpsSubscription.Subscriptions.Included' -StringValues $includedSubscriptions.Count $includedSubscriptions } } 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-PSFMessage -Level Debug -Message $_ } $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-PSFMessage -Level Debug -String 'Invoke-AzOpsRestMethod.Processing' -StringValues $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-PSFMessage -Level Warning -String 'Invoke-AzOpsRestMethod.Processing.RateLimit' -StringValues $Path, $_.value Start-Sleep -Seconds $_.value } } } } catch { Write-PSFMessage -Level Error -String 'Invoke-AzOpsRestMethod.Processing.Error' -StringValues $_, $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-PSFMessage -Level Debug -String 'Invoke-AzOpsScriptBlock.Failed.WillRetry' -StringValues $count, $RetryCount -ErrorRecord $_ -Data $data switch ($RetryType) { Linear { Start-Sleep -Seconds $RetryWait } Exponential { Start-Sleep -Seconds ([math]::Pow($RetryWait, $count)) } } continue } Write-PSFMessage -Level Warning -String 'Invoke-AzOpsScriptBlock.Failed.GivingUp' -StringValues $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 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 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 #> [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)] [AllowEmptyString()] [AllowNull()] [string] $TemplateParameterFilePath, [string] $Mode = "Incremental", [string] $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State'), [string[]] $WhatifExcludedChangeTypes = (Get-PSFConfigValue -FullName 'AzOps.Core.WhatifExcludedChangeTypes') ) process { Write-PSFMessage -Level Important -String 'New-AzOpsDeployment.Processing' -StringValues $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-PSFMessage -Level Warning -String 'New-AzOpsDeployment.Scope.Failed' -Target $TemplateFilePath -StringValues $TemplateFilePath, $TemplateParameterFilePath -ErrorRecord $_ return } if (-not $scopeObject) { Write-PSFMessage -Level Warning -String 'New-AzOpsDeployment.Scope.Empty' -Target $TemplateFilePath -StringValues $TemplateFilePath, $TemplateParameterFilePath return } #endregion Resolve Scope #region Parse Content $templateContent = Get-Content $TemplateFilePath | ConvertFrom-Json -AsHashtable if ($templateContent.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 = @{ 'TemplateFile' = $TemplateFilePath 'SkipTemplateParameterPrompt' = $true 'Location' = $defaultDeploymentRegion } # Resource Groups excluding Microsoft.Resources/resourceGroups that needs to be submitted at subscription scope if ($scopeObject.resourcegroup -and $templateContent.resources[0].type -ne 'Microsoft.Resources/resourceGroups') { Write-PSFMessage -Level Verbose -String 'New-AzOpsDeployment.ResourceGroup.Processing' -StringValues $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-PSFMessage -Level Verbose -String 'New-AzOpsDeployment.Subscription.Processing' -StringValues $defaultDeploymentRegion, $scopeObject -Target $scopeObject Set-AzOpsContext -ScopeObject $scopeObject $whatIfCommand = 'Get-AzSubscriptionDeploymentWhatIfResult' $deploymentCommand = 'New-AzSubscriptionDeployment' } # Management Groups elseif ($scopeObject.managementGroup -and (-not ($scopeObject.StatePath).StartsWith('azopsscope-assume-new-resource_'))) { Write-PSFMessage -Level Verbose -String 'New-AzOpsDeployment.ManagementGroup.Processing' -StringValues $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-PSFMessage -Level Verbose -String 'New-AzOpsDeployment.Root.Processing' -StringValues $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-PSFMessage -Level Verbose -String 'New-AzOpsDeployment.Root.Processing' -StringValues $defaultDeploymentRegion, $scopeObject -Target $scopeObject $whatIfCommand = 'Get-AzTenantDeploymentWhatIfResult' $deploymentCommand = 'New-AzTenantDeployment' } # Invalid directory name else { Write-PSFMessage -Level Error -String 'New-AzOpsDeployment.Directory.NotFound' -Target $scopeObject -Tag Error -StringValues (Get-Item -Path $pathDir).Name, "$($resource.properties.displayName) ($($resource.name))" throw } } # Parent missing else { Write-PSFMessage -Level Error -String 'New-AzOpsDeployment.Parent.NotFound' -Target $scopeObject -Tag Error -StringValues $addition throw } } else { Write-PSFMessage -Level Warning -String 'New-AzOpsDeployment.Scope.Unidentified' -Target $scopeObject -StringValues $scopeObject $scopeFound = $false } # Proceed with WhatIf or Deployment if scope was found if ($scopeFound) { 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-PSFMessage -Level Warning -String '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-PSFMessage -Level Warning -String 'New-AzOpsDeployment.WhatIfWarning' -Target $scopeObject -Tag Error -StringValues $resultsErrorMessage Set-AzOpsWhatIfOutput -FilePath $parameters.TemplateFile -Results ('{0}WhatIf prediction failed with error - validate changes manually before merging:{0}{1}' -f [environment]::NewLine, $resultsErrorMessage) } else { Write-PSFMessage -Level Warning -String 'New-AzOpsDeployment.WhatIfWarning' -Target $scopeObject -Tag Error -StringValues $resultsErrorMessage throw $resultsErrorMessage } } elseif ($results.Error) { Write-PSFMessage -Level Warning -String 'New-AzOpsDeployment.TemplateError' -StringValues $TemplateFilePath -Target $scopeObject return } else { Write-PSFMessage -Level Verbose -String 'New-AzOpsDeployment.WhatIfResults' -StringValues ($results | Out-String) -Target $scopeObject Write-PSFMessage -Level Verbose -String 'New-AzOpsDeployment.WhatIfFile' -Target $scopeObject Set-AzOpsWhatIfOutput -FilePath $parameters.TemplateFile -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) { & $deploymentCommand @parameters } } else { # Exit deployment Write-PSFMessage -Level Verbose -String 'New-AzOpsDeployment.SkipDueToWhatIf' } } } } 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] #> #[OutputType([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-PSFMessage -Level Debug -String '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-PSFMessage -Level Important -String 'New-AzOpsStateDeployment.Processing' -StringValues $FileName $scopeObject = New-AzOpsScope -Path (Get-Item -Path $FileName).FullName -StatePath $StatePath if (-not $scopeObject.Type) { Write-PSFMessage -Level Warning -String 'New-AzOpsStateDeployment.InvalidScope' -StringValues $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-PSFMessage -Level Verbose -String 'New-AzOpsStateDeployment.Subscription' -StringValues $FileName -Target $scopeObject $subscription = $subscriptions | Where-Object Name -EQ $scopeObject.subscriptionDisplayName #region Subscription needs to be created if (-not $subscription) { Write-PSFMessage -Level Important -String 'New-AzOpsStateDeployment.Subscription.New' -StringValues $FileName -Target $scopeObject if (-not $enrollmentAccounts) { Write-PSFMessage -Level Error -String 'New-AzOpsStateDeployment.NoEnrollmentAccount' -Target $scopeObject Write-PSFMessage -Level Error -String 'New-AzOpsStateDeployment.NoEnrollmentAccount.Solution' -Target $scopeObject return } if ($cfgEnrollmentAccount = Get-PSFConfigValue -FullName 'AzOps.Core.EnrollmentAccountPrincipalName') { Write-PSFMessage -Level Important -String 'New-AzOpsStateDeployment.EnrollmentAccount.Selected' -StringValues $cfgEnrollmentAccount $enrollmentAccountObjectId = ($enrollmentAccounts | Where-Object PrincipalName -eq $cfgEnrollmentAccount).ObjectId } else { Write-PSFMessage -Level Important -String 'New-AzOpsStateDeployment.EnrollmentAccount.First' -StringValues @($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-PSFMessage -Level Verbose -String 'New-AzOpsStateDeployment.Subscription.Exists' -StringValues $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-PSFMessage -Level Verbose -String 'Register-AzOpsProviderFeature.Processing' -StringValues $ScopeObject, $FileName -Target $ScopeObject $currentContext = Get-AzContext if ($ScopeObject.Subscription -and $currentContext.Subscription.Id -ne $ScopeObject.Subscription) { Write-PSFMessage -Level Verbose -String 'Register-AzOpsProviderFeature.Context.Switching' -StringValues $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-PSFMessage -Level Verbose -String 'Register-AzOpsProviderFeature.Provider.Feature' -StringValues $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-PSFMessage -Level Verbose -String 'Register-AzOpsResourceProvider.Processing' -StringValues $ScopeObject, $FileName -Target $ScopeObject $currentContext = Get-AzContext if ($ScopeObject.Subscription -and $currentContext.Subscription.Id -ne $ScopeObject.Subscription) { Write-PSFMessage -Level Verbose -String 'Register-AzOpsResourceProvider.Context.Switching' -StringValues $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-PSFMessage -Level Important -String 'Register-AzOpsResourceProvider.Provider.Register' -StringValues $resourceprovider.ProviderNamespace Write-AzOpsLog -Level Verbose -Topic "Register-AzOpsResourceProvider" -Message "Registering Provider $($resourceprovider.ProviderNamespace)" Register-AzResourceProvider -Confirm:$false -Pre -ProviderNamespace $resourceprovider.ProviderNamespace } } } function Remove-AzOpsDeployment { <# .SYNOPSIS Deletion of supported resource types from azure according to AzOps.Core.DeletionSupportedResourceType. .DESCRIPTION Deletion of supported resource types from azure according to AzOps.Core.DeletionSupportedResourceType. .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. .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 ( [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') ) process { function Get-AzLocksDeletionDependency { param ( $resourceToDelete ) $dependency = @() if ($resourceToDelete.ResourceType -in $DeletionSupportedResourceType) { if ($resourceToDelete.SubscriptionId) { $depLock = Get-AzResourceLock -Scope "/subscriptions/$($resourceToDelete.SubscriptionId)" if ($depLock) { foreach ($lock in $depLock) { if ($lock.ResourceGroupName -eq $resourceToDelete.ResourceGroupName) { #Filter through each return and validate resource is not at child resource scope if ($lock.ResourceId -notlike '*/resourcegroups/*/providers/*/providers/*') { $dependency += [PSCustomObject]@{ ResourceType = 'locks' ResourceId = $lock.ResourceId } } } elseif ($lock.ResourceId -notlike '*/resourcegroups/*') { $dependency += [PSCustomObject]@{ ResourceType = 'locks' ResourceId = $lock.ResourceId } } } } if ($dependency) { $dependency = $dependency | Sort-Object ResourceId -Unique return $dependency } } } } function Get-AzPolicyAssignmentDeletionDependency { param ( $resourceToDelete ) $dependency = @() if ($resourceToDelete.ResourceType -in $DeletionSupportedResourceType) { switch ($resourceToDelete.ResourceType) { 'Microsoft.Authorization/policyAssignments' { $depPolicyAssignment = $resourceToDelete } 'Microsoft.Authorization/policyDefinitions' { $depPolicyAssignment = Get-AzPolicyAssignment -PolicyDefinitionId $resourceToDelete.PolicyDefinitionId -ErrorAction SilentlyContinue } 'Microsoft.Authorization/policySetDefinitions' { $query = "PolicyResources | where type == 'microsoft.authorization/policyassignments' and properties.policyDefinitionId == '$($resourceToDelete.PolicySetDefinitionId)' | 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]@{ ResourceType = $policyAssignment.ResourceType ResourceId = $policyAssignment.ResourceId } if ($policyAssignment.Identity.IdentityType -eq 'SystemAssigned') { $depSystemAssignedRoleAssignment = $null $depSystemAssignedRoleAssignment = Get-AzRoleAssignment -ObjectId $policyAssignment.Identity.PrincipalId -Scope $policyAssignment.Properties.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]@{ ResourceType = 'roleAssignments' ResourceId = $roleAssignmentId } } else { Write-PSFMessage -Level Warning -String 'Remove-AzOpsDeployment.ResourceDependencyNested' -StringValues $roleAssignmentId, $policyAssignment.ResourceId } } } } } } if ($dependency) { $dependency = $dependency | Sort-Object ResourceId -Unique | Where-Object {$_.ResourceId -ne $resourceToDelete.ResourceId} return $dependency } } function Get-AzPolicyDefinitionDeletionDependency { param ( $resourceToDelete ) if ($resourceToDelete.ResourceType -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.PolicyDefinitionId)' | 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]@{ ResourceType = $policy.ResourceType ResourceId = $policy.PolicySetDefinitionId } $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $policy } } } if ($dependency) { $dependency = $dependency | Sort-Object ResourceId -Unique | Where-Object {$_.ResourceId -ne $resourceToDelete.ResourceId} 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) { $TemplateFilePath = $TemplateParameterFilePath } #Deployment Name $fileItem = Get-Item -Path $TemplateFilePath $removeJobName = $fileItem.BaseName -replace '\.json$' -replace ' ', '_' $removeJobName = "AzOps-RemoveResource-$removeJobName" Write-PSFMessage -Level Important -String 'Remove-AzOpsDeployment.Processing' -StringValues $removeJobName, $TemplateFilePath -Target $TemplateFilePath #region Parse Content $templateContent = Get-Content $TemplateFilePath | ConvertFrom-Json -AsHashtable #endregion #region Validate it is AzOpsgenerated template $schemavalue = '$schema' if ($templateContent.metadata._generator.name -eq "AzOps" -or $templateContent.$schemavalue -like "*deploymentParameters.json#") { Write-PSFMessage -Level Verbose -Message 'Remove-AzOpsDeployment.Metadata.Success' -StringValues $TemplateFilePath -Target $TemplateFilePath } else { Write-PSFMessage -Level Error -Message 'Remove-AzOpsDeployment.Metadata.Failed' -StringValues $TemplateFilePath -Target $TemplateFilePath return } #endregion Validate it is AzOpsgenerated template #region Resolve Scope try { $scopeObject = New-AzOpsScope -Path $TemplateFilePath -StatePath $StatePath -ErrorAction Stop -WhatIf:$false } catch { Write-PSFMessage -Level Warning -String 'Remove-AzOpsDeployment.Scope.Failed' -Target $TemplateFilePath -StringValues $TemplateFilePath -ErrorRecord $_ return } if (-not $scopeObject) { Write-PSFMessage -Level Warning -String 'Remove-AzOpsDeployment.Scope.Empty' -Target $TemplateFilePath -StringValues $TemplateFilePath return } #endregion Resolve Scope #region SetContext Set-AzOpsContext -ScopeObject $scopeObject #endregion SetContext #region remove supported resources if ($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-01-01-preview" | Where-Object { $_.StatusCode -eq 200 } if ($resourceToDelete) { $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } } # If no resource to delete was found return if (-not $resourceToDelete) { Write-PSFMessage -Level Warning -String 'Remove-AzOpsDeployment.ResourceNotFound' -StringValues $scopeObject.Resource, $scopeObject.Scope -Target $scopeObject $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.ResourceId -notin $deletionList.ScopeObject.Scope) { Write-PSFMessage -Level Critical -String 'Remove-AzOpsDeployment.ResourceDependencyNotFound' -StringValues $resource.ResourceId, $scopeObject.Scope -Target $scopeObject $results = 'Missing resource dependency:{2}{0} for successful deletion of {1}.{2}{2}Please add dependent resource to pull request and retry.' -f $resource.ResourceId, $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-PSFMessage -Level Verbose -String 'Set-AzOpsWhatIfOutput.WhatIfResults' -StringValues $results -Target $scopeObject Write-PSFMessage -Level Verbose -String 'Set-AzOpsWhatIfOutput.WhatIfFile' -Target $scopeObject 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-PSFMessage -Level Verbose -String 'Set-AzOpsWhatIfOutput.WhatIfResults' -StringValues $results -Target $scopeObject Write-PSFMessage -Level Verbose -String 'Set-AzOpsWhatIfOutput.WhatIfFile' -Target $scopeObject Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true } if ($PSCmdlet.ShouldProcess("Remove $($scopeObject.Scope)?")) { $null = Remove-AzResource -ResourceId $scopeObject.Scope -Force } else { Write-PSFMessage -Level Verbose -String 'Remove-AzOpsDeployment.SkipDueToWhatIf' } } } } 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-PSFMessage -Level Verbose -String 'Remove-AzOpsInvalidCharacter.Invalid' -StringValues $String -FunctionName 'Remove-AzOpsInvalidCharacter' # 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-PSFMessage -Level Verbose -String 'Remove-AzOpsInvalidCharacter.Removal' -StringValues $character, $String -FunctionName 'Remove-AzOpsInvalidCharacter' # Remove invalid character $String = $String.Replace($character.ToString(),'') } } } # Always remove square brackets $String = $String -replace "(\[|\])","" Write-PSFMessage -Level Verbose -String 'Remove-AzOpsInvalidCharacter.Completed' -StringValues $String -FunctionName 'Remove-AzOpsInvalidCharacter' # Return processed string return $String } } 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-PSFMessage -Level Debug -String '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-PSFMessage -Level Debug -String 'Save-AzOpsManagementGroupChild.Processing' -StringValues $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-PSFMessage -Level Debug -String 'Save-AzOpsManagementGroupChild.Data.StatePath' -StringValues $scopeStatepath Write-PSFMessage -Level Debug -String 'Save-AzOpsManagementGroupChild.Data.FileName' -StringValues $statepathFileName Write-PSFMessage -Level Debug -String 'Save-AzOpsManagementGroupChild.Data.Directory' -StringValues $statepathDirectory Write-PSFMessage -Level Debug -String 'Save-AzOpsManagementGroupChild.Data.ScopeDirectory' -StringValues $statepathScopeDirectory Write-PSFMessage -Level Debug -String 'Save-AzOpsManagementGroupChild.Data.ScopeDirectoryParent' -StringValues $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-PSFMessage -Level Important -String 'Save-AzOpsManagementGroupChild.Moving.Source' -StringValues $exisitingScopePath Move-Item -Path $exisitingScopePath.Parent -Destination $statepathScopeDirectoryParent -Force Write-PSFMessage -Level Important -String 'Save-AzOpsManagementGroupChild.Moving.Destination' -StringValues $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-PSFMessage -Level Debug -String 'Save-AzOpsManagementGroupChild.Subscription.NotFound' -StringValues $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 } } } } } 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-PSFMessage -Level Verbose -String 'Search-AzOpsAzGraph.Processing' -StringValues $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-PSFMessage -Level Verbose -String 'Search-AzOpsAzGraph.Processing.Done' -StringValues $Query return $resultsType } else { Write-PSFMessage -Level Verbose -String 'Search-AzOpsAzGraph.Processing.NoResult' -StringValues $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 into which context to change. .EXAMPLE > Set-AzOpsContext -ScopeObject $scopeObject Changes the current context to the subscription of $scopeObject. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [AzOpsScope] $ScopeObject ) begin { $context = Get-AzContext } process { if (-not $ScopeObject.Subscription) { return } if ($context.Subscription.Id -ne $ScopeObject.Subscription) { Write-PSFMessage -Level Verbose -String 'Set-AzOpsContext.Change' -StringValues $context.Subscription.Name, $ScopeObject.SubscriptionDisplayName, $ScopeObject.Subscription Set-AzContext -SubscriptionId $scopeObject.Subscription -WhatIf:$false } } } 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-PSFMessage -Level Verbose -String 'Set-AzOpsStringLength.ToLong' -StringValues $String,$MaxStringLength,$overSize -FunctionName 'Set-AzOpsStringLength' # 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-PSFMessage -Level Verbose -String 'Set-AzOpsStringLength.Shortened' -StringValues $String,$MaxStringLength -FunctionName 'Set-AzOpsStringLength' return $String } else { # Return original string, it is within limit Write-PSFMessage -Level Verbose -String 'Set-AzOpsStringLength.WithInLimit' -StringValues $String,$MaxStringLength -FunctionName 'Set-AzOpsStringLength' 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 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 = $false)] $FilePath ) process { Write-PSFMessage -Level Verbose -String 'Set-AzOpsWhatIfOutput.WhatIfFile' $tempPath = [System.IO.Path]::GetTempPath() if (-not (Test-Path -Path ($tempPath + 'OUTPUT.md'))) { New-Item -Path ($tempPath + 'OUTPUT.md') -WhatIf:$false New-Item -Path ($tempPath + 'OUTPUT.json') -WhatIf:$false } $resultHeadline = $FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1] # Measure input $Results.Changes content $resultJson = ($Results.Changes | ConvertTo-Json -Depth 100) $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) # Check if $existingContentStringMeasureMd and $resultStringMeasure exceed allowed size in $ResultSizeLimit if (($existingContentStringMeasureMd.Characters + $resultStringMeasure.Characters) -gt $ResultSizeLimit) { Write-PSFMessage -Level Warning -String 'Set-AzOpsWhatIfOutput.WhatIfFileMax' -StringValues $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, $Results, $resultHeadline } elseif ($Results -match 'What if operation failed') { $mdOutput = ':warning: WhatIf Results for Resource Deletion of {2}:{0}```{0}{1}{0}```' -f [environment]::NewLine, $Results, $resultHeadline } else { $mdOutput = ':white_check_mark: WhatIf Results for Resource Deletion of {2}:{0}```{0}{1}{0}```' -f [environment]::NewLine, $Results, $resultHeadline } } else { if ($existingContent.count -gt 0) { $existingContent += $results.Changes $existingContent = $existingContent | ConvertTo-Json -Depth 100 } else { $existingContent = $resultJson } $mdOutput = 'WhatIf Results for {2}:{0}```{0}{1}{0}```{0}' -f [environment]::NewLine, $resultString, $resultHeadline Write-PSFMessage -Level Verbose -String 'Set-AzOpsWhatIfOutput.WhatIfFileAddingJson' Set-Content -Path ($tempPath + 'OUTPUT.json') -Value $existingContent -WhatIf:$false } } if ((($mdOutput | Measure-Object -Line -Character -Word).Characters + $existingContentStringMeasureMd.Characters) -le $ResultSizeMaxLimit) { Write-PSFMessage -Level Verbose -String 'Set-AzOpsWhatIfOutput.WhatIfFileAddingMd' Add-Content -Path ($tempPath + 'OUTPUT.md') -Value $mdOutput -WhatIf:$false } else { Write-PSFMessage -Level Warning -String 'Set-AzOpsWhatIfOutput.WhatIfMessageMax' -StringValues $ResultSizeMaxLimit } } } 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 ThrottleLimit from previously default 10 to 5 if system has less than 2 cores $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-PSFMessage -Level Warning -String 'Initialize-AzOpsEnvironment.ThrottleLimit.Adjustment' -StringValues $throttleLimit, $cpuCores Set-PSFConfig -Module AzOps -Name Core.ThrottleLimit -Value 5 } } 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-PSFMessage -Level Important -String 'Initialize-AzOpsEnvironment.UsingCache' return } #region Initialize & Prepare Write-PSFMessage -Level Important -String 'Initialize-AzOpsEnvironment.Processing' $currentAzContext = Get-AzContext $tenantId = $currentAzContext.Tenant.Id Write-PSFMessage -Level Important -String '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-PSFMessage -Level Warning -String 'Initialize-AzOpsEnvironment.ManagementGroup.NoManagementGroupAccess' return } #region Validate root '/' permissions - different methods of getting current context depending on principalType $currentPrincipal = Get-AzOpsCurrentPrincipal -AzContext $currentAzContext $rootPermissions = Get-AzRoleAssignment -ObjectId $currentPrincipal.id -Scope "/" -ErrorAction SilentlyContinue -WarningAction SilentlyContinue if (-not $rootPermissions) { Write-PSFMessage -Level Important -String 'Initialize-AzOpsEnvironment.ManagementGroup.NoRootPermissions' -StringValues $currentAzContext.Account.Id $PartialMgDiscovery = $true } else { $PartialMgDiscovery = $false } #endregion Validate root '/' permissions #region Partial Discovery if ($PartialMgDiscoveryRoot) { Write-PSFMessage -Level Important -String '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-PSFMessage -Level Important -String 'Initialize-AzOpsEnvironment.ManagementGroup.Resolution' -StringValues $managementGroups.Count $tempResolved = foreach ($mgmtGroup in $managementGroups) { Write-PSFMessage -Level Verbose -String 'Initialize-AzOpsEnvironment.ManagementGroup.Expanding' -StringValues $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-PSFMessage -Level Important -String 'Initialize-AzOpsEnvironment.Processing.Completed' } } 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-PSFMessage -Level Verbose -String 'Invoke-AzOpsPull.Validating.UserRole' $null = Get-AzADUser -First 1 -ErrorAction Stop Write-PSFMessage -Level Verbose -String 'Invoke-AzOpsPull.Validating.UserRole.Success' Write-PSFMessage -Level Verbose -String '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-PSFMessage -Level Verbose -String 'Invoke-AzOpsPull.Validating.AADP2.Success' } else { Write-PSFMessage -Level Warning -String 'Invoke-AzOpsPull.Validating.AADP2.Failed' $SkipPim = $true } } catch { Write-PSFMessage -Level Warning -String 'Invoke-AzOpsPull.Validating.UserRole.Failed' $SkipPim = $true } } if ($false -eq $SkipChildResource -or $false -eq $SkipResource -and $true -eq $SkipResourceGroup) { Write-PSFMessage -Level Warning -String 'Invoke-AzOpsPull.Validating.ResourceGroupDiscovery.Failed' -StringValues "`n" } $resourceTypeDiff = Compare-Object -ReferenceObject $SkipResourceType -DifferenceObject $IncludeResourceType -ExcludeDifferent if ($resourceTypeDiff) { Write-PSFMessage -Level Warning -Message "SkipResourceType setting conflict found in IncludeResourceType, ignoring $($resourceTypeDiff.InputObject) from IncludeResourceType. To avoid this remove $($resourceTypeDiff.InputObject) from IncludeResourceType or SkipResourceType" $IncludeResourceType = $IncludeResourceType | Where-Object { $_ -notin $resourceTypeDiff.InputObject } } Assert-AzOpsInitialization -Cmdlet $PSCmdlet -StatePath $StatePath $tenantId = (Get-AzContext).Tenant.Id Write-PSFMessage -Level Important -String 'Invoke-AzOpsPull.Tenant' -StringValues $tenantId Write-PSFMessage -Level Verbose -String 'Invoke-AzOpsPull.TemplateParameterFileSuffix' -StringValues (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix') Write-PSFMessage -Level Important -String '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-PSFMessage -Level Important -String '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-PSFMessage -Level Important -String 'Invoke-AzOpsPull.Building.State' -StringValues $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) { Get-AzOpsResourceDefinition -Scope $subscription.id @parameters } } #endregion Root Scopes } end { $stopWatch.Stop() Write-PSFMessage -Level Important -String 'Invoke-AzOpsPull.Duration' -StringValues $stopWatch.Elapsed -Data @{ Elapsed = $stopWatch.Elapsed } } } 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 Resolve-ArmFileAssociation { [CmdletBinding()] param ( [AzOpsScope] $ScopeObject, [string] $FilePath, [string] $AzOpsMainTemplate ) #region Initialization Prep $common = @{ Level = 'Host' Tag = 'pwsh' FunctionName = 'Invoke-AzOpsPush' Target = $ScopeObject } $result = [PSCustomObject] @{ TemplateFilePath = $null TemplateParameterFilePath = $null DeploymentName = $null ScopeObject = $ScopeObject Scope = $ScopeObject.Scope } $fileItem = Get-Item -Path $FilePath if ($fileItem.Extension -notin '.json' , '.bicep') { Write-PSFMessage -Level Warning -String 'Invoke-AzOpsPush.Resolve.NoJson' -StringValues $fileItem.FullName -Tag pwsh -FunctionName 'Invoke-AzOpsPush' -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')) { $result.TemplateParameterFilePath = $fileItem.FullName $deploymentName = $fileItem.Name -replace (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix'), '' -replace ' ', '_' if ($deploymentName.Length -gt 53) { $deploymentName = $deploymentName.SubString(0, 53) } $result.DeploymentName = 'AzOps-{0}-{1}' -f $deploymentName, $deploymentRegionId #region Directly Associated Template file exists $templatePath = $fileItem.FullName -replace '.parameters.json', (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix') if (Test-Path $templatePath) { Write-PSFMessage -Level Verbose @common -String 'Invoke-AzOpsPush.Resolve.FoundTemplate' -StringValues $FilePath, $templatePath $result.TemplateFilePath = $templatePath return $result } #endregion Directly Associated Template file exists #region Directly Associated bicep template exists $bicepTemplatePath = $fileItem.FullName -replace '.parameters.json', '.bicep' if (Test-Path $bicepTemplatePath) { Write-PSFMessage -Level Verbose @common -String 'Invoke-AzOpsPush.Resolve.FoundBicepTemplate' -StringValues $FilePath, $bicepTemplatePath $transpiledTemplatePath = ConvertFrom-AzOpsBicepTemplate -BicepTemplatePath $bicepTemplatePath $result.TemplateFilePath = $transpiledTemplatePath return $result } #endregion Directly Associated bicep template exists #region Check in the main template file for a match Write-PSFMessage -Level Important @common -String 'Invoke-AzOpsPush.Resolve.NotFoundTemplate' -StringValues $FilePath, $templatePath $mainTemplateItem = Get-Item $AzOpsMainTemplate Write-PSFMessage -Level Verbose @common -String 'Invoke-AzOpsPush.Resolve.FromMainTemplate' -StringValues $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-PSFMessage -Level Verbose @common -String 'Invoke-AzOpsPush.Resolve.MainTemplate.Supported' -StringValues $effectiveResourceType, $mainTemplateItem.FullName $result.TemplateFilePath = $mainTemplateItem.FullName return $result } Write-PSFMessage -Level Warning -String 'Invoke-AzOpsPush.Resolve.MainTemplate.NotSupported' -StringValues $effectiveResourceType, $mainTemplateItem.FullName -Tag pwsh -FunctionName 'Invoke-AzOpsPush' -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) { Write-PSFMessage -Level Verbose @common -String 'Invoke-AzOpsPush.Resolve.ParameterFound' -StringValues $FilePath, $parameterPath $result.TemplateParameterFilePath = $parameterPath } else { Write-PSFMessage -Level Verbose @common -String 'Invoke-AzOpsPush.Resolve.ParameterNotFound' -StringValues $FilePath, $parameterPath } $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 } #endregion Utility Functions $common = @{ Level = 'Host' Tag = 'git' } $WhatIfPreferenceState = $WhatIfPreference $WhatIfPreference = $false } process { if (-not $ChangeSet) { return } Assert-AzOpsInitialization -Cmdlet $PSCmdlet -StatePath $StatePath #Supported resource types for deletion $DeletionSupportedResourceType = (Get-PSFConfigValue -FullName 'AzOps.Core.DeletionSupportedResourceType') #region Categorize Input Write-PSFMessage -Level Important @common -String '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-PSFMessage -Level Important @common -String 'Invoke-AzOpsPush.Change.AddModify' foreach ($item in $addModifySet) { Write-PSFMessage -Level Important @common -String 'Invoke-AzOpsPush.Change.AddModify.File' -StringValues $item } } if ($DeleteSetContents -and $deleteSet) { Write-PSFMessage -Level Important @common -String 'Invoke-AzOpsPush.Change.Delete' $DeleteSetContents = $DeleteSetContents -join "" -split "-- " | Where-Object { $_ } foreach ($item in $deleteSet) { Write-PSFMessage -Level Important @common -String 'Invoke-AzOpsPush.Change.Delete.File' -StringValues $item foreach ($content in $DeleteSetContents) { if ($content.Contains($item)) { $jsonValue = $content.replace($item, "") if (-not(Test-Path -Path (Split-Path -Path $item))) { New-Item -Path (Split-Path -Path $item) -ItemType Directory | Out-Null } Set-Content -Path $item -Value $jsonValue } } } } #endregion Categorize Input #region Deploy State $common.Tag = 'pwsh' # 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-PSFMessage -Level Important @common -String 'Invoke-AzOpsPush.Deploy.Subscription' -StringValues $addition -Target $addition $newStateDeploymentCmd.Process($addition) } foreach ($addition in $addModifySet) { if ($addition -notmatch '/*.providerfeatures.json$') { continue } Write-PSFMessage -Level Important @common -String 'Invoke-AzOpsPush.Deploy.ProviderFeature' -StringValues $addition -Target $addition $newStateDeploymentCmd.Process($addition) } foreach ($addition in $addModifySet) { if ($addition -notmatch '/*.resourceproviders.json$') { continue } Write-PSFMessage -Level Important @common -String 'Invoke-AzOpsPush.Deploy.ResourceProvider' -StringValues $addition -Target $addition $newStateDeploymentCmd.Process($addition) } $newStateDeploymentCmd.End() #endregion Deploy State $deploymentList = foreach ($addition in $addModifySet | Where-Object { $_ -match ((Get-Item $StatePath).Name) }) { # Avoid duplicate entries in the deployment list if ($addition.EndsWith(".parameters.json")) { if ($addModifySet -contains $addition.Replace(".parameters.json", ".json") -or $addModifySet -contains $addition.Replace(".parameters.json", ".bicep")) { continue } } # Handle Bicep templates if ($addition.EndsWith(".bicep")) { $transpiledTemplatePath = ConvertFrom-AzOpsBicepTemplate -BicepTemplatePath $addition $addition = $transpiledTemplatePath } try { $scopeObject = New-AzOpsScope -Path $addition -StatePath $StatePath -ErrorAction Stop } catch { Write-PSFMessage -Level Debug @common -String 'Invoke-AzOpsPush.Scope.Failed' -StringValues $addition, $StatePath -Target $addition -ErrorRecord $_ continue } Resolve-ArmFileAssociation -ScopeObject $scopeObject -FilePath $addition -AzOpsMainTemplate $AzOpsMainTemplate } $deletionList = foreach ($deletion in $deleteSet | Where-Object { $_ -match ((Get-Item $StatePath).Name) }) { if ($deletion.EndsWith(".bicep")) { continue } $templateContent = Get-Content $deletion | ConvertFrom-Json -AsHashtable $schemavalue = '$schema' if ($templateContent.$schemavalue -like "*deploymentParameters.json#" -and (-not($templateContent.parameters.input.value.type -in $DeletionSupportedResourceType))) { Write-PSFMessage -Level Warning -String 'Remove-AzOpsDeployment.SkipUnsupportedResource' -StringValues $deletion -Target $scopeObject continue } elseif ($templateContent.$schemavalue -like "*deploymentTemplate.json#" -and (-not($templateContent.resources[0].type -in $DeletionSupportedResourceType))) { Write-PSFMessage -Level Warning -String 'Remove-AzOpsDeployment.SkipUnsupportedResource' -StringValues $deletion -Target $scopeObject continue } try { $scopeObject = New-AzOpsScope -Path $deletion -StatePath $StatePath -ErrorAction Stop } catch { Write-PSFMessage -Level Debug @common -String 'Invoke-AzOpsPush.Scope.Failed' -StringValues $deletion, $StatePath -Target $deletion -ErrorRecord $_ continue } Resolve-ArmFileAssociation -ScopeObject $scopeObject -FilePath $deletion -AzOpsMainTemplate $AzOpsMainTemplate } #Required deletion order $deletionListPriority = @( "locks", "policyExemptions", "policyAssignments", "policySetDefinitions", "policyDefinitions" ) #Sort 'deletionList' based on 'deletionListPriority' $deletionList = $deletionList | Sort-Object -Property {$deletionListPriority.IndexOf($_.ScopeObject.Resource)} #If addModifySet exists and no deploymentList has been generated at the same time as the StatePath root has additional directories, exit with terminating error if (($addModifySet -and -not $deploymentList) -and (Get-ChildItem -Path $StatePath -Directory)) { Write-PSFMessage -Level Critical @common -String 'Invoke-AzOpsPush.DeploymentList.NotFound' throw } #Starting Tenant Deployment $WhatIfPreference = $WhatIfPreferenceState $uniqueProperties = 'Scope', 'DeploymentName', 'TemplateFilePath', 'TemplateParameterFilePath' $deploymentList | Select-Object $uniqueProperties -Unique | New-AzOpsDeployment -WhatIf:$WhatIfPreference #Removal of Supported resourceTypes $uniqueProperties = 'Scope', 'TemplateFilePath', 'TemplateParameterFilePath' $removalJob = $deletionList | Select-Object $uniqueProperties -Unique | Remove-AzOpsDeployment -WhatIf:$WhatIfPreference if ($removalJob.dependencyMissing -eq $true) { Write-PSFMessage -Level Critical @common -String 'Invoke-AzOpsPush.Dependency.Missing' throw } } } 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.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.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') -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.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 'Used in combination with AZOPS_SUPPORT_PARTIAL_MG_DISCOVERY, example value: "Contoso","Tailspin","Management"' 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.SubscriptionsToIncludeResourceGroups -Value @('*') -Initialize -Validation stringarray -Description 'Requires SkipResourceGroup to be false. Subscription ID or Display Name that matches the filter. Powershell filter that matches with like operator is supported.' Set-PSFConfig -Module AzOps -Name Core.TemplateParameterFileSuffix -Value '.json' -Initialize -Validation string -Description 'parameter file suffix to look for' 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 |