From fb78135391db92b6913ceb696d0a2ab2757345c4 Mon Sep 17 00:00:00 2001 From: Marcello Formica Date: Tue, 28 Apr 2026 14:25:55 +0200 Subject: [PATCH] Add private-endpoint toggle and reduce what-if noise --- Bicep/getavailability.bicep | 60 +++-- Bicep/parameters.dev.bicepparam | 27 ++ README.md | 426 +++++++++----------------------- 3 files changed, 181 insertions(+), 332 deletions(-) diff --git a/Bicep/getavailability.bicep b/Bicep/getavailability.bicep index cd9178c..b8375ea 100644 --- a/Bicep/getavailability.bicep +++ b/Bicep/getavailability.bicep @@ -42,17 +42,20 @@ param functionAppName string @description('Name of the Application Insights instance for Function App monitoring.') param applicationInsightsName string +@description('Create private endpoints for the Storage Account and Function App. When false, both resources remain publicly reachable.') +param usePrivateEndpoints bool = true + @description('Subnet resource ID for Function App VNet integration. Must be delegated to Microsoft.App/environments.') param fnSubnetId string -@description('Subnet resource ID for Private Endpoints.') -param peSubnetId string +@description('Subnet resource ID for Private Endpoints. Required only when usePrivateEndpoints is true.') +param peSubnetId string = '' -@description('Subscription ID containing existing Private DNS Zones.') -param dnsZonesSubscriptionId string +@description('Subscription ID containing existing Private DNS Zones. Required only when usePrivateEndpoints is true.') +param dnsZonesSubscriptionId string = '' -@description('Resource group name containing existing Private DNS Zones.') -param dnsZonesResourceGroupName string +@description('Resource group name containing existing Private DNS Zones. Required only when usePrivateEndpoints is true.') +param dnsZonesResourceGroupName string = '' @description('Comma-separated list of Azure subscription names or IDs to monitor (written to GETAVAIL_SUBSCRIPTIONS app setting).') param getavailSubscriptions string @@ -78,14 +81,18 @@ var roleDefinitions = { storageBlobDataOwner: 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' } +var storagePublicNetworkAccess = usePrivateEndpoints ? 'Disabled' : 'Enabled' +var storageDefaultAction = usePrivateEndpoints ? 'Deny' : 'Allow' +var functionAppPublicNetworkAccess = usePrivateEndpoints ? 'Disabled' : 'Enabled' + // ── Existing Private DNS Zones ─────────────────────────────────────────────── -resource blobDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = { +resource blobDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (usePrivateEndpoints) { name: 'privatelink.blob.${environment().suffixes.storage}' scope: resourceGroup(dnsZonesSubscriptionId, dnsZonesResourceGroupName) } -resource webAppDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = { +resource webAppDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (usePrivateEndpoints) { name: 'privatelink.azurewebsites.net' scope: resourceGroup(dnsZonesSubscriptionId, dnsZonesResourceGroupName) } @@ -281,9 +288,9 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = { supportsHttpsTrafficOnly: true networkAcls: { bypass: 'AzureServices' - defaultAction: 'Deny' + defaultAction: storageDefaultAction } - publicNetworkAccess: 'Disabled' + publicNetworkAccess: storagePublicNetworkAccess encryption: { services: { blob: { @@ -294,17 +301,23 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = { } resource blobServices 'blobServices' = { name: 'default' - properties: {} + properties: { + deleteRetentionPolicy: { + enabled: false + allowPermanentDelete: false + } + } } tags: commonTags } // ── Private Endpoint: Storage Account (blob) ───────────────────────────────── -resource storageAccountBlobPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-10-01' = { +resource storageAccountBlobPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-10-01' = if (usePrivateEndpoints) { name: 'pe-blob-${storageAccountName}' location: location - properties: { + properties: any({ + ipVersionType: 'IPv4' subnet: { id: peSubnetId } @@ -320,7 +333,7 @@ resource storageAccountBlobPrivateEndpoint 'Microsoft.Network/privateEndpoints@2 } ] customNetworkInterfaceName: 'nic-pe-${storageAccountName}' - } + }) tags: commonTags resource privateDnsZoneGroup 'privateDnsZoneGroups' = { @@ -346,6 +359,8 @@ resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { kind: 'web' properties: { Application_Type: 'web' + Flow_Type: 'Bluefield' + Request_Source: 'rest' WorkspaceResourceId: logAnalyticsWorkspace.id DisableLocalAuth: true } @@ -384,7 +399,7 @@ resource functionApp 'Microsoft.Web/sites@2024-11-01' = { serverFarmId: flexServicePlan.id httpsOnly: true virtualNetworkSubnetId: fnSubnetId - publicNetworkAccess: 'Disabled' + publicNetworkAccess: functionAppPublicNetworkAccess siteConfig: { minTlsVersion: '1.2' cors: { @@ -431,18 +446,21 @@ resource functionApp 'Microsoft.Web/sites@2024-11-01' = { TIMER_SCHEDULE: timerSchedule } } - dependsOn: [ - storageAccountBlobPrivateEndpoint // Create function only after storage PE is ready - ] + dependsOn: usePrivateEndpoints + ? [ + storageAccountBlobPrivateEndpoint // Create function only after storage PE is ready + ] + : [] tags: commonTags } // ── Private Endpoint: Function App (sites) ─────────────────────────────────── -resource functionAppPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-10-01' = { +resource functionAppPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-10-01' = if (usePrivateEndpoints) { name: 'pe-sites-${functionAppName}' location: location - properties: { + properties: any({ + ipVersionType: 'IPv4' subnet: { id: peSubnetId } @@ -458,7 +476,7 @@ resource functionAppPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-10- } ] customNetworkInterfaceName: 'nic-pe-${functionAppName}' - } + }) tags: commonTags resource privateDnsZoneGroup 'privateDnsZoneGroups' = { diff --git a/Bicep/parameters.dev.bicepparam b/Bicep/parameters.dev.bicepparam index e8e1fae..175f1a7 100644 --- a/Bicep/parameters.dev.bicepparam +++ b/Bicep/parameters.dev.bicepparam @@ -1,14 +1,41 @@ using './getavailability.bicep' +// Log Analytics workspace that stores the custom availability tables. param logAnalyticsWorkspaceName = 'log-getavail-itn-001' + +// Data Collection Endpoint used by the Function App to ingest custom logs. param dataCollectionEndpointName = 'dce-getavail-itn-001' + +// Data Collection Rule that maps the ingestion streams into the two custom tables. param dataCollectionRuleName = 'dcr-getavail-itn-001' + +// Storage account used by the Flex Consumption Function App for deployment and runtime blobs. param storageAccountName = 'flazstgetavailitn001' + +// Function App name for the scheduled Get-Availability runner. param functionAppName = 'fn-getavail-itn-001' + +// Application Insights instance wired to the Function App. param applicationInsightsName = 'appi-getavail-itn-001' + +// Toggle for private endpoints on the Storage Account and Function App. +// Keep true for the current private networking model; set false to allow public access instead. +param usePrivateEndpoints = true + +// Subnet reserved for private endpoints. Used only when usePrivateEndpoints = true. param peSubnetId = '/subscriptions/9068a229-f092-400e-8093-87e8e7d26ae1/resourceGroups/rg-alz-net-workloads-itn-001/providers/Microsoft.Network/virtualNetworks/vnet-alz-workloads-itn-001/subnets/snet-alz-pe-workloads-itn-001' + +// Subnet delegated to Microsoft.App/environments for Function App VNet integration. param fnSubnetId = '/subscriptions/9068a229-f092-400e-8093-87e8e7d26ae1/resourceGroups/rg-alz-net-workloads-itn-001/providers/Microsoft.Network/virtualNetworks/vnet-alz-workloads-itn-001/subnets/snet-alz-fn-workloads-itn-001' + +// Subscription containing the shared private DNS zones. Used only when usePrivateEndpoints = true. param dnsZonesSubscriptionId = 'c4e6c176-bf9c-4e8c-87b2-ebdceea7085f' + +// Resource group containing the shared private DNS zones. Used only when usePrivateEndpoints = true. param dnsZonesResourceGroupName = 'rg-alz-dns-hub-itn-001' + +// Comma-separated subscription names monitored by the Function App. param getavailSubscriptions = 'Flaz-Connectivity,Flaz-Management,Flaz-Identity,Flaz-Workloads' + +// Existing Log Analytics workspace used as the source for Activity Log and Resource Health KQL queries. param sourceWorkspaceId = 'f25755bb-9b46-4aac-bfae-6a10c4c18440' diff --git a/README.md b/README.md index 760f7b4..43d3a8e 100644 --- a/README.md +++ b/README.md @@ -3,23 +3,21 @@ [![CI](https://github.com/formicalab/Get-Availability/actions/workflows/ci.yml/badge.svg)](https://github.com/formicalab/Get-Availability/actions/workflows/ci.yml) [![Release](https://github.com/formicalab/Get-Availability/actions/workflows/release.yml/badge.svg)](https://github.com/formicalab/Get-Availability/actions/workflows/release.yml) -Reports month-scoped availability for Azure Virtual Machines, Azure SQL Databases, Azure Storage Accounts, and Azure Web Apps across one or more Azure subscriptions. +Get-Availability reports month-scoped availability for Azure Virtual Machines, Azure SQL Databases, Azure Storage Accounts, and Azure Web Apps across one or more Azure subscriptions. -No build step; runs as a standalone PowerShell 7 script or as an Azure Function on a schedule. Supports optional Log Analytics ingestion for dashboarding. +It runs either as a standalone PowerShell 7 script or as a timer-triggered Azure Function. Log Analytics ingestion is optional. The legacy C# implementation is preserved in [Old/README.md](Old/README.md) and is not actively maintained. -> A legacy C# (Native AOT) implementation is preserved in [`Old/`](Old/README.md) but is not actively maintained. +For each resource, the tool reports: -For each resource, the tool answers: - -- How many minutes had **suspect** availability (metric below 100% or null)? -- Of those, how many were **confirmed as platform faults** by Resource Health? -- How many were **excused** as normal operations (lifecycle activity, customer-initiated, metric issues)? -- How many remain **unresolved** after all classification attempts? -- What is the **availability percentage** (Available ÷ Eligible × 100)? +- suspect minutes +- confirmed platform faults +- excused minutes +- unresolved minutes +- availability percentage The relationship `Suspect = Faults + Excused + Unresolved` always holds. -## Usage +## Run the script ### Prerequisites @@ -28,28 +26,26 @@ The relationship `Suspect = Faults + Excused + Unresolved` always holds. | PowerShell | 7.0 or later (`pwsh`) | | Az.Accounts | `Install-Module Az.Accounts` | | Az.ResourceGraph | `Install-Module Az.ResourceGraph` | -| Azure auth | `Connect-AzAccount` (used by both Az modules and for ARM token acquisition) | - -If Azure authentication fails, the tool prints the module exception message directly. Re-run `Connect-AzAccount` to fix. +| Azure sign-in | `Connect-AzAccount` | -### Parameters +### Key parameters -| Parameter | Default | Description | +| Parameter | Default | Purpose | |---|---|---| -| `-Subscriptions` | *(required)* | One or more Azure subscription display names | -| `-Month` | *(required)* | Observation month in UTC, format `YYYYMM` | -| `-Kinds` | `vm,sql,storage,webapp` | Resource kinds to process | -| `-Resource` | *(all)* | Filter to a single resource name | -| `-Parallelism` | *(auto)* | Max concurrent API calls (scales to CPU cores, 4–16) | -| `-ActivityGraceMinutes` | `10` | Post-operation grace window for Activity Log lifecycle events | -| `-Batch` | off | Use the regional Metrics Batch API instead of per-resource calls | -| `-BatchSize` | `10` | Max resources per batch call (1–50); implies `-Batch` | -| `-SourceWorkspaceId` | *(none)* | Log Analytics workspace ID (GUID) used as a source for historical Activity Log and Resource Health data. Not the ingestion target. Fetches lifecycle events via a single bulk KQL query (faster for large estates). Resource Health uses a hybrid approach: KQL transitions cover the period beyond the REST API's ~30-day retention, while REST API transitions (curated, with corrected causes) are authoritative for the last ~30 days. | -| `-DceEndpoint` | *(none)* | Data Collection Endpoint ingestion URL. When provided together with `-DcrImmutableId`, results are sent to Log Analytics custom tables via the Azure Monitor Ingestion API. | -| `-DcrImmutableId` | *(none)* | Data Collection Rule immutable ID. Required together with `-DceEndpoint` to enable Log Analytics ingestion. | -| `-Version` | | Print version and exit | - -The observation window is a UTC calendar month: past months use the full calendar month, the current month is reported month-to-date. The requested month cannot start more than 90 days before the current UTC time. Metrics and Activity Log support that 90-day lookback; Health History is applied only for its overlap with the ~30-day REST API retention window. When `-SourceWorkspaceId` / `--workspace` is used, Health History coverage extends to the full observation period via a hybrid approach (Log Analytics for older transitions + REST API for the last ~30 days). +| `-Subscriptions` | required | Azure subscription names or IDs to inspect | +| `-Month` | required | Observation month in UTC, format `YYYYMM` | +| `-Kinds` | `vm,sql,storage,webapp` | Resource kinds to include | +| `-Resource` | all | Limit the run to one resource | +| `-Parallelism` | auto | Max concurrent API calls | +| `-ActivityGraceMinutes` | `10` | Grace window after lifecycle events | +| `-Batch` | off | Use the Metrics Batch API | +| `-BatchSize` | `10` | Batch size when `-Batch` is enabled | +| `-SourceWorkspaceId` | none | Source Log Analytics workspace for bulk Activity Log and older Resource Health history | +| `-DceEndpoint` | none | Data Collection Endpoint for ingestion | +| `-DcrImmutableId` | none | Data Collection Rule immutable ID for ingestion | +| `-Version` | off | Print version and exit | + +The observation window is a UTC calendar month. Past months use the full month; the current month is month-to-date. Metric and Activity Log collection support a 90-day lookback. Resource Health REST history is shorter, so `-SourceWorkspaceId` is the way to extend coverage beyond the last ~30 days. ### Examples @@ -60,354 +56,162 @@ The observation window is a UTC calendar month: past months use the full calenda # Multiple subscriptions, filtered by kind ./Functions/GetAvail/get-availability.ps1 -Subscriptions 'Contoso-Development','Contoso-Production' -Month 202603 -Kinds vm,sql -# Single resource with custom grace window +# Single resource with a custom grace window ./Functions/GetAvail/get-availability.ps1 -Subscriptions 'Contoso-Development' -Month 202603 -Resource myvm02 -ActivityGraceMinutes 15 -# Batch API with custom batch size -./Functions/GetAvail/get-availability.ps1 -Subscriptions 'Contoso-Production','Contoso-Development' -Month 202603 -BatchSize 20 - -# Use Log Analytics for Activity Log + Resource Health (faster, extended retention) +# Use Log Analytics as the source for Activity Log and older Resource Health history ./Functions/GetAvail/get-availability.ps1 -Subscriptions 'Contoso-Production' -Month 202603 -SourceWorkspaceId 'b233a4b7-3c43-433c-ac60-1f6ff217ddd4' # Send results to Log Analytics custom tables ./Functions/GetAvail/get-availability.ps1 -Subscriptions 'Contoso-Production' -Month 202603 ` -DceEndpoint 'https://dce-getavail-itn-001.italynorth-1.ingest.monitor.azure.com' ` -DcrImmutableId 'dcr-00000000000000000000000000000000' - -# Pipe results to CSV -./Functions/GetAvail/get-availability.ps1 -Subscriptions 'Contoso-Production' -Month 202603 | Export-Csv availability.csv ``` ### Output -The header line shows the observation window and total minutes: - -``` -Period: month 202602 (2026-02-01 00:00:00Z -> 2026-03-01 00:00:00Z, 40320 min) -``` - -If the observation window extends beyond the Resource Health retention window, an explicit warning is printed: - -``` -WARNING: Resource Health history covers only part of this period (2026-02-16 18:54:00Z -> 2026-03-01 00:00:00Z, 17586 of 40320 min). Earlier minutes will use Activity Log and metric fallback rules. -``` - -When `-SourceWorkspaceId` is used, the 30-day warning is suppressed (hybrid coverage applies) and an informational line is printed: - -``` -Log Analytics source workspace: b233a4b7-…-1f6ff217ddd4 (Activity Log via KQL, Resource Health via KQL + REST API hybrid) -``` - -Table view (Kind is abbreviated: VM, SQL, Storage, Web): - -| Subscription | Name | Kind | Location | Suspect | Faults | Excused | Unresolved | AvailMin | EligMin | Avail% | -|---|---|---|---|---:|---:|---:|---:|---:|---:|---:| -| Production | `sqlserver02/sqldb02` | SQL | westeurope | | | | | 40320 | 40320 | 100.00000 | -| Development | `devvm01a` | VM | northeurope | 23 | | 23 | | 14369 | 14369 | 100.00000 | -| Production | `storageaccount01` | Storage | westeurope | 14 | | 4 | 10 | 40306 | 40316 | 99.97520 | +The console output includes the observation window, any Resource Health coverage warning, a per-resource table, and aggregated summaries. The PowerShell version also emits objects to the pipeline so you can export to CSV or JSON. -Columns with value 0 are shown as blank. Resources with zero eligible minutes show `N/A`. A per-subscription summary is printed at the end, grouping resources by Kind + Location with aggregate availability. When multiple subscriptions are processed, a cross-subscription overall summary follows. +Resources with zero eligible minutes are shown as `N/A`. -Per-resource classification narration is also printed on the console: +## Classification model -``` - [sqlserver01/sqldb01] metric scan found 23 suspect min across 22 suspect gaps (null or <100% availability values) - [sqlserver01/sqldb01] checked against Activity Log: 23 suspect min explained by admin lifecycle events - [sqlserver01/sqldb01] eligible min = 40320 - 23 gap min excluded by Activity Log = 40297 -``` - -The PowerShell version also emits result objects to the pipeline, so output can be piped to `Export-Csv`, `ConvertTo-Json`, or further filtered. - -## How it works - -### Resource inventory - -A KQL query against the Resource Graph `resources` table returns all matching VMs, SQL databases (excluding system `master` DBs), Storage Accounts, and Web Apps (excluding Function Apps). Server-side filters are applied when `--kinds` or `--resource` are provided. - -### Metric collection - -Azure Monitor is queried at PT1M granularity (one data point per minute) with retry on 429/5xx errors. Two modes are available: - -- **Per-resource** (default): parallel ARM Metrics API calls, one per resource, with configurable parallelism. -- **Batch** (`--batch`): the regional [Azure Monitor Metrics Batch API](https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/migrate-to-batch-api). Resources are grouped by (subscription, region, kind) and sent in configurable chunks (`--batch-size`, default 10, max 50). The batch endpoint uses a separate token scope (`https://metrics.monitor.azure.com`) and each regional endpoint is validated before fetching. Wave-based processing with GC between waves bounds memory usage. +1. Resource inventory comes from Azure Resource Graph. +2. Azure Monitor metrics are collected at one-minute granularity. +3. Suspect minutes are classified using Activity Log lifecycle events, Resource Health, and metric fallback rules. +4. Final availability is computed from eligible minutes and available minutes. -| Resource type | Metrics | Native scale | Aggregation | -|---|---|---|---| -| Virtual Machine | `VmAvailabilityMetric` | 0.0–1.0 | Minimum | -| Azure SQL Database | `Availability` | 0–100 → normalised to 0.0–1.0 | Minimum | -| Storage Account | `Availability`, `Transactions` | 0–100 → normalised to 0.0–1.0 | Minimum, Total | -| Web App | `MemoryWorkingSet` | bytes (binary: >0 = available, 0 = stopped, null = suspect) | Average | +Metric sources by resource type: -Each data point is classified as follows: - -| Data point | Treatment | +| Resource type | Metrics | |---|---| -| Value = 100% | Adds `1.0` to `AvailableSum` (fully available) | -| 0% < value < 100% | Fractional contribution to `AvailableSum`; recorded as a **degraded suspect minute** | -| Value = 0% | Recorded as a **0%-valued suspect minute** | -| Value = null | Recorded as a **null suspect minute** | -| Storage: Transactions = 0 | No availability signal — counted as both suspect and excused, excluded from eligibility | -| Web App: MemoryWorkingSet > 0 | App process is alive — adds `1.0` to `AvailableSum` (fully available) | -| Web App: MemoryWorkingSet = 0 | App process is stopped — recorded as a **0%-valued suspect minute** | -| Web App: MemoryWorkingSet = null | Platform cannot collect data — recorded as a **null suspect minute** | - -Any minute with `null` or a value below `100%` is a **suspect minute**. Contiguous suspect minutes form a **suspect gap** (used for narration only — investigation is always minute-by-minute). - -### Suspect gap investigation - -For every resource with suspect minutes, the tool investigates each minute with the following precedence: - -**1. Activity Log** — Lifecycle operations representing deliberate administrative action are checked: +| Virtual Machine | `VmAvailabilityMetric` | +| Azure SQL Database | `Availability` | +| Storage Account | `Availability`, `Transactions` | +| Web App | `MemoryWorkingSet` | -- **All kinds**: resource creation (`*/write`) and deletion (`*/delete`) — minutes when the resource did not exist are excused (before first creation, between delete→recreate cycles, after final deletion) -- **VMs**: `start/action`, `deallocate/action`, `powerOff/action`, `restart/action` -- **SQL DBs**: `pause`, `resume` -- **Web Apps**: `stop/action`, `start/action`, `restart/action` +Classification precedence is strict: -Matching minutes are treated as customer/admin lifecycle activity and removed from eligibility. A configurable grace window (`--activity-grace-minutes`, default 10) extends these intervals to cover trailing transition datapoints. +1. Platform faults from Resource Health remain eligible downtime. +2. Matching lifecycle events are excused. +3. Resource Health `Unknown` or customer-initiated periods are excused. +4. Remaining nulls are treated as metric issues and excused. +5. Remaining zero or degraded values count against availability. -**2. Health History** — Resource Health transitions are converted into three interval types (below). Two data source modes are supported: +When `-SourceWorkspaceId` is set, Activity Log data and older Resource Health transitions are queried from Log Analytics in bulk, while the most recent Resource Health interval still comes from the REST API and remains authoritative. -- **REST API only** (default, no `-SourceWorkspaceId`): The [Activity Log REST API](https://learn.microsoft.com/azure/azure-monitor/platform/rest-activity-log#retrieve-activity-log-data) and the [Resource Health REST API](https://learn.microsoft.com/en-us/rest/api/resourcehealth/availability-statuses/list?view=rest-resourcehealth-2025-05-01) (`availabilityStatuses`, API version `2025-05-01`) are queried per-resource. Resource Health API has a ~30-day retention limit. -- **Hybrid: Log Analytics + REST API** (`-SourceWorkspaceId` / `--workspace`): A single bulk KQL query against the `AzureActivity` table fetches Activity Log lifecycle events and Resource Health transitions for all resources at once (faster for large estates: 1 query vs. thousands of REST calls). Resource Health transitions older than the REST API's ~30-day retention cutoff come from Log Analytics (workspace retention, typically 365 days). For the last ~30 days, the REST API is always queried and its transitions take precedence — REST data is authoritative because it provides curated synthetic entries that fill coverage gaps between health incidents and retroactively corrects cause classification. The two sources are merged chronologically to form a complete health timeline. Requires the target subscriptions to have diagnostic settings sending Activity Log data to the specified workspace. +## Log Analytics ingestion -Health transition interval types: +When `-DceEndpoint` and `-DcrImmutableId` are both supplied, the script sends results through the Azure Monitor Ingestion API into: -- **Fault** (`Unavailable` / `Degraded`) — confirmed platform issues -- **Unknown** — Azure cannot determine health (typically a monitoring gap, not an outage) -- **Customer-initiated** — detected via `reasonType` (`"Customer Initiated"` / `"User Initiated"`), `context` (`"Customer Initiated"`), or `healthEventCause` (`"UserInitiated"`) - -**3. Minute-by-minute classification** — Each suspect minute is classified with strict precedence: - -| Condition | Effect | +| Table | Content | |---|---| -| Health History: fault interval | Stays eligible, counts as downtime (platform fault wins even if Activity Log also matches) | -| Activity Log: lifecycle match | Excluded from eligibility | -| Health History: Unknown or customer-initiated | Excluded from eligibility (for degraded minutes, only customer-initiated excuses) | -| Remaining null | Metric issue — excluded from eligibility (missing telemetry ≠ downtime) | -| Remaining 0% | Trusted as downtime — stays eligible (explicit metric value) | -| Remaining degraded (0% < v < 100%) | Trusted as degraded availability — stays eligible | +| `GetAvailResources_CL` | Per-resource results | +| `GetAvailSummary_CL` | Aggregated summaries | -**Conservative on failure:** if a Resource Health API call fails, Activity Log matches still apply but no remaining minutes are excused through Health History. If the Activity Log call fails, Health History plus fallback rules still apply. +Payloads are gzip-compressed and batched to stay within ingestion limits. -### Result assembly +## Deploy to Azure -``` -EligibleMinutes = TotalMinutes − ExcusedMinutes -ExcusedMinutes = ActivityLogExcluded + HealthExplained + MetricIssueNulls + CustomerExcusedDegraded + ZeroTxMinutes -AvailableMinutes = Σ metric values above 0% (each 0.0–1.0) − CustomerExcusedDegradedAvailableSum -FaultMinutes = PlatformFaultGap + HealthConfirmedDegraded -UnresolvedMinutes = UnresolvedZeroDowntime + RemainingPositiveDegraded -AvailabilityPct = AvailableMinutes / EligibleMinutes × 100 -``` - -If the metric API returns no usable datapoints across the full period, the resource is excluded from availability calculations and shown as `N/A`. - -### Worked example +The Bicep template in [Bicep/](Bicep/) deploys the complete Azure-hosted stack: -A 30-day month for a VM (43,200 total minutes): +- Log Analytics workspace and custom tables +- Data Collection Endpoint and Data Collection Rule +- Storage account +- Flex Consumption Function App +- Application Insights +- RBAC assignments for ingestion and storage access +- Optional private endpoints for the storage account and Function App -| Category | Minutes | Effect | -|---|---:|---| -| Metric = 1.0 (fully available) | 40,000 | +40,000 to AvailableSum | -| Metric = 0.7 (degraded, unexplained) | 100 | +70 to AvailableSum, +100 Unresolved | -| Metric = 0.8 during restart lifecycle | 5 | +5 Excused, remove 4 from AvailableSum | -| Metric = null — Activity Log match | 40 | +40 Excused | -| Metric = null — Health `Unknown` | 3,000 | +3,000 Excused | -| Metric = null — unresolved | 30 | +30 Excused (metric issue) | -| Metric = 0% — fault confirmed | 10 | +10 Faults, stays eligible | -| Metric = 0% — unresolved | 10 | +10 Unresolved, stays eligible | +The Function App settings are auto-wired from the deployed resources, so after infrastructure deployment and `func publish` no manual app-setting step is required. -``` -SuspectMinutes = (40 + 3,000 + 30 + 10 + 10) + (100 + 5) = 3,195 -FaultMinutes = 10 -ExcusedMinutes = 40 + 3,000 + 30 + 5 = 3,075 -UnresolvedMinutes = 100 + 10 = 110 - check: 10 + 3,075 + 110 = 3,195 ✓ - -EligibleMinutes = 43,200 − 3,075 = 40,125 -AvailableMinutes = 40,000 + 70 − 4 = 40,066 -AvailabilityPct = 40,066 / 40,125 × 100 = 99.85390% -``` +### Networking modes -## Implementation notes +The template now supports two network modes controlled by `usePrivateEndpoints`: -- **`ForEach-Object -Parallel`** for concurrent metric, Activity Log, and Resource Health queries with configurable parallelism. -- **Shared `HttpClient`** with connection pooling — avoids per-request TCP/TLS overhead; streams JSON responses directly into `System.Text.Json` without intermediate string allocation. Used for both per-resource metrics and gap investigation paths. -- **Compiled metric processor** — the ~44k-datapoint-per-resource JSON processing loop is compiled as C# via `Add-Type` and runs at native .NET speed. -- **Compiled gap processor** — `ExpandToTickSet` (interval → `HashSet`) and `ClassifyGaps` (minute-by-minute classification) are also compiled via `Add-Type`. -- **Idempotent `Add-Type` guards** — each compiled C# block (`MetricProcessor`, `GapProcessor`) is independently guarded by a `PSTypeName` check so the script can be re-run within the same session. -- **HashSet-based interval containment** — suspect-minute classification pre-expands intervals into `HashSet` tick sets for O(1) lookups instead of linear scans. -- **O(1) JSON property access** — `TryGetProperty` hash lookup instead of `EnumerateObject` linear scan (~44k calls per resource per month). -- **Ticks-based metric keying** — `long` instead of `DateTime` for zero-allocation per data point. -- **`System.Text.Json`** for efficient JSON parsing — avoids large PSObject trees. - -## Log Analytics Ingestion (Optional) - -When `-DceEndpoint` and `-DcrImmutableId` are provided, the script sends results to two Log Analytics custom tables via the [Azure Monitor Ingestion API](https://learn.microsoft.com/azure/azure-monitor/logs/logs-ingestion-api-overview): - -| Table | Content | +| `usePrivateEndpoints` | Behavior | |---|---| -| `GetAvailResources_CL` | Per-resource detail (one row per resource per run) | -| `GetAvailSummary_CL` | Aggregated summaries (per Kind+Location, per subscription, overall) | +| `true` | Preserves the current deployment model: creates storage and Function App private endpoints, uses the private endpoint subnet and shared private DNS zones, and keeps public access disabled on those resources | +| `false` | Skips private endpoints and DNS zone references, and keeps the storage account and Function App publicly reachable | -Authentication uses `Get-AzAccessToken -ResourceUrl 'https://monitor.azure.com'`, which works identically for interactive sessions (`Connect-AzAccount`) and Azure Function managed identities. Payloads are gzip-compressed and batched at 900 KB to stay within API limits. - -The infrastructure is deployed via the Bicep template in [`Bicep/`](Bicep/). The template creates the full stack (Log Analytics workspace, custom tables, DCE, DCR, Storage Account, Function App, Application Insights, Private Endpoints, and RBAC) and **auto-wires all Function App settings** — after deployment and `func publish`, the function runs with no manual configuration. - -## Infrastructure Deployment - -### What it deploys - -The Bicep template deploys the following resources into the target resource group: - -| # | Resource | Purpose | -|---|----------|--------| -| 1 | **Log Analytics Workspace** | Stores availability data in custom tables; enables KQL queries and Workbooks | -| 2 | **Custom Table `GetAvailResources_CL`** | Per-resource availability detail (one row per resource per run) | -| 3 | **Custom Table `GetAvailSummary_CL`** | Aggregated summaries (per Kind+Location, per subscription, overall) | -| 4 | **Data Collection Endpoint (DCE)** | Ingestion URL for the Azure Monitor Ingestion API | -| 5 | **Data Collection Rule (DCR)** | Routes two custom streams to the corresponding tables with `TimeGenerated` injection | -| 6 | **Storage Account** | Backing store for the Function App (deployment blobs) | -| 7 | **Flex Consumption Plan** | Serverless hosting plan for the Function App | -| 8 | **Function App** | Runs the Get-Availability script on a schedule with system-assigned managed identity | -| 9 | **Application Insights** | Monitoring and telemetry for the Function App (Entra-only auth) | -| 10 | **Private Endpoint (Storage blob)** | Private connectivity for the Function App to its backing storage | -| 11 | **Private Endpoint (Function App sites)** | Private connectivity for publishing and management | +### Deployment prerequisites -RBAC role assignments are created automatically: +- Azure CLI with Bicep support +- Contributor on the target resource group +- A subnet delegated to `Microsoft.App/environments` for `fnSubnetId` +- If `usePrivateEndpoints = true`: a private endpoint subnet and existing private DNS zones for blob storage and Azure Websites -| Principal | Role | Scope | Why | -|-----------|------|-------|-----| -| Function App | Monitoring Metrics Publisher | DCR | Ingest custom logs via the Azure Monitor Ingestion API | -| Function App | Monitoring Metrics Publisher | Application Insights | Send telemetry when local auth is disabled | -| Function App | Storage Blob Data Owner | Storage Account | Flex Consumption plan deployment blobs | +### Parameters -### Deployment prerequisites +See [Bicep/parameters.dev.bicepparam](Bicep/parameters.dev.bicepparam) for a commented example. The main parameters are: -- **Azure CLI** with Bicep support (`az bicep version`) -- **Contributor** role on the target resource group -- A **subnet** delegated to `Microsoft.App/environments` for the Function App VNet integration -- A **subnet** for private endpoints -- Existing **Private DNS Zones** for `privatelink.blob.core.windows.net` and `privatelink.azurewebsites.net` - -### Bicep parameters - -Configured in `Bicep/parameters.dev.bicepparam`: - -| Parameter | Description | Example | -|-----------|-------------|---------| -| `location` | Azure region (defaults to resource group location) | `italynorth` | -| `logAnalyticsWorkspaceName` | Log Analytics workspace name | `log-getavail-itn-001` | -| `dataCollectionEndpointName` | Data Collection Endpoint name | `dce-getavail-itn-001` | -| `dataCollectionRuleName` | Data Collection Rule name | `dcr-getavail-itn-001` | -| `storageAccountName` | Storage account for the Function App | `stgetavailitn001` | -| `functionAppName` | Function App name | `fn-getavail-itn-001` | -| `applicationInsightsName` | Application Insights name | `appi-getavail-itn-001` | -| `fnSubnetId` | Subnet resource ID for Function App VNet integration | `/subscriptions/.../subnets/snet-fn` | -| `peSubnetId` | Subnet resource ID for private endpoints | `/subscriptions/.../subnets/snet-pe` | -| `dnsZonesSubscriptionId` | Subscription ID containing Private DNS Zones | `00000000-0000-...` | -| `dnsZonesResourceGroupName` | Resource group containing Private DNS Zones | `rg-dns-001` | -| `getavailSubscriptions` | Comma-separated subscription names/IDs to monitor | `Contoso-Production,Contoso-Dev` | -| `getavailKinds` | Resource kinds to monitor (default: `vm,sql,storage,webapp`) | `vm,sql` | -| `sourceWorkspaceId` | Log Analytics workspace ID for Activity Log / Resource Health queries (optional) | `f25755bb-...` | -| `timerSchedule` | CRON expression for the timer trigger (default: `0 0 6 1 * *` — 6 AM on the 1st of every month) | `0 0 8 1 * *` | - -### Deploy - -This is a **resource-group scoped** deployment. Create the resource group first, then deploy: +| Parameter | Purpose | +|---|---| +| `logAnalyticsWorkspaceName` | Log Analytics workspace name | +| `dataCollectionEndpointName` | Data Collection Endpoint name | +| `dataCollectionRuleName` | Data Collection Rule name | +| `storageAccountName` | Storage account for the Function App | +| `functionAppName` | Function App name | +| `applicationInsightsName` | Application Insights instance | +| `usePrivateEndpoints` | Toggle between private networking and public reachability | +| `fnSubnetId` | Delegated subnet for Function App VNet integration | +| `peSubnetId` | Private endpoint subnet when private endpoints are enabled | +| `dnsZonesSubscriptionId` | Subscription containing shared private DNS zones | +| `dnsZonesResourceGroupName` | Resource group containing shared private DNS zones | +| `getavailSubscriptions` | Comma-separated subscriptions to monitor | +| `getavailKinds` | Resource kinds to monitor | +| `sourceWorkspaceId` | Optional source workspace for Activity Log and Resource Health history | +| `timerSchedule` | CRON expression for the timer trigger | + +### Deploy the infrastructure ```powershell -# Create resource group (one-time) +# Create resource group (one time) az group create --name rg-getavail-itn-001 --location italynorth --tags solution=Get-Availability # Validate az deployment group validate --resource-group rg-getavail-itn-001 --parameters Bicep/parameters.dev.bicepparam -# What-if (dry run) +# What-if az deployment group what-if --resource-group rg-getavail-itn-001 --parameters Bicep/parameters.dev.bicepparam # Deploy az deployment group create --resource-group rg-getavail-itn-001 --parameters Bicep/parameters.dev.bicepparam ``` -### Post-deployment: cross-subscription Reader role +### Post-deployment access -The Bicep template creates RBAC assignments within the deployment resource group (Metrics Publisher, Storage Blob Data Owner). However, the function also needs **Reader** access on every subscription listed in `getavailSubscriptions` so that `Get-AzSubscription` and `Search-AzGraph` can enumerate and query resources there. +The Function App managed identity needs: -After deployment, retrieve the managed identity principal ID and assign **Reader** on each target subscription: +- `Reader` on each subscription listed in `getavailSubscriptions` +- `Log Analytics Reader` on the `sourceWorkspaceId` workspace when that feature is used + +Example: ```powershell -# Get the Function App managed identity principal ID -$principalId = (az functionapp identity show ` - --name fn-getavail-itn-001 ` - --resource-group rg-getavail-itn-001 ` - --query principalId -o tsv) +$principalId = az functionapp identity show ` + --name fn-getavail-itn-001 ` + --resource-group rg-getavail-itn-001 ` + --query principalId -o tsv -# Assign Reader on each subscription in getavailSubscriptions $subscriptions = @('Flaz-Connectivity', 'Flaz-Management', 'Flaz-Identity', 'Flaz-Workloads') foreach ($sub in $subscriptions) { - $subId = az account show --subscription $sub --query id -o tsv - az role assignment create --assignee $principalId --role Reader --scope "/subscriptions/$subId" + $subId = az account show --subscription $sub --query id -o tsv + az role assignment create --assignee $principalId --role Reader --scope "/subscriptions/$subId" } -``` - -> **Note:** You only need to do this once per subscription (or when the managed identity is recreated). The Workloads subscription (where the Function App lives) may already have Reader via inheritance — include it for completeness. -If `sourceWorkspaceId` points to a Log Analytics workspace (e.g. a Sentinel workspace for Activity Log / Resource Health queries), the managed identity also needs **Log Analytics Reader** on that workspace: - -```powershell -# Assign Log Analytics Reader on the source workspace (if used) -az role assignment create --assignee $principalId --role "Log Analytics Reader" ` - --scope "" +az role assignment create --assignee $principalId --role 'Log Analytics Reader' ` + --scope '' ``` -### Auto-wired app settings - -The Bicep template configures the Function App with all required settings — values are resolved from sibling resources at deploy time: - -| App Setting | Bicep source | Used by `run.ps1` | -|---|---|---| -| `DCE_ENDPOINT` | DCE ingestion endpoint | `$env:DCE_ENDPOINT` | -| `DCR_IMMUTABLE_ID` | DCR immutable ID | `$env:DCR_IMMUTABLE_ID` | -| `SOURCE_WORKSPACE_ID` | `sourceWorkspaceId` parameter | `$env:SOURCE_WORKSPACE_ID` | -| `GETAVAIL_SUBSCRIPTIONS` | `getavailSubscriptions` parameter | `$env:GETAVAIL_SUBSCRIPTIONS` | -| `GETAVAIL_KINDS` | `getavailKinds` parameter | `$env:GETAVAIL_KINDS` | -| `TIMER_SCHEDULE` | `timerSchedule` parameter | *(timer trigger via `%TIMER_SCHEDULE%`)* | -| `APPLICATIONINSIGHTS_CONNECTION_STRING` | App Insights connection string | *(Functions runtime)* | - -The template also configures CORS to allow `https://portal.azure.com`, so you can test-run the function directly from the Azure Portal. - -### Deployment outputs - -Outputs are available for reference or cross-stack integration: - -| Output | Description | -|--------|-------------| -| `logAnalyticsWorkspaceId` | Workspace resource ID | -| `dceIngestionEndpoint` | DCE ingestion URL | -| `dataCollectionRuleImmutableId` | DCR immutable ID | -| `functionAppId` | Function App resource ID | -| `applicationInsightsId` | Application Insights resource ID | -| `storageAccountId` | Storage Account resource ID | - -```powershell -# Retrieve outputs -$outputs = (az deployment group show --resource-group rg-getavail-itn-001 --name getavailability --query properties.outputs -o json | ConvertFrom-Json) -$outputs.dceIngestionEndpoint.value -$outputs.dataCollectionRuleImmutableId.value -``` - -### Publishing the Function App - -The `get-availability.ps1` script lives inside the function app folder (`Functions/GetAvail/`) and is deployed alongside the function code. All app settings (`DCE_ENDPOINT`, `DCR_IMMUTABLE_ID`, `SOURCE_WORKSPACE_ID`, `GETAVAIL_SUBSCRIPTIONS`, `GETAVAIL_KINDS`) are auto-wired by Bicep — after `func publish` the function is ready to run with no manual configuration. +### Publish the Function App ```powershell -# Save required modules (one-time or when upgrading) -Save-Module -Name Az.Accounts -Path Functions/GetAvail/Modules -Repository PSGallery -Force +# Save required modules (one time or when upgrading) +Save-Module -Name Az.Accounts -Path Functions/GetAvail/Modules -Repository PSGallery -Force Save-Module -Name Az.ResourceGraph -Path Functions/GetAvail/Modules -Repository PSGallery -Force -# Publish (from the Functions/GetAvail directory) +# Publish cd Functions/GetAvail func azure functionapp publish fn-getavail-itn-001 --powershell ```