Azure Microsoft Microsoft Graph PowerShell

Automating a Monthly Azure Update Compliance Report with Logic Apps + Azure Resource Graph

Most patching dashboards are great for interactive views—but what if your stakeholders want a scheduled email that shows the current patch compliance for only a scoped set of servers (for example, those tagged for patch governance)? That’s where a small, reliable custom report shines.

In this post I’ll walk through the exact solution I built: a Logic App that queries Azure Update Manager data via Azure Resource Graph (ARG), filters to VMs tagged Monthly_Patch : yes, formats the results into a clean HTML email, and sends it on a monthly cadence.

Why a custom report?

  • No native email report: Azure Update Manager provides blades and workbooks, but not a ready-to-send, nicely formatted email.
  • Audience-specific scoping: We only want to report on VMs with a specific business tag (Monthly_Patch : yes).
  • Consistent sorting & formatting: Stakeholders wanted alphabetical order, readable timestamps, color-coded rows, and centered table content.
  • Lightweight & fast: With ARG we can query Update Manager resources directly—no Log Analytics workspace required for this report.

What we’ll build

A monthly Logic App that:

  1. Queries Update Manager assessment and last installation results via ARG
  2. Queries ARG for VMs tagged Monthly_Patch : yes
  3. Filters the compliance data to those tagged VMs
  4. Renders a styled HTML table (centered cells, color-coded rows)
  5. Emails the report

The end result looks like this:

Prerequisites

  • A Logic App (Consumption or Standard) with System-assigned Managed Identity enabled
  • MI permission: Reader (or higher) on the subscription you will query
  • Ability to send mail via Office 365 connection (use a licensed mailbox)
  • Update Manager assessments enabled on your VMs

Architecture at a glance

  • Azure Resource Graph: fetches patch assessments and installation summaries
  • Logic App: orchestrates queries, filters results to tagged VMs, builds HTML, sends mail
  • Tag filter: only VMs with Monthly_Patch : yes are included

The final Logic App (production)

Below is the exact, working flow and the reasoning behind each step.

Trigger — Recurrence

Runs monthly (you can change the cadence to weekly/daily/custom).

Action 1 — HTTP (ARG: Update compliance)

  • What: Runs an ARG query to pull the latest Update Manager assessment + last install per VM, calculates a compliance label, and sorts alphabetically by VM.
  • Why: Gives us all the fields we need in one pass, fast.
  • Notes: Auth with Managed Identity; audience https://management.azure.com/.

Endpoint

POST https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01

Method: Post

Headers

Content-Type: application/json

Body (Replace the subscription)

{
"subscriptions": [
"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
],
"query": "patchassessmentresources | where type !has 'softwarepatches' | extend p = parse_json(properties) | extend LastAssessment = todatetime(p.lastModifiedDateTime), OSType = tostring(p.osType), Critical = toint(p.availablePatchCountByClassification.critical), Security = toint(p.availablePatchCountByClassification.security), Other = toint(p.availablePatchCountByClassification.updates), PendingReboot = tostring(p.rebootPending), RG = tostring(split(id, '/')[4]), VM = tostring(split(id, '/')[8]), SubId = tostring(split(id, '/')[2]) | summarize arg_max(LastAssessment, *) by id | project SubId, RG, VM, OSType, LastAssessment, Critical, Security, Other, PendingReboot | join kind=fullouter ( patchinstallationresources | where type !has 'softwarepatches' | extend p = parse_json(properties) | extend LastInstall = todatetime(p.lastModifiedDateTime), OSTypeIns = tostring(p.osType), Installed = toint(p.installedPatchCount), Failed = toint(p.failedPatchCount), Pending = toint(p.pendingPatchCount), NotSelected = toint(p.notSelectedPatchCount), RG = tostring(split(id, '/')[4]), VM = tostring(split(id, '/')[8]), SubId = tostring(split(id, '/')[2]) | summarize arg_max(LastInstall, *) by SubId, RG, VM | project SubId, RG, VM, LastInstall, Installed, Failed, Pending, NotSelected, OSTypeIns ) on SubId, RG, VM | extend Critical = coalesce(Critical, 0), Security = coalesce(Security, 0), Other = coalesce(Other, 0), Installed = coalesce(Installed, 0), Failed = coalesce(Failed, 0), Pending = coalesce(Pending, 0), NotSelected = coalesce(NotSelected, 0), PendingReboot = tostring(coalesce(PendingReboot, 'false')), LastAssessment = todatetime(LastAssessment), LastInstall = todatetime(LastInstall), OSType = coalesce(OSType, OSTypeIns) | extend NonCompliantCount = Critical + Security | extend Compliance = case(isnull(LastAssessment), 'No assessment', NonCompliantCount > 0, 'Non-compliant', PendingReboot == 'true', 'Pending reboot', 'Compliant') | extend SortKey = tolower(VM) | order by SortKey asc | project SubId, RG, VM, OSType, LastAssessment, LastInstall, Critical, Security, Other, Installed, Failed, Pending, NotSelected, PendingReboot, Compliance | limit 1000",
"options": {
"resultFormat": "objectArray"
}
}

