diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75010f7..39091f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,25 +5,17 @@ on: branches: [main] jobs: - build: - runs-on: windows-latest + validate: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Build - run: dotnet build csharp/GetAvailability/GetAvailability.csproj -c Release - - name: Verify PS1 syntax shell: pwsh run: | $tokens = $null; $errors = $null $null = [System.Management.Automation.Language.Parser]::ParseFile( - 'get-availability.ps1', [ref]$tokens, [ref]$errors) + 'Functions/GetAvail/get-availability.ps1', [ref]$tokens, [ref]$errors) if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_.ToString() } exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56c4a11..45cf6f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,15 +9,10 @@ permissions: jobs: release: - runs-on: windows-latest + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - name: Extract version from tag id: version shell: pwsh @@ -25,22 +20,15 @@ jobs: $version = "${{ github.ref_name }}".TrimStart('v') echo "VERSION=$version" >> $env:GITHUB_OUTPUT - - name: Publish AOT - run: dotnet publish csharp/GetAvailability/GetAvailability.csproj -c Release -r win-x64 -p:Version=${{ steps.version.outputs.VERSION }} - - name: Prepare release assets shell: pwsh run: | $version = '${{ steps.version.outputs.VERSION }}' - $publishDir = 'csharp/GetAvailability/bin/Release/net10.0/win-x64/publish' - $stageDir = 'release-assets' + $stageDir = 'release-assets' New-Item -ItemType Directory -Path $stageDir -Force | Out-Null - # AOT executable - Copy-Item "$publishDir/GetAvailability.exe" "$stageDir/GetAvailability.exe" - # PowerShell script — stamp version - (Get-Content 'get-availability.ps1' -Raw) -replace "0\.0\.0-dev", $version | + (Get-Content 'Functions/GetAvail/get-availability.ps1' -Raw) -replace "0\.0\.0-dev", $version | Set-Content "$stageDir/get-availability.ps1" -NoNewline - name: Create GitHub Release @@ -48,5 +36,4 @@ jobs: with: generate_release_notes: true files: | - release-assets/GetAvailability.exe release-assets/get-availability.ps1 diff --git a/Bicep/getavailability.bicep b/Bicep/getavailability.bicep new file mode 100644 index 0000000..cd9178c --- /dev/null +++ b/Bicep/getavailability.bicep @@ -0,0 +1,533 @@ +/* + +Get-Availability — Bicep template for the complete Get-Availability infrastructure. + +Creates a Log Analytics workspace, custom tables, DCE, DCR, Storage Account, +Flex Consumption Function App (with auto-wired app settings), Application Insights, +Private Endpoints, and RBAC role assignments. + +Validate: az deployment group validate --resource-group --parameters .\parameters.dev.bicepparam +What-if: az deployment group what-if --resource-group --parameters .\parameters.dev.bicepparam +Deploy: az deployment group create --resource-group --parameters .\parameters.dev.bicepparam + +*/ + +metadata name = 'Get-Availability Infrastructure' +metadata description = 'Complete infrastructure for the Get-Availability solution: Log Analytics, DCE, DCR, Function App, App Insights, Private Endpoints, and RBAC' + +// ── Parameters ─────────────────────────────────────────────────────────────── + +@description('Azure region for all resources. Defaults to the resource group location.') +param location string = resourceGroup().location + +@description('Name of the Log Analytics workspace to create.') +param logAnalyticsWorkspaceName string + +@description('Name of the Data Collection Endpoint.') +param dataCollectionEndpointName string + +@description('Name of the Data Collection Rule.') +param dataCollectionRuleName string + +@description('Name of the Storage Account for the Function App. Must be globally unique, 3-24 characters, lowercase letters and numbers only.') +@minLength(3) +@maxLength(24) +param storageAccountName string + +@description('Name of the Function App. Must be globally unique, 2-60 characters, alphanumerics and hyphens.') +@minLength(2) +@maxLength(60) +param functionAppName string + +@description('Name of the Application Insights instance for Function App monitoring.') +param applicationInsightsName string + +@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('Subscription ID containing existing Private DNS Zones.') +param dnsZonesSubscriptionId string + +@description('Resource group name containing existing Private DNS Zones.') +param dnsZonesResourceGroupName string + +@description('Comma-separated list of Azure subscription names or IDs to monitor (written to GETAVAIL_SUBSCRIPTIONS app setting).') +param getavailSubscriptions string + +@description('Comma-separated resource kinds to monitor. Default: vm,sql,storage,webapp') +param getavailKinds string = 'vm,sql,storage,webapp' + +@description('Log Analytics workspace customer ID used as source for Activity Log and Resource Health queries (SOURCE_WORKSPACE_ID app setting). Leave empty to skip.') +param sourceWorkspaceId string = '' + +@description('CRON expression for the timer trigger schedule. Default: 6 AM on the 1st of every month (0 0 6 1 * *).') +param timerSchedule string = '0 0 6 1 * *' + +// ── Variables ──────────────────────────────────────────────────────────────── + +var commonTags = { + solution: 'Get-Availability' +} + +// Azure built-in role definition IDs +var roleDefinitions = { + monitoringMetricsPublisher: '3913510d-42f4-4e42-8a64-420c390055eb' + storageBlobDataOwner: 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' +} + +// ── Existing Private DNS Zones ─────────────────────────────────────────────── + +resource blobDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = { + name: 'privatelink.blob.${environment().suffixes.storage}' + scope: resourceGroup(dnsZonesSubscriptionId, dnsZonesResourceGroupName) +} + +resource webAppDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = { + name: 'privatelink.azurewebsites.net' + scope: resourceGroup(dnsZonesSubscriptionId, dnsZonesResourceGroupName) +} + +// ── Log Analytics Workspace ────────────────────────────────────────────────── + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: logAnalyticsWorkspaceName + location: location + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + } + tags: commonTags +} + +// ── Custom Table: GetAvailResources_CL (per-resource detail) ───────────────── + +resource resourcesTable 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = { + name: 'GetAvailResources_CL' + parent: logAnalyticsWorkspace + properties: { + retentionInDays: 30 + schema: { + name: 'GetAvailResources_CL' + columns: [ + { name: 'TimeGenerated', type: 'dateTime' } + { name: 'RunId', type: 'string' } + { name: 'Month', type: 'string' } + { name: 'PeriodStart', type: 'dateTime' } + { name: 'PeriodEnd', type: 'dateTime' } + { name: 'IsMonthToDate', type: 'boolean' } + { name: 'SubscriptionName', type: 'string' } + { name: 'ResourceName', type: 'string' } + { name: 'ResourceId', type: 'string' } + { name: 'ResourceGroup', type: 'string' } + { name: 'Kind', type: 'string' } + { name: 'Location', type: 'string' } + { name: 'EligibleMinutes', type: 'int' } + { name: 'AvailableMinutes', type: 'real' } + { name: 'SuspectMinutes', type: 'int' } + { name: 'ConfirmedDowntimeMinutes', type: 'int' } + { name: 'ExcusedMinutes', type: 'int' } + { name: 'UnexplainedSuspectMinutes', type: 'int' } + { name: 'AvailabilityPct', type: 'real' } + ] + } + } +} + +// ── Custom Table: GetAvailSummary_CL (aggregated summaries) ────────────────── + +resource summaryTable 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = { + name: 'GetAvailSummary_CL' + parent: logAnalyticsWorkspace + properties: { + retentionInDays: 30 + schema: { + name: 'GetAvailSummary_CL' + columns: [ + { name: 'TimeGenerated', type: 'dateTime' } + { name: 'RunId', type: 'string' } + { name: 'Month', type: 'string' } + { name: 'PeriodStart', type: 'dateTime' } + { name: 'PeriodEnd', type: 'dateTime' } + { name: 'IsMonthToDate', type: 'boolean' } + { name: 'SummaryLevel', type: 'string' } + { name: 'SubscriptionName', type: 'string' } + { name: 'Kind', type: 'string' } + { name: 'Location', type: 'string' } + { name: 'ResourceCount', type: 'int' } + { name: 'EligibleMinutes', type: 'real' } + { name: 'AvailableMinutes', type: 'real' } + { name: 'AvailabilityPct', type: 'real' } + ] + } + } +} + +// ── Data Collection Endpoint ───────────────────────────────────────────────── + +resource dataCollectionEndpoint 'Microsoft.Insights/dataCollectionEndpoints@2023-03-11' = { + name: dataCollectionEndpointName + location: location + properties: { + networkAcls: { + publicNetworkAccess: 'Enabled' + } + } + tags: commonTags +} + +// ── Data Collection Rule (two streams, one per table) ──────────────────────── + +resource dataCollectionRule 'Microsoft.Insights/dataCollectionRules@2023-03-11' = { + name: dataCollectionRuleName + location: location + kind: 'Direct' + properties: { + dataCollectionEndpointId: dataCollectionEndpoint.id + + // Stream declarations — columns sent by the script (no TimeGenerated; injected by transform) + streamDeclarations: { + 'Custom-GetAvailResources_CL': { + columns: [ + { name: 'RunId', type: 'string' } + { name: 'Month', type: 'string' } + { name: 'PeriodStart', type: 'datetime' } + { name: 'PeriodEnd', type: 'datetime' } + { name: 'IsMonthToDate', type: 'boolean' } + { name: 'SubscriptionName', type: 'string' } + { name: 'ResourceName', type: 'string' } + { name: 'ResourceId', type: 'string' } + { name: 'ResourceGroup', type: 'string' } + { name: 'Kind', type: 'string' } + { name: 'Location', type: 'string' } + { name: 'EligibleMinutes', type: 'int' } + { name: 'AvailableMinutes', type: 'real' } + { name: 'SuspectMinutes', type: 'int' } + { name: 'ConfirmedDowntimeMinutes', type: 'int' } + { name: 'ExcusedMinutes', type: 'int' } + { name: 'UnexplainedSuspectMinutes', type: 'int' } + { name: 'AvailabilityPct', type: 'real' } + ] + } + 'Custom-GetAvailSummary_CL': { + columns: [ + { name: 'RunId', type: 'string' } + { name: 'Month', type: 'string' } + { name: 'PeriodStart', type: 'datetime' } + { name: 'PeriodEnd', type: 'datetime' } + { name: 'IsMonthToDate', type: 'boolean' } + { name: 'SummaryLevel', type: 'string' } + { name: 'SubscriptionName', type: 'string' } + { name: 'Kind', type: 'string' } + { name: 'Location', type: 'string' } + { name: 'ResourceCount', type: 'int' } + { name: 'EligibleMinutes', type: 'real' } + { name: 'AvailableMinutes', type: 'real' } + { name: 'AvailabilityPct', type: 'real' } + ] + } + } + + destinations: { + logAnalytics: [ + { + workspaceResourceId: logAnalyticsWorkspace.id + name: 'workspace' + } + ] + } + + dataFlows: [ + { + streams: [ 'Custom-GetAvailResources_CL' ] + destinations: [ 'workspace' ] + transformKql: 'source | extend TimeGenerated = now()' + outputStream: 'Custom-GetAvailResources_CL' + } + { + streams: [ 'Custom-GetAvailSummary_CL' ] + destinations: [ 'workspace' ] + transformKql: 'source | extend TimeGenerated = now()' + outputStream: 'Custom-GetAvailSummary_CL' + } + ] + } + dependsOn: [ + resourcesTable + summaryTable + ] + tags: commonTags +} + +// ── Storage Account ────────────────────────────────────────────────────────── + +resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = { + name: storageAccountName + location: location + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + accessTier: 'Hot' + defaultToOAuthAuthentication: true + allowBlobPublicAccess: false + allowSharedKeyAccess: false + minimumTlsVersion: 'TLS1_2' + supportsHttpsTrafficOnly: true + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + encryption: { + services: { + blob: { + enabled: true + } + } + } + } + resource blobServices 'blobServices' = { + name: 'default' + properties: {} + } + tags: commonTags +} + +// ── Private Endpoint: Storage Account (blob) ───────────────────────────────── + +resource storageAccountBlobPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-10-01' = { + name: 'pe-blob-${storageAccountName}' + location: location + properties: { + subnet: { + id: peSubnetId + } + privateLinkServiceConnections: [ + { + name: 'pls-${storageAccountName}' + properties: { + privateLinkServiceId: storageAccount.id + groupIds: [ + 'blob' + ] + } + } + ] + customNetworkInterfaceName: 'nic-pe-${storageAccountName}' + } + tags: commonTags + + resource privateDnsZoneGroup 'privateDnsZoneGroups' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'config1' + properties: { + privateDnsZoneId: blobDnsZone.id + } + } + ] + } + } +} + +// ── Application Insights ───────────────────────────────────────────────────── + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: applicationInsightsName + location: location + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalyticsWorkspace.id + DisableLocalAuth: true + } + dependsOn: [ + dataCollectionRule // Ensure workspace backend is fully active before App Insights connects + ] + tags: commonTags +} + +// ── Flex Consumption Plan ──────────────────────────────────────────────────── + +resource flexServicePlan 'Microsoft.Web/serverfarms@2024-11-01' = { + name: 'asp-${functionAppName}' + location: location + kind: 'functionapp' + sku: { + tier: 'FlexConsumption' + name: 'FC1' + } + properties: { + reserved: true + } + tags: commonTags +} + +// ── Function App ───────────────────────────────────────────────────────────── + +resource functionApp 'Microsoft.Web/sites@2024-11-01' = { + name: functionAppName + location: location + kind: 'functionapp,linux' + identity: { + type: 'SystemAssigned' + } + properties: { + serverFarmId: flexServicePlan.id + httpsOnly: true + virtualNetworkSubnetId: fnSubnetId + publicNetworkAccess: 'Disabled' + siteConfig: { + minTlsVersion: '1.2' + cors: { + allowedOrigins: [ + 'https://portal.azure.com' + ] + } + } + functionAppConfig: { + deployment: { + storage: { + type: 'blobContainer' + value: '${storageAccount.properties.primaryEndpoints.blob}azure-webjobs-hosts' + authentication: { + type: 'SystemAssignedIdentity' + } + } + } + scaleAndConcurrency: { + maximumInstanceCount: 100 + instanceMemoryMB: 2048 + } + runtime: { + name: 'powerShell' + version: '7.4' + } + } + } + resource appSettings 'config' = { + name: 'appsettings' + properties: { + // Function App infrastructure + AzureWebJobsStorage__credential: 'managedidentity' + AzureWebJobsStorage__blobServiceUri: storageAccount.properties.primaryEndpoints.blob + APPLICATIONINSIGHTS_AUTHENTICATION_STRING: 'Authorization=AAD' + APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString + + // Get-Availability configuration — auto-wired from Bicep resources + GETAVAIL_SUBSCRIPTIONS: getavailSubscriptions + GETAVAIL_KINDS: getavailKinds + DCE_ENDPOINT: dataCollectionEndpoint.properties.logsIngestion.endpoint + DCR_IMMUTABLE_ID: dataCollectionRule.properties.immutableId + SOURCE_WORKSPACE_ID: sourceWorkspaceId + TIMER_SCHEDULE: timerSchedule + } + } + dependsOn: [ + storageAccountBlobPrivateEndpoint // Create function only after storage PE is ready + ] + tags: commonTags +} + +// ── Private Endpoint: Function App (sites) ─────────────────────────────────── + +resource functionAppPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-10-01' = { + name: 'pe-sites-${functionAppName}' + location: location + properties: { + subnet: { + id: peSubnetId + } + privateLinkServiceConnections: [ + { + name: 'pls-${functionAppName}' + properties: { + privateLinkServiceId: functionApp.id + groupIds: [ + 'sites' + ] + } + } + ] + customNetworkInterfaceName: 'nic-pe-${functionAppName}' + } + tags: commonTags + + resource privateDnsZoneGroup 'privateDnsZoneGroups' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'config1' + properties: { + privateDnsZoneId: webAppDnsZone.id + } + } + ] + } + } +} + +// ── RBAC Role Assignments ──────────────────────────────────────────────────── + +// Function App → Monitoring Metrics Publisher → DCR (ingest custom logs) +resource functionAppDcrPublisher 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, resourceGroup().id, 'functionAppDcrPublisher') + scope: dataCollectionRule + properties: { + description: 'Function App -> Monitoring Metrics Publisher -> DCR' + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + roleDefinitions.monitoringMetricsPublisher + ) + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// Function App → Monitoring Metrics Publisher → Application Insights (Entra-only telemetry) +resource functionAppAppiPublisher 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, resourceGroup().id, 'functionAppAppiPublisher') + scope: applicationInsights + properties: { + description: 'Function App -> Monitoring Metrics Publisher -> Application Insights' + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + roleDefinitions.monitoringMetricsPublisher + ) + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// Function App → Storage Blob Data Owner → Storage Account (Flex Consumption deployment blobs) +resource functionAppStorageBlobDataOwner 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, resourceGroup().id, 'functionAppStorageBlobDataOwner') + scope: storageAccount + properties: { + description: 'Function App -> Storage Blob Data Owner -> Storage Account' + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + roleDefinitions.storageBlobDataOwner + ) + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// ── Outputs ────────────────────────────────────────────────────────────────── + +output logAnalyticsWorkspaceId string = logAnalyticsWorkspace.id +output dceIngestionEndpoint string = dataCollectionEndpoint.properties.logsIngestion.endpoint +output dataCollectionRuleImmutableId string = dataCollectionRule.properties.immutableId +output functionAppId string = functionApp.id +output applicationInsightsId string = applicationInsights.id +output storageAccountId string = storageAccount.id diff --git a/Bicep/parameters.dev.bicepparam b/Bicep/parameters.dev.bicepparam new file mode 100644 index 0000000..e8e1fae --- /dev/null +++ b/Bicep/parameters.dev.bicepparam @@ -0,0 +1,14 @@ +using './getavailability.bicep' + +param logAnalyticsWorkspaceName = 'log-getavail-itn-001' +param dataCollectionEndpointName = 'dce-getavail-itn-001' +param dataCollectionRuleName = 'dcr-getavail-itn-001' +param storageAccountName = 'flazstgetavailitn001' +param functionAppName = 'fn-getavail-itn-001' +param applicationInsightsName = 'appi-getavail-itn-001' +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' +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' +param dnsZonesSubscriptionId = 'c4e6c176-bf9c-4e8c-87b2-ebdceea7085f' +param dnsZonesResourceGroupName = 'rg-alz-dns-hub-itn-001' +param getavailSubscriptions = 'Flaz-Connectivity,Flaz-Management,Flaz-Identity,Flaz-Workloads' +param sourceWorkspaceId = 'f25755bb-9b46-4aac-bfae-6a10c4c18440' diff --git a/Functions/GetAvail/.funcignore b/Functions/GetAvail/.funcignore new file mode 100644 index 0000000..aea08de --- /dev/null +++ b/Functions/GetAvail/.funcignore @@ -0,0 +1,7 @@ +.git* +.vscode +__azurite_db*__.json +__blobstorage__ +__queuestorage__ +local.settings.json +test diff --git a/Functions/GetAvail/.gitignore b/Functions/GetAvail/.gitignore new file mode 100644 index 0000000..706b839 --- /dev/null +++ b/Functions/GetAvail/.gitignore @@ -0,0 +1,14 @@ + +# Azure Functions artifacts +bin +obj +appsettings.json +local.settings.json + +# Azurite artifacts +__blobstorage__ +__queuestorage__ +__azurite_db*__.json + +# Saved modules (large, fetched before publishing) +Modules/Az.* diff --git a/Functions/GetAvail/Modules/README.md b/Functions/GetAvail/Modules/README.md new file mode 100644 index 0000000..60f8d02 --- /dev/null +++ b/Functions/GetAvail/Modules/README.md @@ -0,0 +1,19 @@ +# Modules Directory + +This folder holds the PowerShell modules required by the function app at runtime. + +Since the Flex Consumption plan **does not support managed dependencies**, modules +must be saved here manually using `Save-Module`: + +```powershell +Save-Module -Name Az.Accounts -Path . -Repository PSGallery -Force +Save-Module -Name Az.ResourceGraph -Path . -Repository PSGallery -Force +``` + +The `get-availability.ps1` script requires: +- **Az.Accounts** — `Get-AzAccessToken`, `Get-AzContext`, `Connect-AzAccount` +- **Az.ResourceGraph** — `Search-AzGraph` + +> Do not commit the module folders themselves to source control — they are large +> and version-pinned. Instead, run `Save-Module` as part of your deployment pipeline +> or locally before publishing with `func azure functionapp publish`. diff --git a/Functions/GetAvail/RunGetAvailability/function.json b/Functions/GetAvail/RunGetAvailability/function.json new file mode 100644 index 0000000..7f4e492 --- /dev/null +++ b/Functions/GetAvail/RunGetAvailability/function.json @@ -0,0 +1,10 @@ +{ + "bindings": [ + { + "name": "Timer", + "type": "timerTrigger", + "direction": "in", + "schedule": "%TIMER_SCHEDULE%" + } + ] +} diff --git a/Functions/GetAvail/RunGetAvailability/run.ps1 b/Functions/GetAvail/RunGetAvailability/run.ps1 new file mode 100644 index 0000000..867f466 --- /dev/null +++ b/Functions/GetAvail/RunGetAvailability/run.ps1 @@ -0,0 +1,107 @@ +<# +.SYNOPSIS + Timer-triggered function that runs get-availability.ps1 for the previous month. + +.DESCRIPTION + Triggered on a CRON schedule (default: 1st of every month at 06:00 UTC). + Can also be started manually from the Azure Portal or via the Functions runtime API. + + Reads configuration from App Settings (environment variables): + GETAVAIL_SUBSCRIPTIONS - Comma-separated list of subscription names or IDs (required) + GETAVAIL_KINDS - Comma-separated resource kinds (default: vm,sql,storage,webapp) + DCE_ENDPOINT - Data Collection Endpoint URL (optional, enables ingestion) + DCR_IMMUTABLE_ID - Data Collection Rule immutable ID (optional, paired with DCE_ENDPOINT) + SOURCE_WORKSPACE_ID - Log Analytics workspace ID for Resource Health queries (optional) + GETAVAIL_PARALLELISM - Parallel thread count (optional, default: script default) + GETAVAIL_BATCH - Set to "true" to enable batch metrics API (optional) + GETAVAIL_BATCH_SIZE - Batch size when batch mode is enabled (optional) + + The get-availability.ps1 script lives at the function app root (next to host.json) + and is deployed automatically by 'func azure functionapp publish'. +#> + +param($Timer) + +# Strict / fail fast +Set-StrictMode -Version 1.0 +$ErrorActionPreference = 'Stop' + +# ── Read configuration from App Settings ────────────────────────────────────── + +$subscriptionsRaw = $env:GETAVAIL_SUBSCRIPTIONS +if ([string]::IsNullOrWhiteSpace($subscriptionsRaw)) { + throw 'App Setting GETAVAIL_SUBSCRIPTIONS is required but not set.' +} +$subscriptions = @($subscriptionsRaw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + +$kindsRaw = $env:GETAVAIL_KINDS +$kinds = if (-not [string]::IsNullOrWhiteSpace($kindsRaw)) { + @($kindsRaw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +} else { + @('vm', 'sql', 'storage', 'webapp') +} + +# Compute previous month in YYYYMM format +$previousMonth = (Get-Date).ToUniversalTime().AddMonths(-1).ToString('yyyyMM') + +# ── Ensure Azure context ───────────────────────────────────────────────────── + +try { + $context = Get-AzContext + if (-not $context -or -not $context.Account -or $context.Account.Id -eq 'NotLoggedIn') { + Write-Warning 'No valid Azure context found. Attempting Identity-based login...' + Disable-AzContextAutosave -Scope Process | Out-Null + Connect-AzAccount -Identity -ErrorAction Stop | Out-Null + Write-Information 'Identity-based login succeeded.' + } else { + Write-Information "Using existing Azure context: $($context.Account.Id)" + } +} catch { + throw "Failed to verify or establish Azure login context: $_" +} + +# ── Build script arguments ──────────────────────────────────────────────────── + +$scriptPath = Join-Path $PSScriptRoot '..' 'get-availability.ps1' +if (-not (Test-Path $scriptPath)) { + throw "get-availability.ps1 not found at expected path: $scriptPath" +} + +$scriptArgs = @{ + Subscriptions = $subscriptions + Month = $previousMonth + Kinds = $kinds +} + +# Optional: Log Analytics ingestion +if (-not [string]::IsNullOrWhiteSpace($env:DCE_ENDPOINT) -and -not [string]::IsNullOrWhiteSpace($env:DCR_IMMUTABLE_ID)) { + $scriptArgs['DceEndpoint'] = $env:DCE_ENDPOINT + $scriptArgs['DcrImmutableId'] = $env:DCR_IMMUTABLE_ID +} + +# Optional: Source workspace for Resource Health +if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_WORKSPACE_ID)) { + $scriptArgs['SourceWorkspaceId'] = $env:SOURCE_WORKSPACE_ID +} + +# Optional: Parallelism +if (-not [string]::IsNullOrWhiteSpace($env:GETAVAIL_PARALLELISM)) { + $scriptArgs['Parallelism'] = [int]$env:GETAVAIL_PARALLELISM +} + +# Optional: Batch mode +if ($env:GETAVAIL_BATCH -eq 'true') { + $scriptArgs['Batch'] = $true + if (-not [string]::IsNullOrWhiteSpace($env:GETAVAIL_BATCH_SIZE)) { + $scriptArgs['BatchSize'] = [int]$env:GETAVAIL_BATCH_SIZE + } +} + +# ── Execute ─────────────────────────────────────────────────────────────────── + +$timerStatus = if ($Timer.IsPastDue) { 'past due' } else { 'on time' } +Write-Information "GetAvail timer trigger fired ($timerStatus). Running for month $previousMonth with $($subscriptions.Count) subscription(s)." + +& $scriptPath @scriptArgs + +Write-Information "GetAvail completed for month $previousMonth." diff --git a/get-availability.ps1 b/Functions/GetAvail/get-availability.ps1 similarity index 89% rename from get-availability.ps1 rename to Functions/GetAvail/get-availability.ps1 index 15eb0f7..b2f865f 100644 --- a/get-availability.ps1 +++ b/Functions/GetAvail/get-availability.ps1 @@ -6,7 +6,7 @@ Reports month-scoped availability for VMs, Azure SQL databases, Storage Accounts, and Web Apps. .DESCRIPTION - Mirrors the C# Get-Availability pipeline: + Availability pipeline: 1. Resolve subscriptions 2. Inventory via Resource Graph 3. Fetch metrics (Azure Monitor, parallel) @@ -30,15 +30,17 @@ Remaining null minutes become metric issues (excluded from eligibility), while remaining 0% minutes are trusted as downtime. - When -Workspace is specified, Activity Log lifecycle events are fetched - from a Log Analytics workspace via a single bulk KQL query instead of - per-resource REST API calls. This is faster for large estates and uses + When -SourceWorkspaceId is specified, Activity Log lifecycle events are + fetched from a Log Analytics workspace via a single bulk KQL query instead + of per-resource REST API calls. This is faster for large estates and uses workspace retention (typically 365 days). Resource Health data uses a hybrid approach: Log Analytics transitions cover the period beyond the REST API's ~30-day retention, while REST API transitions (curated, synthetic, with retroactively corrected causes) are authoritative for the last ~30 days. The two sources are merged to produce complete - coverage. Without -Workspace, the REST APIs are used directly. + coverage. Without -SourceWorkspaceId, the REST APIs are used directly. + Note: this is NOT the workspace used for result ingestion (see + -DceEndpoint / -DcrImmutableId for that). The observation window is a UTC calendar month selected via -Month YYYYMM. Current month runs month-to-date; past months run full-month. @@ -67,16 +69,28 @@ .PARAMETER BatchSize Max resources per batch call (default: 10, max 50). Implies -Batch. -.PARAMETER Workspace - Log Analytics workspace ID (GUID). When provided, Activity Log lifecycle - events are fetched from the AzureActivity table in this workspace via a - single bulk KQL query instead of per-resource REST API calls. Resource - Health uses a hybrid approach: Log Analytics transitions cover the period - beyond the REST API's ~30-day retention limit, while REST API transitions - (curated, with corrected causes) are authoritative for the last ~30 days. - Faster for large estates and provides complete Resource Health coverage - across the full observation window. Requires the workspace to receive - Activity Log diagnostic settings from the target subscriptions. +.PARAMETER SourceWorkspaceId + Log Analytics workspace ID (GUID) used as a source for historical Activity + Log and Resource Health data. This is NOT the ingestion target — it is + an existing workspace that receives Activity Log diagnostic settings from + the target subscriptions. When provided, lifecycle events are fetched via + a single bulk KQL query (faster for large estates, uses workspace + retention). Resource Health uses a hybrid approach: Log Analytics + transitions cover the period beyond the REST API's ~30-day retention + limit, while REST API transitions (curated, with corrected causes) are + authoritative for the last ~30 days. + +.PARAMETER DceEndpoint + Data Collection Endpoint ingestion URL. When both -DceEndpoint and + -DcrImmutableId are provided, results are sent to Log Analytics custom + tables in addition to the normal console output. Obtain the URL from + the Bicep deployment output 'dceIngestionEndpoint'. Works in both + interactive (az login / Connect-AzAccount) and Azure Function + (managed identity) execution contexts. + +.PARAMETER DcrImmutableId + Data Collection Rule immutable ID. Required together with -DceEndpoint. + Obtain from the Bicep deployment output 'dataCollectionRuleImmutableId'. .EXAMPLE ./get-availability.ps1 -Subscriptions 'MySubscription' -Month 202506 @@ -88,7 +102,10 @@ ./get-availability.ps1 -Subscriptions 'MySub' -Month 202506 -Batch -BatchSize 20 .EXAMPLE - ./get-availability.ps1 -Subscriptions 'MySub' -Month 202506 -Workspace 'b233a4b7-3c43-433c-ac60-1f6ff217ddd4' + ./get-availability.ps1 -Subscriptions 'MySub' -Month 202506 -SourceWorkspaceId 'b233a4b7-3c43-433c-ac60-1f6ff217ddd4' + +.EXAMPLE + ./get-availability.ps1 -Subscriptions 'MySub' -Month 202506 -DceEndpoint 'https://dce-getavail-itn-001.italynorth-1.ingest.monitor.azure.com' -DcrImmutableId 'dcr-00000000000000000000000000000000' #> [CmdletBinding(DefaultParameterSetName = 'Run')] @@ -125,7 +142,15 @@ param( [Parameter(ParameterSetName = 'Run')] [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] - [string]$Workspace, + [string]$SourceWorkspaceId, + + [Parameter(ParameterSetName = 'Run')] + [ValidateNotNullOrEmpty()] + [string]$DceEndpoint, + + [Parameter(ParameterSetName = 'Run')] + [ValidateNotNullOrEmpty()] + [string]$DcrImmutableId, [Parameter(Mandatory, ParameterSetName = 'ShowVersion')] [switch]$Version @@ -141,8 +166,90 @@ if ($Version) { return } +# Validate paired ingestion parameters +$sendToLogAnalytics = $false +if ($DceEndpoint -and $DcrImmutableId) { + $sendToLogAnalytics = $true +} elseif ($DceEndpoint -or $DcrImmutableId) { + throw '-DceEndpoint and -DcrImmutableId must both be provided together.' +} + +# ── Log Analytics Ingestion ─────────────────────────────────────────────────── + +## Sends an array of objects to a Log Analytics custom table via the Azure +## Monitor Ingestion REST API. Gzip-compresses the JSON payload and splits +## into multiple calls if the compressed size exceeds 900 KB (API limit is 1 MB). +function Send-ToLogAnalytics { + param( + [string]$Endpoint, + [string]$RuleId, + [string]$StreamName, + [string]$Token, + [object[]]$Payload + ) + + if ($Payload.Count -eq 0) { return } + + $uri = "$Endpoint/dataCollectionRules/$RuleId/streams/${StreamName}?api-version=2023-01-01" + $headers = @{ + 'Authorization' = "Bearer $Token" + 'Content-Type' = 'application/json' + 'Content-Encoding' = 'gzip' + } + + # Gzip-compress a JSON array into a byte[]. + # Returns [byte[]] directly — NOT through the pipeline — to avoid PowerShell + # unrolling the array into individual System.Byte objects. + function Compress-JsonPayload([object[]]$Items) { + $json = $Items | ConvertTo-Json -Depth 5 -Compress -AsArray + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $ms = [System.IO.MemoryStream]::new() + try { + $gz = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionLevel]::Optimal, $true) + $gz.Write($bytes, 0, $bytes.Length) + $gz.Dispose() + [byte[]]$ms.ToArray() + } finally { $ms.Dispose() } + } + + # POST with retry (3 attempts, exponential backoff) + function Send-Chunk([byte[]]$Body) { + $maxAttempts = 3 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + try { + Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -Body $Body -TimeoutSec 60 -StatusCodeVariable statusCode + return + } catch { + if ($attempt -eq $maxAttempts) { throw } + $delay = [math]::Pow(2, $attempt) + Write-Warning "Ingestion attempt $attempt to $StreamName failed (HTTP $statusCode): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + } + } + } + + # Try full payload as a single call; split only if compressed size exceeds 900 KB + [byte[]]$compressed = Compress-JsonPayload $Payload + if ($compressed.Length -lt 900KB) { + Send-Chunk $compressed + return + } + + # Estimate chunk size from compression ratio and send each chunk separately + $chunkSize = [math]::Max(1, [math]::Floor($Payload.Count / [math]::Ceiling($compressed.Length / 900KB))) + for ($i = 0; $i -lt $Payload.Count; $i += $chunkSize) { + $chunk = @($Payload[$i..([math]::Min($i + $chunkSize - 1, $Payload.Count - 1))]) + [byte[]]$body = Compress-JsonPayload $chunk + Send-Chunk $body + } +} + # ── Observation window ──────────────────────────────────────────────────────── +## Parses a YYYYMM string into a start/end observation window, clamped to the +## current UTC minute. Returns Start, End, NormalizedMonth, IsMonthToDate, and +## TotalMinutes. Throws if the month is in the future, older than 90 days, or +## produces an empty period. function Resolve-ObservationWindow([string]$MonthParam) { $parsed = [datetime]::new(1, 1, 1) if (-not [datetime]::TryParseExact($MonthParam, 'yyyyMM', @@ -173,6 +280,9 @@ function Resolve-ObservationWindow([string]$MonthParam) { # ── Resource inventory via Azure Resource Graph ─────────────────────────────── +## Queries Azure Resource Graph for resources matching the requested -Kind and +## optional -ResourceName filter across the supplied subscriptions. Returns +## an array of lightweight resource descriptors used by the metrics pipeline. function Get-ResourceInventory { param( [string[]]$SubscriptionIds, @@ -263,9 +373,9 @@ function Get-ShortKind([string]$Kind) { ## Returns the effective start of the Resource Health coverage window, clamped ## to PeriodStart if coverage spans the full observation period. -## When using Log Analytics (-Workspace), the hybrid approach (LA for older -## transitions + REST API for the last ~30 days) provides coverage from -## PeriodStart. Without -Workspace, coverage is limited to ~30 days. +## When using Log Analytics (-SourceWorkspaceId), the hybrid approach (LA for +## older transitions + REST API for the last ~30 days) provides coverage from +## PeriodStart. Without -SourceWorkspaceId, coverage is limited to ~30 days. function Get-HealthCoverageStart([DateTimeOffset]$PeriodStart, [switch]$UseLogAnalytics) { if ($UseLogAnalytics) { return $PeriodStart } $now = [DateTimeOffset]::UtcNow @@ -943,12 +1053,8 @@ function Test-BatchEndpoints { $body = '{"resourceids":[]}' try { - $oldPref = $ProgressPreference; $ProgressPreference = 'SilentlyContinue' - try { - Invoke-WebRequest -Uri $testUri -Method POST -Body $body -ContentType 'application/json' ` - -Headers @{ Authorization = "Bearer $MetricsToken" } -UseBasicParsing -ErrorAction Stop | Out-Null - } - finally { $ProgressPreference = $oldPref } + Invoke-WebRequest -Uri $testUri -Method POST -Body $body -ContentType 'application/json' ` + -Headers @{ Authorization = "Bearer $MetricsToken" } -ErrorAction Stop | Out-Null Write-Host " $region`: OK" } catch { @@ -1416,8 +1522,8 @@ function Invoke-SuspectGapInvestigation { # Shared HttpClient for REST API calls within the parallel block. # Always created: needed for Resource Health REST calls (sole source - # without -Workspace; hybrid supplement with -Workspace) and for - # Activity Log REST calls when -Workspace is not specified. + # without -SourceWorkspaceId; hybrid supplement with -SourceWorkspaceId) + # and for Activity Log REST calls when -SourceWorkspaceId is not specified. $gapHttpClient = [System.Net.Http.HttpClient]::new() $gapHttpClient.DefaultRequestHeaders.Add('Authorization', "Bearer $ArmToken") $gapHttpClient.Timeout = [TimeSpan]::FromMinutes(5) @@ -1830,12 +1936,12 @@ function Invoke-SuspectGapInvestigation { # ── Resource Health ─────────────────────────────────────────── # Query Resource Health transitions to classify platform faults, # unknown states, and customer-initiated events. - # Hybrid mode (-Workspace): LA transitions older than the REST API - # ~30-day cutoff are merged with REST API transitions for the last + # Hybrid mode (-SourceWorkspaceId): LA transitions older than the REST + # API ~30-day cutoff are merged with REST API transitions for the last # ~30 days. REST data is authoritative within its retention window: # it provides curated synthetic entries that fill coverage gaps # between incidents and retroactively corrects cause classification. - # REST-only mode (no -Workspace): REST API is the sole source. + # REST-only mode (no -SourceWorkspaceId): REST API is the sole source. $healthHistoryApplied = [DateTimeOffset]$hcStart -lt [DateTimeOffset]$pEnd $faultIntervals = @() $unknownIntervals = @() @@ -1855,7 +1961,7 @@ function Invoke-SuspectGapInvestigation { } # Fetch REST API health transitions — sole source without - # -Workspace; covers last ~30 days with curated authoritative + # -SourceWorkspaceId; covers last ~30 days with curated authoritative # data in hybrid mode. $url = "https://management.azure.com$($c.ResourceId)" + "/providers/Microsoft.ResourceHealth/availabilityStatuses" + @@ -2135,6 +2241,13 @@ function Write-SubscriptionSummaries([object[]]$Sorted) { } } +## Acquires a plain-text bearer token via Az.Accounts. +## Handles both SecureString (Az.Accounts ≥ 5.x) and legacy plain string returns. +function Get-PlainToken([string]$ResourceUrl) { + $raw = (Get-AzAccessToken -ResourceUrl $ResourceUrl).Token + ($raw -is [securestring]) ? ($raw | ConvertFrom-SecureString -AsPlainText) : [string]$raw +} + # ── Main ────────────────────────────────────────────────────────────────────── $sw = [System.Diagnostics.Stopwatch]::StartNew() @@ -2145,7 +2258,7 @@ $utcStart = $window.Start $utcEnd = $window.End $totalMinutes = $window.TotalMinutes -$healthCoverageStart = if ($Workspace) { +$healthCoverageStart = if ($SourceWorkspaceId) { Get-HealthCoverageStart $utcStart -UseLogAnalytics } else { Get-HealthCoverageStart $utcStart @@ -2155,8 +2268,8 @@ $healthCoveredMinutes = $healthCoverageStart -lt $utcEnd ? [int]($utcEnd - $heal $periodLabel = $window.IsMonthToDate ? "month $($window.NormalizedMonth) (month-to-date)" : "month $($window.NormalizedMonth)" Write-Host "Period: $periodLabel ($($utcStart.ToString('u')) -> $($utcEnd.ToString('u')), $totalMinutes min)" -if ($Workspace) { - Write-Host "Log Analytics workspace: $Workspace (Activity Log via KQL, Resource Health via KQL + REST API hybrid)" +if ($SourceWorkspaceId) { + Write-Host "Log Analytics source workspace: $SourceWorkspaceId (Activity Log via KQL, Resource Health via KQL + REST API hybrid)" } elseif ($healthCoverageStart -gt $utcStart -and $healthCoveredMinutes -gt 0) { Write-Host "WARNING: Resource Health history covers only part of this period ($($healthCoverageStart.ToString('u')) -> $($utcEnd.ToString('u')), $healthCoveredMinutes of $totalMinutes min). Earlier minutes will use Activity Log and metric fallback rules." } elseif ($healthCoveredMinutes -eq 0) { @@ -2179,10 +2292,7 @@ $resolvedSubs = @(foreach ($name in $Subscriptions) { $subIds = @($resolvedSubs.Id) $subIdToName = @{}; foreach ($s in $resolvedSubs) { $subIdToName[$s.Id] = $s.Name } -# Acquire ARM token — Az.Accounts may return SecureString or plain string -$rawToken = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com').Token -$armToken = ($rawToken -is [securestring]) ? ($rawToken | ConvertFrom-SecureString -AsPlainText) : [string]$rawToken -$rawToken = $null +$armToken = Get-PlainToken 'https://management.azure.com' Write-Host 'OK' Write-Host "Processing $($resolvedSubs.Count) subscription(s): $($resolvedSubs.Name -join ', ')" @@ -2199,9 +2309,7 @@ Write-Host "Found $($resources.Count) resource(s) across $($resolvedSubs.Count) if ($resources.Count -eq 0) { Write-Host 'No resources found.'; return } if ($Batch) { - $rawMetrics = (Get-AzAccessToken -ResourceUrl 'https://metrics.monitor.azure.com').Token - $metricsToken = ($rawMetrics -is [securestring]) ? ($rawMetrics | ConvertFrom-SecureString -AsPlainText) : [string]$rawMetrics - $rawMetrics = $null + $metricsToken = Get-PlainToken 'https://metrics.monitor.azure.com' Write-Host "Mode: Batch (batch-size=$BatchSize)" } @@ -2237,6 +2345,39 @@ if ($Batch) { -EndDate $utcEnd -ThrottleLimit $Parallelism -ArmToken $armToken } +# Step 5b: Detect perpetually-deallocated VMs +# VMs that are currently deallocated/stopped AND had zero healthy minutes for the +# entire period are excused — the VM was never running so availability is N/A. +# This handles cases where the deallocate event fell outside Activity Log and +# Resource Health retention windows. +$perpetuallyDeallocated = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +$vmCandidates = @($resources | Where-Object { + $_.Kind -eq 'VirtualMachine' -and + $metricResults.ContainsKey($_.ResourceId.ToLowerInvariant()) -and + $metricResults[$_.ResourceId.ToLowerInvariant()].SuspectMinutes -ge $totalMinutes +}) +if ($vmCandidates.Count -gt 0) { + foreach ($vm in $vmCandidates) { + try { + $ivUrl = "https://management.azure.com$($vm.ResourceId)/instanceView?api-version=2024-07-01" + $ivResp = Invoke-RestMethod -Uri $ivUrl -Headers @{ Authorization = "Bearer $armToken" } -Method Get + $powerCode = ($ivResp.statuses | Where-Object { $_.code -like 'PowerState/*' } | Select-Object -First 1).code + if ($powerCode -eq 'PowerState/deallocated' -or $powerCode -eq 'PowerState/stopped') { + $perpetuallyDeallocated.Add($vm.ResourceId) | Out-Null + $elig = $eligByRes[$vm.ResourceId.ToLowerInvariant()] + $elig.EligibleMinutes = 0 + $elig.ExcusedMinutes = $totalMinutes + $elig.SuspectMinutes = $totalMinutes + $elig.AvailabilityPct = 'N/A' + Write-Host " [$($vm.Name)] Deallocated for entire period — excluded from availability counts" + } + } + catch { + Write-Warning "Instance View check failed for '$($vm.Name)': $_" + } + } +} + # Step 6: Build suspect candidates and investigate via Activity Log + Resource Health # Build the list of resources that have at least one suspect minute — # these need Activity Log + Resource Health investigation. Combine null @@ -2244,6 +2385,7 @@ if ($Batch) { $suspectCandidates = [System.Collections.Generic.List[object]]::new() foreach ($res in $resources) { $key = $res.ResourceId.ToLowerInvariant() + if ($perpetuallyDeallocated.Contains($res.ResourceId)) { continue } $mr = $metricResults[$key] if ($mr -and $mr.SuspectMinutes -gt 0) { $allTicks = [System.Collections.Generic.List[long]]::new() @@ -2269,12 +2411,10 @@ foreach ($res in $resources) { # complement REST API data in hybrid mode (LA covers pre-30-day, REST provides # curated authoritative data for the last ~30 days). $logAnalyticsData = $null -if ($Workspace -and $suspectCandidates.Count -gt 0) { +if ($SourceWorkspaceId -and $suspectCandidates.Count -gt 0) { Write-Host -NoNewline 'Fetching Activity Log + Resource Health history from Log Analytics... ' - $laToken = (Get-AzAccessToken -ResourceUrl 'https://api.loganalytics.io').Token - $laTokenStr = ($laToken -is [securestring]) ? ($laToken | ConvertFrom-SecureString -AsPlainText) : [string]$laToken - $laToken = $null - $logAnalyticsData = Get-LogAnalyticsData -WorkspaceId $Workspace ` + $laTokenStr = Get-PlainToken 'https://api.loganalytics.io' + $logAnalyticsData = Get-LogAnalyticsData -WorkspaceId $SourceWorkspaceId ` -SubscriptionIds $subIds -PeriodStart $utcStart -PeriodEnd $utcEnd ` -ArmToken $laTokenStr $laTokenStr = $null @@ -2313,6 +2453,9 @@ foreach ($res in $resources) { continue } + # Skip perpetually-deallocated VMs — already handled in Step 5b + if ($perpetuallyDeallocated.Contains($res.ResourceId)) { continue } + $activityLogExcludedGapMinutes = 0 $healthExplainedGapMinutes = 0 $metricIssueNullMinutes = 0 @@ -2457,5 +2600,115 @@ $sorted = @($eligByRes.Values | Write-ResultsTable $sorted Write-SubscriptionSummaries $sorted +# Step 9: Optional Log Analytics ingestion +if ($sendToLogAnalytics) { + Write-Host -NoNewline 'Sending results to Log Analytics... ' + + $monitorToken = Get-PlainToken 'https://monitor.azure.com' + + $runId = [guid]::NewGuid().ToString() + $normalizedMonth = $window.NormalizedMonth + $isMonthToDate = $window.IsMonthToDate + $periodStartIso = $utcStart.ToString('o') + $periodEndIso = $utcEnd.ToString('o') + + # Build per-resource detail payload + $resourcePayload = @(foreach ($elig in $eligByRes.Values) { + @{ + RunId = $runId + Month = $normalizedMonth + PeriodStart = $periodStartIso + PeriodEnd = $periodEndIso + IsMonthToDate = $isMonthToDate + SubscriptionName = $elig.SubscriptionName + ResourceName = $elig.Name + ResourceId = $elig.ResourceId + ResourceGroup = $elig.ResourceGroupName + Kind = $elig.Kind + Location = $elig.Location + EligibleMinutes = $elig.EligibleMinutes + AvailableMinutes = $elig.AvailableMinutes + SuspectMinutes = $elig.SuspectMinutes + ConfirmedDowntimeMinutes = $elig.ConfirmedDowntimeMinutes + ExcusedMinutes = $elig.ExcusedMinutes + UnexplainedSuspectMinutes = $elig.UnexplainedSuspectMinutes + AvailabilityPct = if ($elig.AvailabilityPct -eq 'N/A') { -1 } else { [double]$elig.AvailabilityPct } + } + }) + + Send-ToLogAnalytics -Endpoint $DceEndpoint -RuleId $DcrImmutableId ` + -StreamName 'Custom-GetAvailResources_CL' -Token $monitorToken -Payload $resourcePayload + + # Build summary payload + $eligible = @($sorted | Where-Object { $_.AvailabilityPct -ne 'N/A' }) + $summaryPayload = [System.Collections.Generic.List[hashtable]]::new() + + $commonSummary = @{ + RunId = $runId + Month = $normalizedMonth + PeriodStart = $periodStartIso + PeriodEnd = $periodEndIso + IsMonthToDate = $isMonthToDate + } + + foreach ($subGroup in ($eligible | Group-Object SubscriptionName | Sort-Object Name)) { + foreach ($g in ($subGroup.Group | Group-Object { "$($_.Kind)|$($_.Location)" } | Sort-Object Name)) { + $items = @($g.Group) + $a = ($items | Measure-Object AvailableMinutes -Sum).Sum + $e = ($items | Measure-Object EligibleMinutes -Sum).Sum + $pct = if ($e -gt 0) { [math]::Round($a / $e * 100, 5) } else { 0 } + $summaryPayload.Add(($commonSummary + @{ + SummaryLevel = 'KindLocation' + SubscriptionName = $subGroup.Name + Kind = $items[0].Kind + Location = $items[0].Location + ResourceCount = $items.Count + EligibleMinutes = [math]::Round($e, 2) + AvailableMinutes = [math]::Round($a, 2) + AvailabilityPct = $pct + })) + } + $ta = ($subGroup.Group | Measure-Object AvailableMinutes -Sum).Sum + $te = ($subGroup.Group | Measure-Object EligibleMinutes -Sum).Sum + $tpct = if ($te -gt 0) { [math]::Round($ta / $te * 100, 5) } else { 0 } + $summaryPayload.Add(($commonSummary + @{ + SummaryLevel = 'SubscriptionTotal' + SubscriptionName = $subGroup.Name + Kind = '' + Location = '' + ResourceCount = $subGroup.Count + EligibleMinutes = [math]::Round($te, 2) + AvailableMinutes = [math]::Round($ta, 2) + AvailabilityPct = $tpct + })) + } + + # Cross-subscription overall (only if >1 subscription) + $subs = @($eligible | ForEach-Object { $_.SubscriptionName } | Select-Object -Unique) + if ($subs.Count -gt 1 -and $eligible.Count -gt 0) { + $oa = ($eligible | Measure-Object AvailableMinutes -Sum).Sum + $oe = ($eligible | Measure-Object EligibleMinutes -Sum).Sum + $opct = if ($oe -gt 0) { [math]::Round($oa / $oe * 100, 5) } else { 0 } + $summaryPayload.Add(($commonSummary + @{ + SummaryLevel = 'Overall' + SubscriptionName = '' + Kind = '' + Location = '' + ResourceCount = $eligible.Count + EligibleMinutes = [math]::Round($oe, 2) + AvailableMinutes = [math]::Round($oa, 2) + AvailabilityPct = $opct + })) + } + + if ($summaryPayload.Count -gt 0) { + Send-ToLogAnalytics -Endpoint $DceEndpoint -RuleId $DcrImmutableId ` + -StreamName 'Custom-GetAvailSummary_CL' -Token $monitorToken -Payload $summaryPayload.ToArray() + } + + $monitorToken = $null + Write-Host "OK (RunId: $runId, $($resourcePayload.Count) resource rows, $($summaryPayload.Count) summary rows)" +} + $sw.Stop() Write-Host "Completed in $($sw.Elapsed.ToString('hh\:mm\:ss\.ff'))" diff --git a/Functions/GetAvail/host.json b/Functions/GetAvail/host.json new file mode 100644 index 0000000..9554c3b --- /dev/null +++ b/Functions/GetAvail/host.json @@ -0,0 +1,18 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "default": "Warning", + "Function": "Information" + }, + "applicationInsights": { + "samplingSettings": { + "isEnabled": false + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/Functions/GetAvail/profile.ps1 b/Functions/GetAvail/profile.ps1 new file mode 100644 index 0000000..d3d2757 --- /dev/null +++ b/Functions/GetAvail/profile.ps1 @@ -0,0 +1,10 @@ +# Azure Functions profile.ps1 +# +# Runs on every "cold start" of the Function App. Sets up Azure context +# so subsequent function invocations can call Az cmdlets immediately. + +# Authenticate with Azure PowerShell using the Function App's managed identity. +if ($env:FUNCTIONS_WORKER_RUNTIME -eq 'powershell') { + Disable-AzContextAutosave -Scope Process | Out-Null + Connect-AzAccount -Identity +} diff --git a/Functions/GetAvail/requirements.psd1 b/Functions/GetAvail/requirements.psd1 new file mode 100644 index 0000000..0c0b158 --- /dev/null +++ b/Functions/GetAvail/requirements.psd1 @@ -0,0 +1,7 @@ +# Module manifest for Azure Functions managed dependencies. +# Flex Consumption does not support managed dependencies — use Save-Module +# to vendor modules into the Modules/ folder instead. + +@{ +# 'Az' = '14.*' +} diff --git a/csharp/Get-Availability.sln b/Old/Get-Availability.sln similarity index 100% rename from csharp/Get-Availability.sln rename to Old/Get-Availability.sln diff --git a/csharp/GetAvailability/GetAvailability.csproj b/Old/GetAvailability/GetAvailability.csproj similarity index 100% rename from csharp/GetAvailability/GetAvailability.csproj rename to Old/GetAvailability/GetAvailability.csproj diff --git a/csharp/GetAvailability/Models/EligibilityResult.cs b/Old/GetAvailability/Models/EligibilityResult.cs similarity index 100% rename from csharp/GetAvailability/Models/EligibilityResult.cs rename to Old/GetAvailability/Models/EligibilityResult.cs diff --git a/csharp/GetAvailability/Models/MetricScalars.cs b/Old/GetAvailability/Models/MetricScalars.cs similarity index 100% rename from csharp/GetAvailability/Models/MetricScalars.cs rename to Old/GetAvailability/Models/MetricScalars.cs diff --git a/csharp/GetAvailability/Models/TrackedResource.cs b/Old/GetAvailability/Models/TrackedResource.cs similarity index 100% rename from csharp/GetAvailability/Models/TrackedResource.cs rename to Old/GetAvailability/Models/TrackedResource.cs diff --git a/csharp/GetAvailability/Output/SummaryWriter.cs b/Old/GetAvailability/Output/SummaryWriter.cs similarity index 100% rename from csharp/GetAvailability/Output/SummaryWriter.cs rename to Old/GetAvailability/Output/SummaryWriter.cs diff --git a/csharp/GetAvailability/Program.cs b/Old/GetAvailability/Program.cs similarity index 100% rename from csharp/GetAvailability/Program.cs rename to Old/GetAvailability/Program.cs diff --git a/csharp/GetAvailability/Services/ActivityLogService.cs b/Old/GetAvailability/Services/ActivityLogService.cs similarity index 100% rename from csharp/GetAvailability/Services/ActivityLogService.cs rename to Old/GetAvailability/Services/ActivityLogService.cs diff --git a/csharp/GetAvailability/Services/BatchMetricsService.cs b/Old/GetAvailability/Services/BatchMetricsService.cs similarity index 100% rename from csharp/GetAvailability/Services/BatchMetricsService.cs rename to Old/GetAvailability/Services/BatchMetricsService.cs diff --git a/csharp/GetAvailability/Services/LogAnalyticsService.cs b/Old/GetAvailability/Services/LogAnalyticsService.cs similarity index 100% rename from csharp/GetAvailability/Services/LogAnalyticsService.cs rename to Old/GetAvailability/Services/LogAnalyticsService.cs diff --git a/csharp/GetAvailability/Services/MetricsService.cs b/Old/GetAvailability/Services/MetricsService.cs similarity index 100% rename from csharp/GetAvailability/Services/MetricsService.cs rename to Old/GetAvailability/Services/MetricsService.cs diff --git a/csharp/GetAvailability/Services/ResourceHealthService.cs b/Old/GetAvailability/Services/ResourceHealthService.cs similarity index 100% rename from csharp/GetAvailability/Services/ResourceHealthService.cs rename to Old/GetAvailability/Services/ResourceHealthService.cs diff --git a/csharp/GetAvailability/Services/ResourceInventoryService.cs b/Old/GetAvailability/Services/ResourceInventoryService.cs similarity index 100% rename from csharp/GetAvailability/Services/ResourceInventoryService.cs rename to Old/GetAvailability/Services/ResourceInventoryService.cs diff --git a/csharp/GetAvailability/Services/SubscriptionResolver.cs b/Old/GetAvailability/Services/SubscriptionResolver.cs similarity index 100% rename from csharp/GetAvailability/Services/SubscriptionResolver.cs rename to Old/GetAvailability/Services/SubscriptionResolver.cs diff --git a/Old/README.md b/Old/README.md new file mode 100644 index 0000000..5973ade --- /dev/null +++ b/Old/README.md @@ -0,0 +1,77 @@ +# Get-Availability — C# Version + +Native AOT implementation of Get-Availability using .NET 10. Produces a ~15 MB standalone binary with no runtime dependency. + +For the full pipeline description, classification rules, output format, and invariants shared with the PowerShell version, see the [main README](../README.md). + +## Prerequisites + +| Requirement | Detail | +|---|---| +| .NET SDK | 10.0 or later (build from source only) | +| Azure auth | `az login` or any method supported by `DefaultAzureCredential` | + +The published binary (`GetAvailability.exe`) requires no .NET runtime — it is a Native AOT self-contained executable. + +If Azure authentication fails, the tool prints the SDK exception message directly. Re-run `az login` to fix. + +## Parameters + +| Option | Short | Default | Description | +|---|---|---|---| +| `--subscriptions` | `-s` | *(required)* | One or more Azure subscription display names | +| `--month` | `-m` | *(required)* | Observation month in UTC, format `YYYYMM` | +| `--kinds` | `-k` | `vm sql storage webapp` | Resource kinds to process | +| `--resource` | `-r` | *(all)* | Filter to a single resource name | +| `--parallelism` | `-p` | *(auto)* | Max concurrent API calls (scales to CPU cores) | +| `--activity-grace-minutes` | `-g` | `10` | Post-operation grace window for Activity Log lifecycle events | +| `--batch` | `-b` | off | Use the regional Metrics Batch API instead of per-resource calls | +| `--batch-size` | | `10` | Max resources per batch call (1–50); implies `--batch` | +| `--workspace` | `-w` | *(none)* | Log Analytics workspace ID (GUID). Fetches Activity Log via bulk KQL; Resource Health uses hybrid approach (KQL for older + REST for last ~30 days) | +| `--version` | `-v` | | Print version and exit | + +## Build + +```bash +cd Old/GetAvailability + +# Debug (JIT, for development) +dotnet build + +# Release Native AOT binary +dotnet publish -c Release -r win-x64 # output in bin/Release/net10.0/win-x64/publish/ +``` + +## Examples + +```bash +# Single subscription +./GetAvailability --subscriptions Contoso-Production --month 202603 + +# Multiple subscriptions, filtered by kind +./GetAvailability --subscriptions Contoso-Development Contoso-Production --month 202603 --kinds vm sql + +# Single resource (SQL database by server/database name) +./GetAvailability --subscriptions Contoso-Development --month 202603 --resource sqlserver01/sqldb01 + +# Batch API with custom batch size +./GetAvailability --subscriptions Contoso-Production Contoso-Development --month 202603 --batch-size 20 + +# Use Log Analytics for Activity Log + Resource Health (faster, extended retention) +./GetAvailability --subscriptions Contoso-Production --month 202603 --workspace b233a4b7-3c43-433c-ac60-1f6ff217ddd4 + +# Run directly without publishing +cd Old/GetAvailability +dotnet run -- --subscriptions Contoso-Production --month 202603 +``` + +## Implementation Notes + +- **`Parallel.ForEachAsync`** for concurrent metric, Activity Log, and Resource Health queries with configurable parallelism. +- **`System.Text.Json`** for AOT-safe, efficient JSON parsing. +- **Native AOT** — ~15 MB standalone binary, no .NET runtime required. +- **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. +- **HashSet-based interval containment** — suspect-minute classification pre-expands intervals into `HashSet` tick sets for O(1) lookups instead of linear scans. + +> **Note:** The C# version does not currently support the Log Analytics ingestion feature (`-DceEndpoint`/`-DcrImmutableId`). Use the PowerShell version for that capability. diff --git a/README.md b/README.md index fa4e957..760f7b4 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,9 @@ Reports month-scoped availability for Azure Virtual Machines, Azure SQL Databases, Azure Storage Accounts, and Azure Web Apps across one or more Azure subscriptions. -Two equivalent implementations are provided: +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. -| Version | Path | Runtime | Notes | -|---|---|---|---| -| **C#** | `csharp/GetAvailability/` | .NET 10 Native AOT (~15 MB standalone binary, no runtime required) | Fastest; recommended for production use | -| **PowerShell** | `get-availability.ps1` | PowerShell 7+ with `Az.Accounts` and `Az.ResourceGraph` modules | No build step; convenient for ad-hoc use | - -Both versions share the same pipeline, classification rules, output format, and invariants. +> A legacy C# (Native AOT) implementation is preserved in [`Old/`](Old/README.md) but is not actively maintained. For each resource, the tool answers: @@ -28,17 +23,6 @@ The relationship `Suspect = Faults + Excused + Unresolved` always holds. ### Prerequisites -**C# version:** - -| Requirement | Detail | -|---|---| -| .NET SDK | 10.0 or later (build from source only) | -| Azure auth | `az login` or any method supported by `DefaultAzureCredential` | - -The published binary (`GetAvailability.exe`) requires no .NET runtime — it is a Native AOT self-contained executable. - -**PowerShell version:** - | Requirement | Detail | |---|---| | PowerShell | 7.0 or later (`pwsh`) | @@ -46,27 +30,10 @@ The published binary (`GetAvailability.exe`) requires no .NET runtime — it is | 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 SDK/module exception message directly. For Azure CLI-based auth (C#), re-run `az login`; for PowerShell, re-run `Connect-AzAccount`. +If Azure authentication fails, the tool prints the module exception message directly. Re-run `Connect-AzAccount` to fix. ### Parameters -**C# (`GetAvailability.exe`):** - -| Option | Short | Default | Description | -|---|---|---|---| -| `--subscriptions` | `-s` | *(required)* | One or more Azure subscription display names | -| `--month` | `-m` | *(required)* | Observation month in UTC, format `YYYYMM` | -| `--kinds` | `-k` | `vm sql storage webapp` | Resource kinds to process | -| `--resource` | `-r` | *(all)* | Filter to a single resource name | -| `--parallelism` | `-p` | *(auto)* | Max concurrent API calls (scales to CPU cores) | -| `--activity-grace-minutes` | `-g` | `10` | Post-operation grace window for Activity Log lifecycle events | -| `--batch` | `-b` | off | Use the regional Metrics Batch API instead of per-resource calls | -| `--batch-size` | | `10` | Max resources per batch call (1–50); implies `--batch` | -| `--workspace` | `-w` | *(none)* | Log Analytics workspace ID (GUID). Fetches Activity Log via bulk KQL; Resource Health uses hybrid approach (KQL for older + REST for last ~30 days) | -| `--version` | `-v` | | Print version and exit | - -**PowerShell (`get-availability.ps1`):** - | Parameter | Default | Description | |---|---|---| | `-Subscriptions` | *(required)* | One or more Azure subscription display names | @@ -77,60 +44,38 @@ If Azure authentication fails, the tool prints the SDK/module exception message | `-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` | -| `-Workspace` | *(none)* | Log Analytics workspace ID (GUID). Fetches Activity Log lifecycle events from the workspace 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. Provides complete Resource Health coverage across the full observation window. | +| `-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 `-Workspace` / `--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). +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). ### Examples -**C#:** - -```bash -# Build the Native AOT binary (one-time) -cd csharp/GetAvailability -dotnet publish -c Release -r win-x64 # output in bin/Release/net10.0/win-x64/publish/ - -# Single subscription -./GetAvailability --subscriptions Contoso-Production --month 202603 - -# Multiple subscriptions, filtered by kind -./GetAvailability --subscriptions Contoso-Development Contoso-Production --month 202603 --kinds vm sql - -# Single resource (SQL database by server/database name) -./GetAvailability --subscriptions Contoso-Development --month 202603 --resource sqlserver01/sqldb01 - -# Batch API with custom batch size -./GetAvailability --subscriptions Contoso-Production Contoso-Development --month 202603 --batch-size 20 - -# Use Log Analytics for Activity Log + Resource Health (faster, extended retention) -./GetAvailability --subscriptions Contoso-Production --month 202603 --workspace b233a4b7-3c43-433c-ac60-1f6ff217ddd4 - -# Run directly without publishing -cd csharp/GetAvailability -dotnet run -- --subscriptions Contoso-Production --month 202603 -``` - -**PowerShell:** - ```powershell # Single subscription -./get-availability.ps1 -Subscriptions 'Contoso-Production' -Month 202603 +./Functions/GetAvail/get-availability.ps1 -Subscriptions 'Contoso-Production' -Month 202603 # Multiple subscriptions, filtered by kind -./get-availability.ps1 -Subscriptions 'Contoso-Development','Contoso-Production' -Month 202603 -Kinds vm,sql +./Functions/GetAvail/get-availability.ps1 -Subscriptions 'Contoso-Development','Contoso-Production' -Month 202603 -Kinds vm,sql # Single resource with custom grace window -./get-availability.ps1 -Subscriptions 'Contoso-Development' -Month 202603 -Resource myvm02 -ActivityGraceMinutes 15 +./Functions/GetAvail/get-availability.ps1 -Subscriptions 'Contoso-Development' -Month 202603 -Resource myvm02 -ActivityGraceMinutes 15 # Batch API with custom batch size -./get-availability.ps1 -Subscriptions 'Contoso-Production','Contoso-Development' -Month 202603 -BatchSize 20 +./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) -./get-availability.ps1 -Subscriptions 'Contoso-Production' -Month 202603 -Workspace 'b233a4b7-3c43-433c-ac60-1f6ff217ddd4' +./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 -./get-availability.ps1 -Subscriptions 'Contoso-Production' -Month 202603 | Export-Csv availability.csv +./Functions/GetAvail/get-availability.ps1 -Subscriptions 'Contoso-Production' -Month 202603 | Export-Csv availability.csv ``` ### Output @@ -147,10 +92,10 @@ If the observation window extends beyond the Resource Health retention window, a 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 `-Workspace` is used, the 30-day warning is suppressed (hybrid coverage applies) and an informational line is printed: +When `-SourceWorkspaceId` is used, the 30-day warning is suppressed (hybrid coverage applies) and an informational line is printed: ``` -Log Analytics workspace: b233a4b7-…-1f6ff217ddd4 (Activity Log via KQL, Resource Health via KQL + REST API hybrid) +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): @@ -223,8 +168,8 @@ Matching minutes are treated as customer/admin lifecycle activity and removed fr **2. Health History** — Resource Health transitions are converted into three interval types (below). Two data source modes are supported: -- **REST API only** (default, no `-Workspace`): 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** (`-Workspace` / `--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. +- **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. Health transition interval types: @@ -287,15 +232,182 @@ AvailabilityPct = 40,066 / 40,125 × 100 = 99.85390% ## Implementation notes -These notes cover performance and implementation details specific to each version. - -- **`Parallel.ForEachAsync`** (C#) / **`ForEach-Object -Parallel`** (PowerShell) for concurrent metric, Activity Log, and Resource Health queries with configurable parallelism. -- **Shared `HttpClient`** with connection pooling (PowerShell) — 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** (PowerShell) — the ~44k-datapoint-per-resource JSON processing loop is compiled as C# via `Add-Type` and runs at native .NET speed. -- **Compiled gap processor** (PowerShell) — `ExpandToTickSet` (interval → `HashSet`) and `ClassifyGaps` (minute-by-minute classification) are also compiled via `Add-Type`. -- **Idempotent `Add-Type` guards** (PowerShell) — each compiled C# block (`MetricProcessor`, `GapProcessor`) is independently guarded by a `PSTypeName` check so the script can be re-run within the same session. +- **`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 in PowerShell and enables AOT-safe parsing in C#. -- **Native AOT** (C#) — ~15 MB standalone binary, no .NET runtime required. +- **`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 | +|---|---| +| `GetAvailResources_CL` | Per-resource detail (one row per resource per run) | +| `GetAvailSummary_CL` | Aggregated summaries (per Kind+Location, per subscription, overall) | + +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 | + +RBAC role assignments are created automatically: + +| 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 | + +### Deployment prerequisites + +- **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: + +```powershell +# 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) +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 + +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. + +After deployment, retrieve the managed identity principal ID and assign **Reader** on each target subscription: + +```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) + +# 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" +} +``` + +> **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 "" +``` + +### 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. + +```powershell +# 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) +cd Functions/GetAvail +func azure functionapp publish fn-getavail-itn-001 --powershell +```