tests/FinOpsVMMetrics.Tests.ps1
|
#requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { $root = Split-Path -Parent $PSScriptRoot # Dot-source the private (and supporting) functions directly so we can test them # without an Azure connection. . (Join-Path $root 'Private/Measure-Percentile.ps1') . (Join-Path $root 'Private/ConvertTo-VMPerformanceRow.ps1') . (Join-Path $root 'Private/Write-MetricOutput.ps1') . (Join-Path $root 'Private/Get-VMMetricsBlobName.ps1') . (Join-Path $root 'Private/New-VMMetricsStorageContext.ps1') . (Join-Path $root 'Private/Test-VMExcluded.ps1') . (Join-Path $root 'Public/Publish-VMPerformanceData.ps1') } Describe 'Measure-Percentile' { It 'computes P95 with linear interpolation (Kusto-compatible)' { # 9*0.45 + 10*0.55 = 9.55 (rounded to output precision; raw double is 9.5499...). [math]::Round((Measure-Percentile -Value (1..10) -Percentile 95), 2) | Should -Be 9.55 } It 'computes the median (P50)' { Measure-Percentile -Value (1..10) -Percentile 50 | Should -Be 5.5 } It 'returns the max at P100 and the min at P0' { Measure-Percentile -Value (1..10) -Percentile 100 | Should -Be 10 Measure-Percentile -Value (1..10) -Percentile 0 | Should -Be 1 } It 'returns the single value when only one sample' { Measure-Percentile -Value @(42) -Percentile 95 | Should -Be 42 } It 'returns $null for an empty collection' { Measure-Percentile -Value @() -Percentile 95 | Should -BeNullOrEmpty } It 'ignores nulls in the sample set' { Measure-Percentile -Value @(1, $null, 2, $null, 3) -Percentile 100 | Should -Be 3 } It 'is order-independent' { $a = Measure-Percentile -Value (1..10) -Percentile 95 $b = Measure-Percentile -Value (10..1) -Percentile 95 $a | Should -Be $b } } Describe 'ConvertTo-VMPerformanceRow' { BeforeAll { $jan = [datetime]'2026-01-15T10:00:00Z' $feb = [datetime]'2026-02-15T10:00:00Z' $script:cpu = @( [pscustomobject]@{ TimeStamp = $jan; Average = 10.0 } [pscustomobject]@{ TimeStamp = $jan; Average = 30.0 } [pscustomobject]@{ TimeStamp = $feb; Average = 50.0 } ) # 4 GiB available out of 8 GiB SKU -> 50% committed proxy, 4096 MB available. $script:mem = @( [pscustomobject]@{ TimeStamp = $jan; Average = 4GB } [pscustomobject]@{ TimeStamp = $jan; Average = 4GB } ) } It 'emits one CPU row per calendar month' { $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint $cpu -MemPoint @() $cpuRows = $rows | Where-Object CounterName -eq '% Processor Time' $cpuRows.Count | Should -Be 2 ($cpuRows.Date | Sort-Object) | Should -Be @('2026-01-01', '2026-02-01') } It 'aggregates CPU stats within a month' { $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint $cpu -MemPoint @() $janCpu = $rows | Where-Object { $_.CounterName -eq '% Processor Time' -and $_.Date -eq '2026-01-01' } $janCpu.Min | Should -Be 10 $janCpu.Max | Should -Be 30 $janCpu.Average | Should -Be 20 } It 'emits exact Available MBytes' { $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint @() -MemPoint $mem -TotalRamMB 8192 $avail = $rows | Where-Object CounterName -eq 'Available MBytes' $avail.Count | Should -Be 1 $avail.Average | Should -Be 4096 } It 'derives the committed-memory proxy from total RAM' { $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint @() -MemPoint $mem -TotalRamMB 8192 $comm = $rows | Where-Object CounterName -eq '% Committed Bytes In Use' $comm.Count | Should -Be 1 $comm.Average | Should -Be 50 } It 'skips the committed proxy when total RAM is unknown' { $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint @() -MemPoint $mem ($rows | Where-Object CounterName -eq '% Committed Bytes In Use') | Should -BeNullOrEmpty ($rows | Where-Object CounterName -eq 'Available MBytes') | Should -Not -BeNullOrEmpty } It 'produces the full VMPerformance schema on every row' { $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint $cpu -MemPoint $mem -TotalRamMB 8192 ` -ResourceId '/subscriptions/S/resourceGroups/RG/providers/Microsoft.Compute/virtualMachines/vm1' -SkuName 'Standard_D4as_v5' $expected = 'resourceId', 'vmName', 'skuName', 'Date', 'CounterName', 'Min', 'Median', 'Percentile95', 'Percentile99', 'Max', 'Average', 'SampleCount' foreach ($r in $rows) { ($r.PSObject.Properties.Name) | Should -Be $expected } } It 'lowercases the resourceId for reliable Kusto joins' { $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint $cpu -MemPoint @() ` -ResourceId '/subscriptions/S/resourceGroups/RG/providers/Microsoft.Compute/virtualMachines/MyVM' $rows[0].resourceId | Should -Be '/subscriptions/s/resourcegroups/rg/providers/microsoft.compute/virtualmachines/myvm' } } Describe 'Write-MetricOutput' { BeforeAll { $script:rows = ConvertTo-VMPerformanceRow -VMName 'vm1' ` -CpuPoint @([pscustomobject]@{ TimeStamp = [datetime]'2026-01-15T10:00:00Z'; Average = 25.0 }) ` -MemPoint @() $script:outDir = Join-Path ([System.IO.Path]::GetTempPath()) ("vmmetrics-test-" + [guid]::NewGuid()) New-Item -ItemType Directory -Path $outDir -Force | Out-Null } AfterAll { if (Test-Path $script:outDir) { Remove-Item $script:outDir -Recurse -Force } } It 'writes a CSV with the rows' { $path = Join-Path $outDir 'out.csv' $written = Write-MetricOutput -Row $rows -Path $path -Format CSV Test-Path $written | Should -BeTrue (Import-Csv $written).Count | Should -Be $rows.Count } It 'writes a sidecar manifest that flags the memory proxy' { $path = Join-Path $outDir 'out2.csv' Write-MetricOutput -Row $rows -Path $path -Format CSV -Manifest @{ windowDays = 30 } | Out-Null $manifest = Get-Content (Join-Path $outDir 'out2.manifest.json') -Raw | ConvertFrom-Json $manifest.windowDays | Should -Be 30 $manifest.counters.'% Committed Bytes In Use' | Should -Match 'PROXY' } It 'falls back to CSV with a warning when Parquet is unavailable' { $path = Join-Path $outDir 'out3.parquet' $written = Write-MetricOutput -Row $rows -Path $path -Format Parquet -WarningAction SilentlyContinue $written | Should -BeLike '*.csv' } } Describe 'Get-VMMetricsBlobName' { It 'builds {customer}/{date}/{file}' { Get-VMMetricsBlobName -Customer 'Demo' -Date '2026-06-11' -FileName 'VMPerformance-2026-06-11.csv' | Should -Be 'Demo/2026-06-11/VMPerformance-2026-06-11.csv' } It 'keeps spaces in real customer names' { Get-VMMetricsBlobName -Customer 'WorkSafe BC' -Date '2026-06-11' -FileName 'f.csv' | Should -Be 'WorkSafe BC/2026-06-11/f.csv' } It 'rejects a malformed date' { { Get-VMMetricsBlobName -Customer 'Demo' -Date '11.06.2026' -FileName 'f.csv' } | Should -Throw } } Describe 'Publish-VMPerformanceData' { BeforeAll { $script:pubDir = Join-Path ([System.IO.Path]::GetTempPath()) ("vmmetrics-pub-" + [guid]::NewGuid()) New-Item -ItemType Directory -Path $pubDir -Force | Out-Null $script:csv = Join-Path $pubDir 'VMPerformance-2026-06-11.csv' 'vmName,Date,CounterName' | Set-Content $csv 'vm1,2026-06-01,% Processor Time' | Add-Content $csv Set-Content (Join-Path $pubDir 'VMPerformance-2026-06-11.manifest.json') '{"rowCount":1}' # A real (offline) storage context so the mocked Set-AzStorageBlobContent's # -Context type transform passes. New-AzStorageContext builds it locally, no network. $script:fakeCtx = New-AzStorageContext -StorageAccountName 'acct' ` -StorageAccountKey ([Convert]::ToBase64String([byte[]](1..64))) } AfterAll { if (Test-Path $script:pubDir) { Remove-Item $script:pubDir -Recurse -Force } } It 'uploads CSV + manifest to {customer}/{date}/ using the parsed date' { Mock New-VMMetricsStorageContext { $script:fakeCtx } # Pester exposes the bound parameters ($Container, $Blob) as variables in the mock body. Mock Set-AzStorageBlobContent { [pscustomobject]@{ ICloudBlob = [pscustomobject]@{ Uri = [pscustomobject]@{ AbsoluteUri = "https://acct/$Container/$Blob" } } } } $res = Publish-VMPerformanceData -Path $csv -Customer 'Demo' ` -StorageAccountName 'acct' -StorageAccountResourceGroup 'rg' $res.BlobUri | Should -Be 'https://acct/vmperformance/Demo/2026-06-11/VMPerformance-2026-06-11.csv' $res.ManifestUri | Should -Be 'https://acct/vmperformance/Demo/2026-06-11/VMPerformance-2026-06-11.manifest.json' Should -Invoke Set-AzStorageBlobContent -Times 2 } } Describe 'New-VMMetricsStorageContext (SAS auth)' { It 'uses the SAS token and skips key fetch + container ops' { Mock New-AzStorageContext { 'ctx-from-sas' } -ParameterFilter { $SasToken } Mock Get-AzStorageAccountKey { throw 'key fetch should not be called with a SAS token' } Mock Get-AzStorageContainer { throw 'container ops should not be called with a SAS token' } Mock New-AzStorageContainer { throw 'container ops should not be called with a SAS token' } $ctx = New-VMMetricsStorageContext -StorageAccountName 'acct' -ContainerName 'vmperformance' -SasToken '?sv=x&sig=y' $ctx | Should -Be 'ctx-from-sas' Should -Invoke Get-AzStorageAccountKey -Times 0 Should -Invoke New-AzStorageContainer -Times 0 } } Describe 'Test-VMExcluded' { BeforeAll { function MakeRec { param($Name = 'vm', $Rg = 'rg-prod', $Sku = 'Standard_D4as_v5', $Vmss = '', $Priority = 'Regular', $Tags = @{}, $Created = $null) [pscustomobject]@{ Id = "/subscriptions/s/resourceGroups/$Rg/providers/Microsoft.Compute/virtualMachines/$Name" Name = $Name; ResourceGroup = $Rg; Location = 'westeurope'; SkuName = $Sku Tags = $Tags; Vmss = $Vmss; Priority = $Priority; Created = $Created } } $script:defaults = @{ ExcludeResourceGroupPattern = @('^databricks-rg-', '^MC_') ExcludeTag = @{ Vendor = 'Databricks' } } } It 'keeps a normal standalone prod VM' { Test-VMExcluded -VM (MakeRec) @defaults | Should -BeNullOrEmpty } It 'excludes a VMSS (Flex) member' { Test-VMExcluded -VM (MakeRec -Vmss '/subscriptions/s/.../vmss1') @defaults | Should -Be 'VMSS member' } It 'keeps a VMSS member when -IncludeScaleSetMember' { Test-VMExcluded -VM (MakeRec -Vmss '/x') -IncludeScaleSetMember @defaults | Should -BeNullOrEmpty } It 'excludes a Spot VM' { Test-VMExcluded -VM (MakeRec -Priority 'Spot') @defaults | Should -Be 'Spot' } It 'excludes a Databricks managed resource group' { Test-VMExcluded -VM (MakeRec -Rg 'databricks-rg-myws-abc123') @defaults | Should -Match 'databricks' } It 'excludes an AKS node resource group' { Test-VMExcluded -VM (MakeRec -Rg 'MC_rg_cluster_westeurope') @defaults | Should -Match 'MC_' } It 'excludes by Vendor=Databricks tag (case-insensitive)' { Test-VMExcluded -VM (MakeRec -Tags @{ vendor = 'databricks' }) @defaults | Should -Be 'tag vendor=databricks' } It 'excludes by a custom name pattern' { Test-VMExcluded -VM (MakeRec -Name 'spark-worker-7') -ExcludeNamePattern 'worker' | Should -Match 'worker' } It 'excludes VMs younger than MinAgeDays' { $now = [datetime]'2026-06-12T00:00:00Z' Test-VMExcluded -VM (MakeRec -Created $now.AddDays(-2)) -MinAgeDays 7 -Now $now | Should -Match 'younger' } It 'keeps VMs older than MinAgeDays' { $now = [datetime]'2026-06-12T00:00:00Z' Test-VMExcluded -VM (MakeRec -Created $now.AddDays(-30)) -MinAgeDays 7 -Now $now | Should -BeNullOrEmpty } } |