Highlights inside the KQL:

  • Join assessment and installation resources
  • Coalesce numeric fields
  • Calculate a Compliance label
    (No assessment, Non-compliant, Pending reboot, Compliant)
  • Sort with SortKey = tolower(VM) so the email rows stay in alpha order

Authentication


Action 2 — Parse JSON

Parses the compliance HTTP results .

Content: Body (From the Action 1)

Schema

{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object"
}
},
"totalRecords": {
"type": "integer"
},
"count": {
"type": "integer"
},
"facets": {
"type": "array"
},
"resultTruncated": {
"type": "string"
}
},
"required": [
"data"
]
}

Action 3 — HTTP_TaggedVMs (ARG: Scope filter)

Follow the same steps as the Action 1, but with a different body code

  • What: Queries ARG for VMs with Monthly_Patch : yes
  • Why: Lets us scope the final report to the exact VM population stakeholders care about.

KQL (You need to customise the subscription and the tag here)

{
"subscriptions": [
"
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
],
“query”: “resources | where type =~ ‘microsoft.compute/virtualmachines’ and tostring(tags[‘Monthly_Patch’]) =~ ‘yes’ | project id, subscriptionId, resourceGroup, name”,
“options”: {
“resultFormat”: “objectArray”
}
}

Action 4 — ParseJSON_TaggedVMs

Content: Body (From the Action 3)

Schema

{
"properties": {
"data": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "string"
},
"subscriptionId": {
"type": "string"
},
"resourceGroup": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"subscriptionId",
"resourceGroup",
"name"
]
}
}
}
}

Action 5 — Initialize variable (TaggedIds)

Creates an empty array to hold the allowed resource IDs.

Action 6 — ForEach_TaggedVMs → Append to array

  • Iterate the tagged VMs; push toLower(id) into TaggedIds.
  • Why: Normalize case before matching; ARM IDs can vary in case.
@toLower(item()?['id'])

Action 7 — Filter_Tagged (Query/Filter array)

  • From: the compliance data
  • Where: builds the VM’s ARM ID from SubId/RG/VM, lowercases it, and checks membership in TaggedIds.

@and(
not(empty(item()?['SubId'])),
not(empty(item()?['RG'])),
not(empty(item()?['VM'])),
contains(
variables('TaggedIds'),
tolower(
concat(
'/subscriptions/', item()?['SubId'],
'/resourceGroups/', item()?['RG'],
'/providers/Microsoft.Compute/virtualMachines/', item()?['VM']
)
)
)
)

Action 8 — Select

  • Shapes the fields your HTML row will reference (kept short and simple):

VM, RG, OSType, LastAssessment, LastInstall, Critical, Security, PendingReboot, Compliance

