|
| 1 | +--- |
| 2 | +title: "Validate Azure Resource Relationships with PSRule and PowerShell Graphs" |
| 3 | +description: "Learn how to combine PSRule for Azure with PSQuickGraph to validate relationships that span multiple Bicep resources, such as VNet integration and private endpoint connectivity." |
| 4 | +author: eosfor |
| 5 | +authors: |
| 6 | + - eosfor |
| 7 | +date: 2026-07-24T00:00:00+00:00 |
| 8 | +categories: |
| 9 | + - DevOps |
| 10 | +tags: |
| 11 | + - psrule |
| 12 | + - azure |
| 13 | + - bicep |
| 14 | + - graph |
| 15 | + - infrastructure-as-code |
| 16 | +--- |
| 17 | + |
| 18 | +PSRule for Azure makes it straightforward to validate the properties of individual |
| 19 | +resources before deployment. But some architecture requirements describe a |
| 20 | +relationship, not a property: every Function App must connect to the expected virtual |
| 21 | +network, or every application must have exactly one private endpoint. |
| 22 | + |
| 23 | +This article shows how to collect those relationships in a PowerShell graph while |
| 24 | +PSRule processes a Bicep deployment, then validate the completed graph at the end of |
| 25 | +the pipeline. |
| 26 | + |
| 27 | +## Why per-resource rules are not enough |
| 28 | + |
| 29 | +Consider an architecture with a Function App, a virtual network, an integration |
| 30 | +subnet, and a private endpoint subnet. We can easily write rules that check: |
| 31 | + |
| 32 | +- whether the Function App has public access disabled; |
| 33 | +- whether the virtual network uses the correct address space; |
| 34 | +- whether the expected subnets exist. |
| 35 | + |
| 36 | +Those checks still do not prove that the Function App is connected to the correct |
| 37 | +subnet or that its private endpoint belongs to the expected virtual network. The |
| 38 | +required information is spread across several expanded ARM resources. |
| 39 | + |
| 40 | +A graph is a natural representation of this problem: |
| 41 | + |
| 42 | +- resources and subnets become vertices; |
| 43 | +- references between them become edges; |
| 44 | +- an architecture requirement becomes a path or edge-count assertion. |
| 45 | + |
| 46 | +## Prepare PSRule and the graph module |
| 47 | + |
| 48 | +The example uses |
| 49 | +[PSRule.Rules.Azure](https://github.com/Azure/PSRule.Rules.Azure) to expand and |
| 50 | +analyze Bicep and |
| 51 | +[PSQuickGraph](https://github.com/eosfor/PSGraph) to build the dependency graph. |
| 52 | + |
| 53 | +```powershell |
| 54 | +Install-Module -Name PSRule.Rules.Azure -Scope CurrentUser |
| 55 | +Install-Module -Name PSQuickGraph -Scope CurrentUser |
| 56 | +
|
| 57 | +Import-Module PSQuickGraph |
| 58 | +``` |
| 59 | + |
| 60 | +In `ps-rule.yaml`, enable Bicep expansion and include the convention that will collect |
| 61 | +relationships: |
| 62 | + |
| 63 | +```yaml |
| 64 | +include: |
| 65 | + module: |
| 66 | + - PSRule.Rules.Azure |
| 67 | + - PSQuickGraph |
| 68 | + |
| 69 | +convention: |
| 70 | + include: |
| 71 | + - FullConnectivityTest |
| 72 | + |
| 73 | +configuration: |
| 74 | + AZURE_BICEP_FILE_EXPANSION: true |
| 75 | +``` |
| 76 | +
|
| 77 | +## Collect relationships with a convention |
| 78 | +
|
| 79 | +A PSRule |
| 80 | +[convention](https://microsoft.github.io/PSRule/v2/concepts/PSRule/en-US/about_PSRule_Conventions/) |
| 81 | +can run custom PowerShell at different stages of the pipeline. Its `Process` block |
| 82 | +runs once for each input object, while its `End` block runs after all objects have |
| 83 | +been processed. |
| 84 | + |
| 85 | +That lifecycle gives us a convenient two-phase approach: |
| 86 | + |
| 87 | +1. Add every relevant resource and relationship to a graph. |
| 88 | +2. Validate the graph only after the complete deployment has been seen. |
| 89 | + |
| 90 | +The following is a simplified version of the convention. The example uses |
| 91 | +child-to-parent edges so a valid dependency chain ends at the virtual network. |
| 92 | + |
| 93 | +```powershell |
| 94 | +$global:vnetId = $null |
| 95 | +$global:webSites = @() |
| 96 | +$global:connectionGraph = New-Graph |
| 97 | +$global:privateEndpointGraph = New-Graph |
| 98 | +
|
| 99 | +Export-PSRuleConvention 'FullConnectivityTest' -Process { |
| 100 | + if ($TargetObject.Type -eq 'Microsoft.Network/virtualNetworks') { |
| 101 | + $global:vnetId = $TargetObject.Id |
| 102 | +
|
| 103 | + foreach ($subnetResource in $TargetObject.Resources) { |
| 104 | + Add-Edge ` |
| 105 | + -From $subnetResource.Id ` |
| 106 | + -To $TargetObject.Id ` |
| 107 | + -Graph $global:connectionGraph |
| 108 | + |
| 109 | + Add-Edge ` |
| 110 | + -From $subnetResource.Id ` |
| 111 | + -To $TargetObject.Id ` |
| 112 | + -Graph $global:privateEndpointGraph |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + if ($TargetObject.Type -eq 'Microsoft.Web/sites') { |
| 117 | + $vnetIntegration = $TargetObject.Resources | |
| 118 | + Where-Object Type -eq 'Microsoft.Web/sites/networkConfig' |
| 119 | + |
| 120 | + $global:webSites += $TargetObject.Id |
| 121 | + |
| 122 | + Add-Edge ` |
| 123 | + -From $TargetObject.Id ` |
| 124 | + -To $vnetIntegration.Properties.SubnetResourceId ` |
| 125 | + -Graph $global:connectionGraph |
| 126 | + } |
| 127 | + |
| 128 | + if ($TargetObject.Type -eq 'Microsoft.Network/privateEndpoints') { |
| 129 | + Add-Edge ` |
| 130 | + -From $TargetObject.Id ` |
| 131 | + -To $TargetObject.Properties.Subnet.Id ` |
| 132 | + -Graph $global:privateEndpointGraph |
| 133 | + |
| 134 | + foreach ($connection in $TargetObject.Properties.PrivateLinkServiceConnections) { |
| 135 | + Add-Edge ` |
| 136 | + -From $connection.Properties.PrivateLinkServiceId ` |
| 137 | + -To $TargetObject.Id ` |
| 138 | + -Graph $global:privateEndpointGraph |
| 139 | + } |
| 140 | + } |
| 141 | +} -End { |
| 142 | + # The completed graphs are validated here. |
| 143 | +} |
| 144 | +``` |
| 145 | + |
| 146 | +The sample uses global variables because the state must remain available across |
| 147 | +PSRule callback invocations. In a larger rule set, wrap this state in a single object |
| 148 | +and reset it before each run. |
| 149 | + |
| 150 | +## Validate complete dependency paths |
| 151 | + |
| 152 | +Once PSRule reaches the `End` block, every relevant Bicep resource has been expanded |
| 153 | +and processed. We can now ask questions about the deployment as a whole. |
| 154 | + |
| 155 | +For example, the following check confirms that every Function App has a VNet |
| 156 | +integration path: |
| 157 | + |
| 158 | +```powershell |
| 159 | +foreach ($webApp in $global:webSites) { |
| 160 | + $path = Get-GraphPath ` |
| 161 | + -From $webApp ` |
| 162 | + -To $global:vnetId ` |
| 163 | + -Graph $global:connectionGraph |
| 164 | +
|
| 165 | + if ($null -eq $path) { |
| 166 | + throw "No VNet integration path was found for Function App: $webApp" |
| 167 | + } |
| 168 | +} |
| 169 | +``` |
| 170 | + |
| 171 | +We can apply the same idea to private endpoint connectivity: |
| 172 | + |
| 173 | +```powershell |
| 174 | +foreach ($webApp in $global:webSites) { |
| 175 | + $path = Get-GraphPath ` |
| 176 | + -From $webApp ` |
| 177 | + -To $global:vnetId ` |
| 178 | + -Graph $global:privateEndpointGraph |
| 179 | +
|
| 180 | + if ($null -eq $path) { |
| 181 | + throw "No private endpoint path was found for Function App: $webApp" |
| 182 | + } |
| 183 | +} |
| 184 | +``` |
| 185 | + |
| 186 | +Throwing from the `End` block causes the validation run to fail. This works well in |
| 187 | +CI, although it does not produce the same detailed assertion output as a normal |
| 188 | +PSRule `Rule` block. |
| 189 | + |
| 190 | +Run the rules against the Bicep entry point with: |
| 191 | + |
| 192 | +```powershell |
| 193 | +Invoke-PSRule -Format File -InputPath ./deployments/non-prod/main.bicep |
| 194 | +``` |
| 195 | + |
| 196 | +## Export the graph for troubleshooting |
| 197 | + |
| 198 | +The same model used for validation can produce a diagram. This is particularly |
| 199 | +helpful when a CI check reports a missing path and you need to see which relationship |
| 200 | +was absent. PSQuickGraph can export the graph in Graphviz DOT format; the |
| 201 | +[Graphviz](https://graphviz.org/download/) `dot` executable can then render it as SVG. |
| 202 | + |
| 203 | +```powershell |
| 204 | +Export-Graph ` |
| 205 | + -Graph $global:privateEndpointGraph ` |
| 206 | + -Format Graphviz ` |
| 207 | + -Path ./output/private-endpoints.dot |
| 208 | +
|
| 209 | +& dot ` |
| 210 | + -Tsvg ./output/private-endpoints.dot ` |
| 211 | + -o ./output/private-endpoints.svg |
| 212 | +``` |
| 213 | + |
| 214 | + |
| 215 | + |
| 216 | +This separates the solution into three clear steps: |
| 217 | + |
| 218 | +1. PSRule expands Bicep into resource objects. |
| 219 | +2. A convention converts cross-resource references into graph edges. |
| 220 | +3. Graph queries validate the architecture after all resources are available. |
| 221 | + |
| 222 | +The result complements normal per-resource rules instead of replacing them. Use |
| 223 | +regular PSRule assertions for local properties and graph assertions for requirements |
| 224 | +that span the deployment. |
| 225 | + |
| 226 | +A complete Bicep project, PSRule configuration, architecture document, and runnable |
| 227 | +Codespaces environment are available in the |
| 228 | +[psrule-demo repository](https://github.com/eosfor/psrule-demo). |
0 commit comments