Keeping names short here makes the HTML step easy to read. If you prefer full names, you can rename here and update the row template accordingly.

Action 9 — Initialize variables (HTML)

Starts the HTML, CSS, and table header. We center only the table content:

[
{
"name": "HTML",
"type": "string",
"value": "<html>\n <head>\n <style>\n body { font-family: Arial, sans-serif; font-size: 13px; color: #333; }\n h2 { margin: 0 0 10px 0; }\n p { margin: 0 0 15px 0; }\n table { border-collapse: collapse; width: 100%; }\n th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: center; }\n th { background-color: #f2f2f2; font-weight: bold; }\n </style>\n </head>\n <body>\n <h2>Monthly Azure Update Compliance Report</h2>\n <p>Generated: @{utcNow()}</p>\n <table>\n <thead>\n <tr>\n <th>Virtual Machine</th>\n <th>Resource Group</th>\n <th>Operating System</th>\n <th>Last Assessment</th>\n <th>Last Installation</th>\n <th>Critical Updates</th>\n <th>Security Updates</th>\n <th>Pending Reboot</th>\n <th>Compliance Status</th>\n </tr>\n </thead>\n <tbody>\n"
}
]

Action 10 — For_each (Concurrency = 1) → Append row

Sequential on purpose, so the ordering from ARG is preserved.

Row template (color-coded):

<tr style="background-color:@{if(equals(items('For_each')?['Compliance'],'Compliant'),'#d4edda', if(equals(items('For_each')?['Compliance'],'Pending reboot'),'#fff3cd', if(equals(items('For_each')?['Compliance'],'Non-compliant'),'#f8d7da','#e2e3e5')))};">
<td>@{items('For_each')?['VM']}</td>
<td>@{items('For_each')?['RG']}</td>
<td>@{items('For_each')?['OSType']}</td>
<td>@{if(empty(items('For_each')?['LastAssessment']),'', replace(substring(string(items('For_each')?['LastAssessment']),0,16),'T',' '))}</td>
<td>@{if(empty(items('For_each')?['LastInstall']),'', replace(substring(string(items('For_each')?['LastInstall']),0,16),'T',' '))}</td>
<td>@{string(items('For_each')?['Critical'])}</td>
<td>@{string(items('For_each')?['Security'])}</td>
<td>@{string(items('For_each')?['PendingReboot'])}</td>
<td>@{items('For_each')?['Compliance']}</td>
</tr>

The date formatting here stays intentionally simple and null-safe (substring + replace).

Action 11 — Append_to_string_variable_1

Closes the HTML:

</tbody>
</table>
</body>
</html>

Action 12 — Send an email (V2)

  • Subject: Monthly Azure Update Compliance
  • Body: @{variables('HTML')}
  • Connection: an O365 connection using a licensed mailbox

Tips & pitfalls

  • Scope control: The tag filter ensures only governed VMs are reported.
  • Order: Keep For each → Concurrency at 1 to preserve sort order.
  • Null safety: Don’t call date format functions on nulls—this solution avoids that.
  • Access: The Logic App’s Managed Identity needs Reader on the subscription(s).
  • Performance: ARG is very fast; reports render in seconds even at scal

Nice enhancements (optional)

  • CSV attachment: Use Create CSV table on the Select output and attach it in the email.
  • Teams post: Post the same HTML to a channel for quick visibility.
  • Daily digest: Clone the Logic App, change schedule to daily, and rename the subject.

Wrap-up

Sometimes the gap between a great portal view and a stakeholder-friendly deliverable is a small, targeted automation. With Logic Apps + Azure Resource Graph, we created a report that is:

  • Accurate (sourced from Update Manager),
  • Focused (tag-scoped),
  • Readable (HTML email with color-coding),
  • Reliable (runs on a schedule, no manual steps).

Leave a Reply

Discover more from Get Practical

Subscribe now to keep reading and get access to the full archive.

Continue reading