diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index ef17e2e00..d66a5e7cf 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -3,7 +3,7 @@ title: FinOps toolkit changelog description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more. author: MSBrett ms.author: brettwil -ms.date: 06/22/2026 +ms.date: 07/07/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -25,6 +25,11 @@ This article summarizes the features and enhancements in each release of the Fin The following section lists features and enhancements that are currently in development. +### [FinOps hubs](hubs/finops-hubs-overview.md) + +- **Added** + - Added VNet and private network modes, including opt-in NAT Gateway support for private mode; NAT Gateway incurs additional cost when enabled ([#2163](https://github.com/microsoft/finops-toolkit/pull/2163)). + -->
diff --git a/src/powershell/Public/Deploy-FinOpsHub.ps1 b/src/powershell/Public/Deploy-FinOpsHub.ps1 index 26d9713d2..5f62f99ad 100644 --- a/src/powershell/Public/Deploy-FinOpsHub.ps1 +++ b/src/powershell/Public/Deploy-FinOpsHub.ps1 @@ -84,8 +84,11 @@ .PARAMETER DataExplorerFinalRetentionInMonths Optional. Number of months of data to retain in the Data Explorer *_final_v* tables. Default: 13. - .PARAMETER EnablePublicAccess - Optional. Enable public access to the data lake. Default: true. + .PARAMETER NetworkMode + Optional. Network mode for the hub: 'public' (default), 'vnet' (private endpoints, default outbound), or 'private' (private endpoints + NAT Gateway, subnets locked down with defaultOutboundAccess=false - required when the 'Subnets should be private' policy is enforced). + + .PARAMETER DisablePublicAccess + Optional. Deprecated. Use -NetworkMode 'vnet' or -NetworkMode 'private' instead. When set without -NetworkMode, behaves as -NetworkMode 'vnet'. Ignored when -NetworkMode is supplied. .PARAMETER VirtualNetworkAddressPrefix Optional. Address space for the workload. A /26 is required for the workload. Default: "10.20.30.0/26". @@ -187,6 +190,11 @@ function Deploy-FinOpsHub [int] $DataExplorerFinalRetentionInMonths = 13, + [Parameter()] + [ValidateSet('public', 'vnet', 'private')] + [string] + $NetworkMode, + [Parameter()] [switch] $DisablePublicAccess, @@ -249,6 +257,16 @@ function Deploy-FinOpsHub # Init deployment (register providers) Initialize-FinOpsHubDeployment -WhatIf:$WhatIfPreference + # Resolve network mode. -NetworkMode wins; -DisablePublicAccess (deprecated) maps to 'vnet'. + $effectiveNetworkMode = if ($NetworkMode) { $NetworkMode } elseif ($DisablePublicAccess) { 'vnet' } else { 'public' } + if (-not $NetworkMode -and $DisablePublicAccess) { Write-Warning "-DisablePublicAccess is deprecated; use -NetworkMode 'vnet' or -NetworkMode 'private'." } + + # Private network mode (NAT Gateway) requires hub template version 15.0 or later + if ($effectiveNetworkMode -eq 'private' -and $Version -ne 'latest' -and [version]$Version -lt '15.0') + { + throw "NAT Gateway / private network mode requires hub template version 15.0 or later. Current version: $Version" + } + # Download template if (Test-ShouldProcess $PSCmdlet $Version 'DownloadTemplate') { @@ -281,7 +299,7 @@ function Deploy-FinOpsHub $parameterSplat.TemplateParameterObject.Add('dataExplorerCapacity', $DataExplorerCapacity) $parameterSplat.TemplateParameterObject.Add('dataExplorerRawRetentionInDays', $DataExplorerRawRetentionInDays) $parameterSplat.TemplateParameterObject.Add('dataExplorerFinalRetentionInMonths', $DataExplorerFinalRetentionInMonths) - $parameterSplat.TemplateParameterObject.Add('enablePublicAccess', -not $DisablePublicAccess) + $parameterSplat.TemplateParameterObject.Add('enablePublicAccess', ($effectiveNetworkMode -eq 'public')) $parameterSplat.TemplateParameterObject.Add('virtualNetworkAddressPrefix', $VirtualNetworkAddressPrefix) $parameterSplat.TemplateParameterObject.Add('exportRetentionInDays', $ExportRetentionInDays) $parameterSplat.TemplateParameterObject.Add('ingestionRetentionInMonths', $IngestionRetentionInMonths) @@ -304,6 +322,13 @@ function Deploy-FinOpsHub $parameterSplat.TemplateParameterObject.Add('enablePurgeProtection', $EnablePurgeProtection.IsPresent) } + # Only pass enableNatGateway when private mode is requested. This keeps public/vnet + # deployments compatible with template versions that predate the parameter. + if ($effectiveNetworkMode -eq 'private' -and ($Version -eq 'latest' -or [version]$Version -ge '15.0')) + { + $parameterSplat.TemplateParameterObject.Add('enableNatGateway', $true) + } + if ($Tags -and $Tags.Keys.Count -gt 0) { $parameterSplat.TemplateParameterObject.Add('tags', $Tags) diff --git a/src/powershell/Tests/Initialize-Tests.ps1 b/src/powershell/Tests/Initialize-Tests.ps1 index 51c3f5864..25abeae63 100644 --- a/src/powershell/Tests/Initialize-Tests.ps1 +++ b/src/powershell/Tests/Initialize-Tests.ps1 @@ -3,6 +3,7 @@ Remove-Module FinOpsToolkit -ErrorAction SilentlyContinue Import-Module -FullyQualifiedName "$PSScriptRoot/../FinOpsToolkit.psm1" +Import-Module Pester -Global -ErrorAction Stop BeforeAll { # Bring the Monitor functions in to simplify debugging diff --git a/src/powershell/Tests/Integration/CostExports.Tests.ps1 b/src/powershell/Tests/Integration/CostExports.Tests.ps1 index 6af8a6bc6..c0f9dc633 100644 --- a/src/powershell/Tests/Integration/CostExports.Tests.ps1 +++ b/src/powershell/Tests/Integration/CostExports.Tests.ps1 @@ -337,7 +337,7 @@ Describe 'CostExports' { -Backfill 12 # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Write-Progress' -Times 4 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Write-Progress' -Times 4 $success | Should -Be $true ((Get-Date) - $testStartTime).TotalSeconds | Should -BeGreaterThan 60 } diff --git a/src/powershell/Tests/Unit/Deploy-FinOpsHub.Tests.ps1 b/src/powershell/Tests/Unit/Deploy-FinOpsHub.Tests.ps1 index 45cde0758..1bb4512a0 100644 --- a/src/powershell/Tests/Unit/Deploy-FinOpsHub.Tests.ps1 +++ b/src/powershell/Tests/Unit/Deploy-FinOpsHub.Tests.ps1 @@ -33,9 +33,9 @@ InModuleScope 'FinOpsToolkit' { Deploy-FinOpsHub -WhatIf -Name $hubName -ResourceGroupName $rgName -Location $location # Assert - Assert-MockCalled -CommandName 'Initialize-FinOpsHubDeployment' -Times 1 -ParameterFilter { $WhatIf -eq $true } + Should -Invoke -CommandName 'Initialize-FinOpsHubDeployment' -Times 1 -ParameterFilter { $WhatIf -eq $true } @('CreateResourceGroup', 'CreateTempDirectory', 'DownloadTemplate', 'DeployFinOpsHub') | ForEach-Object { - Assert-MockCalled -CommandName 'Test-ShouldProcess' -Times 1 -ParameterFilter { $Action -eq $_ } + Should -Invoke -CommandName 'Test-ShouldProcess' -Times 1 -ParameterFilter { $Action -eq $_ } } } } @@ -55,8 +55,8 @@ InModuleScope 'FinOpsToolkit' { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location # Assert - Assert-MockCalled -CommandName 'Get-AzResourceGroup' -Times 1 - Assert-MockCalled -CommandName 'New-AzResourceGroup' -Times 1 + Should -Invoke -CommandName 'Get-AzResourceGroup' -Times 1 + Should -Invoke -CommandName 'New-AzResourceGroup' -Times 1 } It 'Should use RG if it exists' { @@ -69,8 +69,8 @@ InModuleScope 'FinOpsToolkit' { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location # Assert - Assert-MockCalled -CommandName 'Get-AzResourceGroup' -Times 1 - Assert-MockCalled -CommandName 'New-AzResourceGroup' -Times 0 + Should -Invoke -CommandName 'Get-AzResourceGroup' -Times 1 + Should -Invoke -CommandName 'New-AzResourceGroup' -Times 0 } } @@ -85,7 +85,7 @@ InModuleScope 'FinOpsToolkit' { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location # Assert - Assert-MockCalled -CommandName 'Initialize-FinOpsHubDeployment' -Times 1 + Should -Invoke -CommandName 'Initialize-FinOpsHubDeployment' -Times 1 } } @@ -111,7 +111,7 @@ InModuleScope 'FinOpsToolkit' { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location # Assert - Assert-MockCalled -CommandName 'New-AzResourceGroupDeployment' -Times 1 + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -Times 1 } It 'Should add tags to the deployment' -Skip { } @@ -130,7 +130,7 @@ InModuleScope 'FinOpsToolkit' { It 'Should throw if template file is not found' { Mock -CommandName 'Get-ChildItem' { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -Version 'latest' } | Should -Throw - Assert-MockCalled -CommandName 'Get-ChildItem' -Times 1 + Should -Invoke -CommandName 'Get-ChildItem' -Times 1 } Context 'More' { @@ -142,14 +142,14 @@ InModuleScope 'FinOpsToolkit' { It 'Should deploy the template without throwing' { { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -Version 'latest' } | Should -Not -Throw - Assert-MockCalled -CommandName 'Get-ChildItem' -Times 1 - Assert-MockCalled -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { @{ TemplateFile = $templateFile } } -Times 1 + Should -Invoke -CommandName 'Get-ChildItem' -Times 1 + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { @{ TemplateFile = $templateFile } } -Times 1 } It 'Should deploy the template with tags' { { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -Tags @{ Test = 'Tag' } -Version 'latest' } | Should -Not -Throw - Assert-MockCalled -CommandName 'Get-ChildItem' -Times 1 - Assert-MockCalled -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + Should -Invoke -CommandName 'Get-ChildItem' -Times 1 + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { @{ TemplateParameterObject = @{ tags = @{ @@ -163,8 +163,8 @@ InModuleScope 'FinOpsToolkit' { It 'Should deploy the template with StorageSku' { $storageSku = 'Premium_ZRS' { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -StorageSku $storageSku -Version 'latest' } | Should -Not -Throw - Assert-MockCalled -CommandName 'Get-ChildItem' -Times 1 - Assert-MockCalled -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + Should -Invoke -CommandName 'Get-ChildItem' -Times 1 + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { @{ TemplateParameterObject = @{ storageSku = $storageSku @@ -176,8 +176,8 @@ InModuleScope 'FinOpsToolkit' { It 'Should deploy the template with RemoteHubStorageUri' { $remoteHubStorageUri = 'https://primaryhub.dfs.core.windows.net/' { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -RemoteHubStorageUri $remoteHubStorageUri -Version 'latest' } | Should -Not -Throw - Assert-MockCalled -CommandName 'Get-ChildItem' -Times 1 - Assert-MockCalled -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + Should -Invoke -CommandName 'Get-ChildItem' -Times 1 + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { $TemplateParameterObject.remoteHubStorageUri -eq $remoteHubStorageUri } -Times 1 } @@ -185,8 +185,8 @@ InModuleScope 'FinOpsToolkit' { It 'Should deploy the template with RemoteHubStorageKey' { $remoteHubStorageKey = 'abc123...xyz789==' { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -RemoteHubStorageKey $remoteHubStorageKey -Version 'latest' } | Should -Not -Throw - Assert-MockCalled -CommandName 'Get-ChildItem' -Times 1 - Assert-MockCalled -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + Should -Invoke -CommandName 'Get-ChildItem' -Times 1 + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { $TemplateParameterObject.remoteHubStorageKey -eq $remoteHubStorageKey } -Times 1 } @@ -195,12 +195,78 @@ InModuleScope 'FinOpsToolkit' { $remoteHubStorageUri = 'https://primaryhub.dfs.core.windows.net/' $remoteHubStorageKey = 'abc123...xyz789==' { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -RemoteHubStorageUri $remoteHubStorageUri -RemoteHubStorageKey $remoteHubStorageKey -Version 'latest' } | Should -Not -Throw - Assert-MockCalled -CommandName 'Get-ChildItem' -Times 1 - Assert-MockCalled -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + Should -Invoke -CommandName 'Get-ChildItem' -Times 1 + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { $TemplateParameterObject.remoteHubStorageUri -eq $remoteHubStorageUri -and $TemplateParameterObject.remoteHubStorageKey -eq $remoteHubStorageKey } -Times 1 } } } + + Context 'Network mode' { + BeforeAll { + Mock -CommandName 'Get-AzResourceGroup' -MockWith { return @{ ResourceGroupName = $rgName } } + Mock -CommandName 'New-AzResourceGroup' + Mock -CommandName 'Save-FinOpsHubTemplate' + Mock -CommandName 'Initialize-FinOpsHubDeployment' + $templateFile = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'FinOps/finops-hub-v1.0.0/main.bicep' + Mock -CommandName 'Get-ChildItem' -MockWith { return @{ FullName = $templateFile } } + Mock -CommandName 'New-AzResourceGroupDeployment' + } + + It 'Should default to public access without a NAT Gateway' { + { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -Version 'latest' } | Should -Not -Throw + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + $TemplateParameterObject.enablePublicAccess -eq $true -and -not $TemplateParameterObject.ContainsKey('enableNatGateway') + } -Times 1 + } + + It "Should map -NetworkMode 'public' to enablePublicAccess and no NAT Gateway" { + { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -NetworkMode 'public' -Version 'latest' } | Should -Not -Throw + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + $TemplateParameterObject.enablePublicAccess -eq $true -and -not $TemplateParameterObject.ContainsKey('enableNatGateway') + } -Times 1 + } + + It "Should map -NetworkMode 'vnet' to private access without a NAT Gateway" { + { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -NetworkMode 'vnet' -Version 'latest' } | Should -Not -Throw + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + $TemplateParameterObject.enablePublicAccess -eq $false -and -not $TemplateParameterObject.ContainsKey('enableNatGateway') + } -Times 1 + } + + It "Should map -NetworkMode 'private' to private access with a NAT Gateway" { + { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -NetworkMode 'private' -Version 'latest' } | Should -Not -Throw + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + $TemplateParameterObject.enablePublicAccess -eq $false -and $TemplateParameterObject.enableNatGateway -eq $true + } -Times 1 + } + + It 'Should map deprecated -DisablePublicAccess to vnet (no NAT Gateway)' { + { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -DisablePublicAccess -Version 'latest' -WarningAction 'SilentlyContinue' } | Should -Not -Throw + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + $TemplateParameterObject.enablePublicAccess -eq $false -and -not $TemplateParameterObject.ContainsKey('enableNatGateway') + } -Times 1 + } + + It 'Should let -NetworkMode win over deprecated -DisablePublicAccess' { + { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -NetworkMode 'private' -DisablePublicAccess -Version 'latest' } | Should -Not -Throw + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + $TemplateParameterObject.enablePublicAccess -eq $false -and $TemplateParameterObject.enableNatGateway -eq $true + } -Times 1 + } + + It 'Should throw when private mode targets a version older than 15.0' { + { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -NetworkMode 'private' -Version '14.0' } | Should -Throw + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -Times 0 + } + + It 'Should pass enableNatGateway when private mode targets version 15.0 or later' { + { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -NetworkMode 'private' -Version '15.0' } | Should -Not -Throw + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + $TemplateParameterObject.enableNatGateway -eq $true + } -Times 1 + } + } } } \ No newline at end of file diff --git a/src/powershell/Tests/Unit/DocsLinks.Tests.ps1 b/src/powershell/Tests/Unit/DocsLinks.Tests.ps1 index 5d92fa414..e25c3ecbd 100644 --- a/src/powershell/Tests/Unit/DocsLinks.Tests.ps1 +++ b/src/powershell/Tests/Unit/DocsLinks.Tests.ps1 @@ -286,30 +286,56 @@ Describe 'Documentation links' { } Context 'docs-mslearn: No learn.microsoft.com URLs' { - - It 'Should not contain https://learn.microsoft.com links: :' -ForEach ($mslearnUrls | Where-Object { $_.Url -match 'learn\.microsoft\.com' }) { - $Url | Should -Not -Match 'learn\.microsoft\.com' -Because "links in docs-mslearn should use root-relative paths (e.g., /azure/...) instead of full URLs since docs are deployed to learn.microsoft.com (${SourceRel}:${LineNumber})" + $learnMicrosoftLinks = @($mslearnUrls | Where-Object { $_.Url -match 'learn\.microsoft\.com' }) + if ($learnMicrosoftLinks.Count -gt 0) { + It 'Should not contain https://learn.microsoft.com links: :' -ForEach $learnMicrosoftLinks { + $Url | Should -Not -Match 'learn\.microsoft\.com' -Because "links in docs-mslearn should use root-relative paths (e.g., /azure/...) instead of full URLs since docs are deployed to learn.microsoft.com (${SourceRel}:${LineNumber})" + } + } + else { + It 'Should not contain https://learn.microsoft.com links' { + $learnMicrosoftLinks | Should -BeNullOrEmpty -Because 'docs-mslearn should not contain fully qualified learn.microsoft.com URLs' + } } } Context 'docs-mslearn: No language locale in MS Learn links' { - - It 'Should not contain language locale in URL: : ' -ForEach ($mslearnUrls | Where-Object { $_.Url -match 'learn\.microsoft\.com/[a-z]{2}-[a-z]{2}/' }) { - $Url | Should -Not -Match 'learn\.microsoft\.com/[a-z]{2}-[a-z]{2}/' -Because "MS Learn links should not include language locale segments like /en-us/ (${SourceRel}:${LineNumber})" + $localizedMsLearnLinks = @($mslearnUrls | Where-Object { $_.Url -match 'learn\.microsoft\.com/[a-z]{2}-[a-z]{2}/' }) + if ($localizedMsLearnLinks.Count -gt 0) { + It 'Should not contain language locale in URL: : ' -ForEach $localizedMsLearnLinks { + $Url | Should -Not -Match 'learn\.microsoft\.com/[a-z]{2}-[a-z]{2}/' -Because "MS Learn links should not include language locale segments like /en-us/ (${SourceRel}:${LineNumber})" + } + } + else { + It 'Should not contain language locale in MS Learn links' { + $localizedMsLearnLinks | Should -BeNullOrEmpty -Because 'MS Learn links should not include language locale segments like /en-us/' + } } } Context 'docs-mslearn: No known broken external URLs' { - - It 'Should not contain known broken external URL: : ' -ForEach $knownBrokenExternalUrlMatches { - $Url | Should -Not -Match $Pattern -Because "known broken external URLs should not appear in docs-mslearn content (${SourceRel}:${LineNumber})" + if ($knownBrokenExternalUrlMatches.Count -gt 0) { + It 'Should not contain known broken external URL: : ' -ForEach $knownBrokenExternalUrlMatches { + $Url | Should -Not -Match $Pattern -Because "known broken external URLs should not appear in docs-mslearn content (${SourceRel}:${LineNumber})" + } + } + else { + It 'Should not contain known broken external URLs' { + $knownBrokenExternalUrlMatches | Should -BeNullOrEmpty -Because 'docs-mslearn should not contain any known broken external URLs' + } } } Context 'docs-mslearn: No incomplete placeholder external URLs' { - - It 'Should not contain incomplete placeholder URL: : ' -ForEach $incompleteExternalUrlMatches { - $Url | Should -Not -Match $Pattern -Because "incomplete placeholder URLs should not appear in docs-mslearn content (${SourceRel}:${LineNumber})" + if ($incompleteExternalUrlMatches.Count -gt 0) { + It 'Should not contain incomplete placeholder URL: : ' -ForEach $incompleteExternalUrlMatches { + $Url | Should -Not -Match $Pattern -Because "incomplete placeholder URLs should not appear in docs-mslearn content (${SourceRel}:${LineNumber})" + } + } + else { + It 'Should not contain incomplete placeholder external URLs' { + $incompleteExternalUrlMatches | Should -BeNullOrEmpty -Because 'docs-mslearn should not contain placeholder external URLs' + } } } diff --git a/src/powershell/Tests/Unit/Get-FinOpsCostExport.Tests.ps1 b/src/powershell/Tests/Unit/Get-FinOpsCostExport.Tests.ps1 index 7a528284b..3abc4a85e 100644 --- a/src/powershell/Tests/Unit/Get-FinOpsCostExport.Tests.ps1 +++ b/src/powershell/Tests/Unit/Get-FinOpsCostExport.Tests.ps1 @@ -135,7 +135,7 @@ InModuleScope 'FinOpsToolkit' { $result | Should -HaveCount 1 # The individual GET endpoint was queried for the export's full run history - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Uri -match 'exports/test-export\?' -and $Uri -match '\$expand=runHistory' } @@ -175,7 +175,7 @@ InModuleScope 'FinOpsToolkit' { Get-FinOpsCostExport -Scope $scope -RunHistory # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Uri -match '\$expand=runHistory' } } @@ -208,8 +208,8 @@ InModuleScope 'FinOpsToolkit' { # Assert # Only the list call should be made (no $expand, no per-export GET) - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -Exactly - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -Exactly + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Uri -notmatch '\$expand=runHistory' } } @@ -257,7 +257,7 @@ InModuleScope 'FinOpsToolkit' { # Assert # The per-export individual GET must be attempted before falling back - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Uri -match 'exports/test-export\?' -and $Uri -match '\$expand=runHistory' } $result | Should -Not -BeNullOrEmpty @@ -310,7 +310,7 @@ InModuleScope 'FinOpsToolkit' { # Assert # The cmdlet must still return data (the thrown error is caught and the # list run history is used). - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Uri -match 'exports/test-export\?' -and $Uri -match '\$expand=runHistory' } $result | Should -Not -BeNullOrEmpty @@ -394,10 +394,10 @@ InModuleScope 'FinOpsToolkit' { # Assert # The throttled GET was retried (2 calls), waited once, and the full # history (2 runs) -- not the truncated list (1 run) -- was returned. - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 2 -Exactly -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 2 -Exactly -ParameterFilter { $Uri -match 'exports/test-export\?' } - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Start-Sleep' -Times 1 -Exactly + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Start-Sleep' -Times 1 -Exactly $result.RunHistory | Should -HaveCount 2 $result.RunHistory[1].RunId | Should -Be 'run2' } diff --git a/src/powershell/Tests/Unit/Get-FinOpsToolkitVersion.Tests.ps1 b/src/powershell/Tests/Unit/Get-FinOpsToolkitVersion.Tests.ps1 index 8482cd598..9c67430ed 100644 --- a/src/powershell/Tests/Unit/Get-FinOpsToolkitVersion.Tests.ps1 +++ b/src/powershell/Tests/Unit/Get-FinOpsToolkitVersion.Tests.ps1 @@ -103,13 +103,13 @@ InModuleScope 'FinOpsToolkit' { It 'Should return 1 result when [Latest] is used' { $result = Get-FinOpsToolkitVersion -Latest $result.Count | Should -Be 1 - Assert-MockCalled -CommandName 'Invoke-WebRequest' -Times 1 + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 1 } It 'Should return all versions if [Latest] not used' { $result = Get-FinOpsToolkitVersion $result.Count | Should -Be 2 - Assert-MockCalled -CommandName 'Invoke-WebRequest' -Times 1 + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 1 } } @@ -142,8 +142,8 @@ InModuleScope 'FinOpsToolkit' { It 'Should throw if the Uri is unreachable' { { Get-FinOpsToolkitVersion } | Should -Throw - Assert-MockCalled -CommandName 'Invoke-WebRequest' - Assert-MockCalled -CommandName 'New-Object' -Times 0 + Should -Invoke -CommandName 'Invoke-WebRequest' + Should -Invoke -CommandName 'New-Object' -Times 0 } } @@ -158,13 +158,13 @@ InModuleScope 'FinOpsToolkit' { It 'Should include prereleases when [Preview] is used' { $result = Get-FinOpsToolkitVersion -Preview $result.Count | Should -Be 2 - Assert-MockCalled -CommandName 'Invoke-WebRequest' -Times 1 + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 1 } It 'Should not include prereleases when [Preview] is not used' { $result = Get-FinOpsToolkitVersion $result.Count | Should -Be 1 - Assert-MockCalled -CommandName 'Invoke-WebRequest' -Times 1 + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 1 } } @@ -180,7 +180,7 @@ InModuleScope 'FinOpsToolkit' { $result = Get-FinOpsToolkitVersion -Latest -Preview $result.Count | Should -Be 1 $result.Version | Should -Be $previewVersion - Assert-MockCalled -CommandName 'Invoke-WebRequest' -Times 1 + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 1 } } } diff --git a/src/powershell/Tests/Unit/Initialize-FinOpsHubDeployment.Tests.ps1 b/src/powershell/Tests/Unit/Initialize-FinOpsHubDeployment.Tests.ps1 index 8bd783743..4ffe82375 100644 --- a/src/powershell/Tests/Unit/Initialize-FinOpsHubDeployment.Tests.ps1 +++ b/src/powershell/Tests/Unit/Initialize-FinOpsHubDeployment.Tests.ps1 @@ -19,7 +19,7 @@ InModuleScope FinOpsToolkit { Initialize-FinOpsHubDeployment # Assert - Assert-MockCalled -CommandName 'Register-FinOpsHubProviders' -Times 1 + Should -Invoke -CommandName 'Register-FinOpsHubProviders' -Times 1 } } } diff --git a/src/powershell/Tests/Unit/New-Directory.Tests.ps1 b/src/powershell/Tests/Unit/New-Directory.Tests.ps1 index 13978560a..0f7f5c3ad 100644 --- a/src/powershell/Tests/Unit/New-Directory.Tests.ps1 +++ b/src/powershell/Tests/Unit/New-Directory.Tests.ps1 @@ -105,8 +105,8 @@ InModuleScope 'FinOpsToolkit' { New-Directory -Path $path # Assert - Assert-MockCalled -CommandName 'Test-Path' - Assert-MockCalled -CommandName 'New-Item' -Times 0 + Should -Invoke -CommandName 'Test-Path' + Should -Invoke -CommandName 'New-Item' -Times 0 } It 'Should create a directory if it does not exist' { @@ -117,8 +117,8 @@ InModuleScope 'FinOpsToolkit' { New-Directory -Path $path # Assert - Assert-MockCalled -CommandName 'Test-Path' - Assert-MockCalled -CommandName 'New-Item' -Times 1 + Should -Invoke -CommandName 'Test-Path' + Should -Invoke -CommandName 'New-Item' -Times 1 } } } diff --git a/src/powershell/Tests/Unit/New-FinOpsCostExport.Tests.ps1 b/src/powershell/Tests/Unit/New-FinOpsCostExport.Tests.ps1 index 5a3c27812..e65fd986f 100644 --- a/src/powershell/Tests/Unit/New-FinOpsCostExport.Tests.ps1 +++ b/src/powershell/Tests/Unit/New-FinOpsCostExport.Tests.ps1 @@ -42,8 +42,8 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams # Assert - Assert-MockCalled -CommandName 'Get-AzResourceProvider' -Times 1 - Assert-MockCalled -CommandName 'Register-AzResourceProvider' -Times 1 + Should -Invoke -CommandName 'Get-AzResourceProvider' -Times 1 + Should -Invoke -CommandName 'Register-AzResourceProvider' -Times 1 } It 'Should create export' { @@ -52,11 +52,11 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams # Assert - Assert-MockCalled -CommandName 'Get-AzResourceProvider' -Times 1 - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' -Times 2 + Should -Invoke -CommandName 'Get-AzResourceProvider' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' -Times 2 # TODO: Validate request body via parameter filter in Invoke-Rest call - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Start-FinOpsCostExport' -Times 0 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Start-FinOpsCostExport' -Times 0 } It 'Should create and run scheduled export' { @@ -65,11 +65,11 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams -Execute # Assert - Assert-MockCalled -CommandName 'Get-AzResourceProvider' -Times 1 - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' -Times 2 + Should -Invoke -CommandName 'Get-AzResourceProvider' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' -Times 2 # TODO: Validate request body via parameter filter in Invoke-Rest call - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Start-FinOpsCostExport' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Start-FinOpsCostExport' -Times 1 } It 'Should create and backfill scheduled export' { @@ -80,11 +80,11 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams -Backfill $backfillMonths # Assert - Assert-MockCalled -CommandName 'Get-AzResourceProvider' -Times 1 - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' -Times 2 + Should -Invoke -CommandName 'Get-AzResourceProvider' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' -Times 2 # TODO: Validate request body via parameter filter in Invoke-Rest call - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Start-FinOpsCostExport' -Times 1 -ParameterFilter { $Backfill -eq $backfillMonths } + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Start-FinOpsCostExport' -Times 1 -ParameterFilter { $Backfill -eq $backfillMonths } } It 'Should create and run one-time export' { @@ -93,11 +93,11 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams -OneTime # Assert - Assert-MockCalled -CommandName 'Get-AzResourceProvider' -Times 1 - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' -Times 2 + Should -Invoke -CommandName 'Get-AzResourceProvider' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' -Times 2 # TODO: Validate request body via parameter filter in Invoke-Rest call - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Start-FinOpsCostExport' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Start-FinOpsCostExport' -Times 1 } } @@ -108,7 +108,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Uri -match 'api-version=2025-03-01' } } @@ -119,7 +119,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.properties.definition.type -eq 'FocusCost' } } @@ -130,7 +130,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $null -eq $Body.identity } } @@ -141,7 +141,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.properties.schedule.status -eq 'Active' } } @@ -161,7 +161,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams -Dataset $_.dataset # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.properties.definition.type -eq $_.dataset ` -and $Body.properties.schedule.recurrence -eq $_.schedule } @@ -173,7 +173,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams -Dataset $_.dataset # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.properties.definition.type -eq $_.dataset ` -and $Body.properties.definition.dataSet.configuration.dataVersion -eq $_.version } @@ -188,7 +188,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams -SystemAssignedIdentity # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.identity.type -eq 'SystemAssigned' ` -and $Body.location -eq 'global' } @@ -202,7 +202,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams -SystemAssignedIdentity -Location $location # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.identity.type -eq 'SystemAssigned' ` -and $Body.location -eq $location } @@ -216,7 +216,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.properties.format -eq 'Csv' } } @@ -227,7 +227,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams -Format 'Parquet' # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.properties.format -eq 'Parquet' } } @@ -238,7 +238,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.properties.compressionMode -eq 'None' } } @@ -249,7 +249,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @newExportParams -CompressionMode 'Snappy' # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.properties.compressionMode -eq 'Snappy' } } @@ -269,7 +269,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @paramsWithColons # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.properties.deliveryInfo.destination.rootFolderPath -eq (($scopeWithColons -replace ':','-').Trim('/')) } } @@ -289,7 +289,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @paramsWithExplicitPath # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.properties.deliveryInfo.destination.rootFolderPath -eq $explicitPathWithColons } } @@ -307,7 +307,7 @@ InModuleScope 'FinOpsToolkit' { New-FinOpsCostExport @paramsWithoutColons # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $Body.properties.deliveryInfo.destination.rootFolderPath -eq $scopeWithoutColons.Trim('/') } } diff --git a/src/powershell/Tests/Unit/Register-FinOpsHubProviders.Tests.ps1 b/src/powershell/Tests/Unit/Register-FinOpsHubProviders.Tests.ps1 index 7d5ef11de..80de71465 100644 --- a/src/powershell/Tests/Unit/Register-FinOpsHubProviders.Tests.ps1 +++ b/src/powershell/Tests/Unit/Register-FinOpsHubProviders.Tests.ps1 @@ -21,8 +21,8 @@ InModuleScope 'FinOpsToolkit' { # Assert $requiredRPs | ForEach-Object { - Assert-MockCalled -CommandName 'Get-AzResourceProvider' -Times 1 -ParameterFilter { $ProviderNamespace -eq $_ } - Assert-MockCalled -CommandName 'Register-AzResourceProvider' -Times 1 -ParameterFilter { $ProviderNamespace -eq $_ -and $WhatIf -eq $true } + Should -Invoke -CommandName 'Get-AzResourceProvider' -Times 1 -ParameterFilter { $ProviderNamespace -eq $_ } + Should -Invoke -CommandName 'Register-AzResourceProvider' -Times 1 -ParameterFilter { $ProviderNamespace -eq $_ -and $WhatIf -eq $true } } } } @@ -40,8 +40,8 @@ InModuleScope 'FinOpsToolkit' { Register-FinOpsHubProviders # Assert - Assert-MockCalled -CommandName 'Get-AzResourceProvider' -Times $requiredRPs.Count - Assert-MockCalled -CommandName 'Register-AzResourceProvider' -Times ($state -eq 'Registered' ? 0 : $requiredRPs.Count) + Should -Invoke -CommandName 'Get-AzResourceProvider' -Times $requiredRPs.Count + Should -Invoke -CommandName 'Register-AzResourceProvider' -Times ($state -eq 'Registered' ? 0 : $requiredRPs.Count) } } } diff --git a/src/powershell/Tests/Unit/Save-FinOpsHubTemplate.Tests.ps1 b/src/powershell/Tests/Unit/Save-FinOpsHubTemplate.Tests.ps1 index 7bfc93ec7..98a31dec0 100644 --- a/src/powershell/Tests/Unit/Save-FinOpsHubTemplate.Tests.ps1 +++ b/src/powershell/Tests/Unit/Save-FinOpsHubTemplate.Tests.ps1 @@ -39,8 +39,8 @@ InModuleScope FinOpsToolkit { Save-FinOpsHubTemplate # Assert - Assert-MockCalled -CommandName 'Get-FinOpsToolkitVersion' -Times 1 - Assert-MockCalled -CommandName 'Invoke-WebRequest' -Times 0 + Should -Invoke -CommandName 'Get-FinOpsToolkitVersion' -Times 1 + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 0 } } @@ -59,8 +59,8 @@ InModuleScope FinOpsToolkit { Save-FinOpsHubTemplate # Assert - Assert-MockCalled -CommandName 'Get-FinOpsToolkitVersion' -Times 1 - Assert-MockCalled -CommandName 'Invoke-WebRequest' -Times 1 + Should -Invoke -CommandName 'Get-FinOpsToolkitVersion' -Times 1 + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 1 } } @@ -75,9 +75,9 @@ InModuleScope FinOpsToolkit { Save-FinOpsHubTemplate -Version $downloadVersion # Assert - Assert-MockCalled -CommandName 'Invoke-WebRequest' -Times 1 -ParameterFilter { $Uri -eq $downloadUrl } - Assert-MockCalled -CommandName 'Expand-Archive' -Times 1 - Assert-MockCalled -CommandName 'Remove-Item' -Times 1 + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 1 -ParameterFilter { $Uri -eq $downloadUrl } + Should -Invoke -CommandName 'Expand-Archive' -Times 1 + Should -Invoke -CommandName 'Remove-Item' -Times 1 } } @@ -88,7 +88,7 @@ InModuleScope FinOpsToolkit { It 'Should throw' { { Save-FinOpsHubTemplate } | Should -Throw - Assert-MockCalled -CommandName 'Invoke-WebRequest' -Times 0 + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 0 } } @@ -102,7 +102,7 @@ InModuleScope FinOpsToolkit { Save-FinOpsHubTemplate -Version '0.2' # Assert - Assert-MockCalled -CommandName 'Test-Path' -Times 1 -ParameterFilter { $Path.EndsWith('0.3.zip') } + Should -Invoke -CommandName 'Test-Path' -Times 1 -ParameterFilter { $Path.EndsWith('0.3.zip') } } It 'Should not redirect 0.2 to 0.1.1 for Azure Gov' { @@ -114,7 +114,7 @@ InModuleScope FinOpsToolkit { Save-FinOpsHubTemplate -Version '0.3' # Assert - Assert-MockCalled -CommandName 'Test-Path' -Times 1 -ParameterFilter { $Path.EndsWith('0.3.zip') } + Should -Invoke -CommandName 'Test-Path' -Times 1 -ParameterFilter { $Path.EndsWith('0.3.zip') } } It 'Should not redirect 0.2 to 0.1.1 for Azure China' { @@ -126,7 +126,7 @@ InModuleScope FinOpsToolkit { Save-FinOpsHubTemplate -Version '0.3' # Assert - Assert-MockCalled -CommandName 'Test-Path' -Times 1 -ParameterFilter { $Path.EndsWith('0.3.zip') } + Should -Invoke -CommandName 'Test-Path' -Times 1 -ParameterFilter { $Path.EndsWith('0.3.zip') } } It 'Should support 0.3 for Azure Gov' { @@ -138,7 +138,7 @@ InModuleScope FinOpsToolkit { Save-FinOpsHubTemplate -Version '0.3' # Assert - Assert-MockCalled -CommandName 'Test-Path' -Times 1 -ParameterFilter { $Path.EndsWith('0.3.zip') } + Should -Invoke -CommandName 'Test-Path' -Times 1 -ParameterFilter { $Path.EndsWith('0.3.zip') } } It 'Should support 0.3 for Azure China' { @@ -150,7 +150,7 @@ InModuleScope FinOpsToolkit { Save-FinOpsHubTemplate -Version '0.3' # Assert - Assert-MockCalled -CommandName 'Test-Path' -Times 1 -ParameterFilter { $Path.EndsWith('0.3.zip') } + Should -Invoke -CommandName 'Test-Path' -Times 1 -ParameterFilter { $Path.EndsWith('0.3.zip') } } } } diff --git a/src/powershell/Tests/Unit/Start-FinOpsCostExport.Tests.ps1 b/src/powershell/Tests/Unit/Start-FinOpsCostExport.Tests.ps1 index fa07da945..b9d55edbd 100644 --- a/src/powershell/Tests/Unit/Start-FinOpsCostExport.Tests.ps1 +++ b/src/powershell/Tests/Unit/Start-FinOpsCostExport.Tests.ps1 @@ -31,7 +31,7 @@ Describe 'Start-FinOpsCostExport' { { Start-FinOpsCostExport @params } | Should -Throw # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' -Times 1 } It 'Should call /run with default dates' { @@ -47,7 +47,7 @@ Describe 'Start-FinOpsCostExport' { $success = Start-FinOpsCostExport @params # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 ` + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 ` -ParameterFilter { return $body -eq $null } $success | Should -Be $true } @@ -66,7 +66,7 @@ Describe 'Start-FinOpsCostExport' { $success = Start-FinOpsCostExport @params # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 ` + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 ` -ParameterFilter { $body.timePeriod.from -eq $params.StartDate.ToUniversalTime().ToString("yyyy-01-01'T'00:00:00'Z'") -and $body.timePeriod.to -eq $params.StartDate.ToUniversalTime().ToString("yyyy-01-31'T'00:00:00'Z'") } $success | Should -Be $true } @@ -86,7 +86,7 @@ Describe 'Start-FinOpsCostExport' { $success = Start-FinOpsCostExport @params # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 ` + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 ` -ParameterFilter { $body.timePeriod.from -eq $params.StartDate.ToUniversalTime().Date.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") -and $body.timePeriod.to -eq $params.EndDate.ToUniversalTime().Date.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") } $success | Should -Be $true } @@ -107,15 +107,15 @@ Describe 'Start-FinOpsCostExport' { $success = Start-FinOpsCostExport @params # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times ($params.Backfill + 1) - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times ($params.Backfill + 1) + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $startDate = $startOfMonth.ToUniversalTime().Date $body.timePeriod.from -eq $startDate.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") ` -and $body.timePeriod.to -eq $today.AddDays(-1).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") } foreach ($i in 1..($params.Backfill)) { - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { $startDate = $startOfMonth.AddMonths($i * -1).ToUniversalTime().Date $body.timePeriod.from -eq $startDate.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") ` -and $body.timePeriod.to -eq $startDate.AddMonths(1).AddMilliseconds(-1).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") @@ -137,7 +137,7 @@ Describe 'Start-FinOpsCostExport' { -Backfill 3 # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Write-Progress' -Times 4 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Write-Progress' -Times 4 $success | Should -Be $true } @@ -166,7 +166,7 @@ Describe 'Start-FinOpsCostExport' { $success = Start-FinOpsCostExport @params # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 ` + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 ` -ParameterFilter { $body.timePeriod.from -eq $firstDayOfCurrentMonth.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") -and $body.timePeriod.to -eq $today.AddDays(-1).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") @@ -213,8 +213,8 @@ Describe 'Start-FinOpsCostExport' { $success = Start-FinOpsCostExport @params # Assert - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 2 - Assert-MockCalled -ModuleName FinOpsToolkit -CommandName 'Start-Sleep' -Times 1 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 2 + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Start-Sleep' -Times 1 $success | Should -Be $true } } diff --git a/src/scripts/Deploy-Hub.ps1 b/src/scripts/Deploy-Hub.ps1 index b48779470..3cc008d9a 100644 --- a/src/scripts/Deploy-Hub.ps1 +++ b/src/scripts/Deploy-Hub.ps1 @@ -89,7 +89,10 @@ Optional. Use managed exports instead of manual exports. Requires -Scope. Grants the hub managed identity the required roles on the scope and passes scopesToMonitor to the template. .PARAMETER Private - Optional. Deploy with private networking (VNet and private endpoints). Default: false. + Optional. Deploy with private networking (VNet and private endpoints, default outbound access). Equivalent to -NetworkMode vnet. Kept for back-compat. Default: false. + + .PARAMETER NetworkMode + Optional. Network mode: 'public' (default), 'vnet' (private endpoints, default outbound), or 'private' (private endpoints + NAT Gateway, subnets locked down — required when the 'Subnets should be private' policy is enforced). .PARAMETER VirtualNetworkAddressPrefix Optional. Virtual network address prefix for private networking. Requires a /26 CIDR block. When set, also sets -Private. Default: "10.20.30.0/26". @@ -117,6 +120,8 @@ param( [string]$Scope, [switch]$ManagedExports, [switch]$Private, + [ValidateSet('public', 'vnet', 'private')] + [string]$NetworkMode, [string]$VirtualNetworkAddressPrefix, [string]$Location, [switch]$Build, @@ -283,16 +288,17 @@ else $params.enableManagedExports = $false } -# Private networking (infer from VNet prefix if provided) -if ($VirtualNetworkAddressPrefix) { $Private = $true } -if ($Private) +# Network mode — -NetworkMode wins; -Private (back-compat) and -VirtualNetworkAddressPrefix imply 'vnet'. +if (-not $NetworkMode -and ($Private -or $VirtualNetworkAddressPrefix)) { $NetworkMode = 'vnet' } +if ($NetworkMode -and $NetworkMode -ne 'public') { $params.enablePublicAccess = $false + $params.enableNatGateway = ($NetworkMode -eq 'private') if ($VirtualNetworkAddressPrefix) { $params.virtualNetworkAddressPrefix = $VirtualNetworkAddressPrefix } - Write-Host " Private networking: enabled (VNet $($VirtualNetworkAddressPrefix ?? '10.20.30.0/26'))" + Write-Host " Network mode: $NetworkMode (VNet $($VirtualNetworkAddressPrefix ?? '10.20.30.0/26'))" } # Resource group diff --git a/src/templates/finops-hub/createUiDefinition.json b/src/templates/finops-hub/createUiDefinition.json index 726974856..d7ea79097 100644 --- a/src/templates/finops-hub/createUiDefinition.json +++ b/src/templates/finops-hub/createUiDefinition.json @@ -950,6 +950,14 @@ ] }, "visible": "[equals(steps('advanced').networking.enablePublicAccess, false)]" + }, + { + "name": "enableNatGateway", + "type": "Microsoft.Common.CheckBox", + "label": "Deploy NAT Gateway and disable default outbound access", + "toolTip": "Required by the September 2025 'Subnets should be private' policy. Adds a Standard NAT Gateway and Standard static Public IP attached to the script and Data Explorer subnets to provide controlled outbound internet access. Leave unchecked if you have your own egress path (UDR to firewall, peered hub with NAT, etc.).", + "defaultValue": false, + "visible": "[equals(steps('advanced').networking.enablePublicAccess, false)]" } ], "visible": true @@ -973,6 +981,8 @@ "Microsoft.Network/privateDnsZones", "Microsoft.Network/privateDnsZones/virtualNetworkLinks", "Microsoft.Network/privateEndpoints", + "Microsoft.Network/publicIPAddresses", + "Microsoft.Network/natGateways", "Microsoft.Resources/deploymentScripts", "Microsoft.Storage/storageAccounts" ] @@ -993,6 +1003,7 @@ "enableAHBRecommendations": "[steps('recommendations').optional.enableAHBRecommendations]", "enableSpotRecommendations": "[steps('recommendations').optional.enableSpotRecommendations]", "enablePublicAccess": "[steps('advanced').networking.enablePublicAccess]", + "enableNatGateway": "[steps('advanced').networking.enableNatGateway]", "virtualNetworkAddressPrefix": "[steps('advanced').networking.virtualNetworkAddressPrefix]", "dataExplorerSku": "[steps('pricing').dataExplorer.dataExplorerSku]", "exportRetentionInDays": "[steps('retention').storage.msexportsDays]", diff --git a/src/templates/finops-hub/main.bicep b/src/templates/finops-hub/main.bicep index 4e058fcba..687c26174 100644 --- a/src/templates/finops-hub/main.bicep +++ b/src/templates/finops-hub/main.bicep @@ -159,6 +159,9 @@ param dataExplorerFinalRetentionInMonths int = 13 @description('Optional. Enable public access to FinOps hubs resources. Default: true.') param enablePublicAccess bool = true +@description('Optional. Deploy a NAT Gateway for controlled outbound access when private routing is enabled. When true, subnets disable Azure default outbound access and route through the NAT Gateway. Ignored when enablePublicAccess is true. Default: false.') +param enableNatGateway bool = false + @description('Optional. Address space for the workload. Minimum /26 subnet size is required for the workload. Default: "10.20.30.0/26".') param virtualNetworkAddressPrefix string = '10.20.30.0/26' @@ -195,6 +198,7 @@ module hub 'modules/hub.bicep' = { remoteHubStorageUri: remoteHubStorageUri remoteHubStorageKey: remoteHubStorageKey enablePublicAccess: enablePublicAccess + enableNatGateway: enableNatGateway virtualNetworkAddressPrefix: virtualNetworkAddressPrefix } } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/infrastructure.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/infrastructure.bicep index 0cf23a808..85b5e9591 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/infrastructure.bicep +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/infrastructure.bicep @@ -17,6 +17,8 @@ param hub HubProperties //============================================================================== var nsgName = '${hub.routing.networkName}-nsg' +var natGatewayName = '${hub.routing.networkName}-natgw' +var natGatewayPipName = '${hub.routing.networkName}-natgw-pip' // Workaround https://github.com/Azure/bicep/issues/1853 var finopsHubSubnetName = 'private-endpoint-subnet' @@ -28,6 +30,7 @@ var subnets = !hub.options.privateRouting ? [] : [ name: finopsHubSubnetName properties: { addressPrefix: cidrSubnet(hub.options.networkAddressPrefix, 28, 0) + defaultOutboundAccess: !hub.options.natGateway networkSecurityGroup: { id: nsg.id } @@ -42,6 +45,12 @@ var subnets = !hub.options.privateRouting ? [] : [ name: scriptSubnetName properties: { addressPrefix: cidrSubnet(hub.options.networkAddressPrefix, 28, 1) + defaultOutboundAccess: !hub.options.natGateway + ...(hub.options.natGateway ? { + natGateway: { + id: resourceId('Microsoft.Network/natGateways', natGatewayName) + } + } : {}) networkSecurityGroup: { id: nsg.id } @@ -64,6 +73,12 @@ var subnets = !hub.options.privateRouting ? [] : [ name: dataExplorerSubnetName properties: { addressPrefix: cidrSubnet(hub.options.networkAddressPrefix, 27, 1) + defaultOutboundAccess: !hub.options.natGateway + ...(hub.options.natGateway ? { + natGateway: { + id: resourceId('Microsoft.Network/natGateways', natGatewayName) + } + } : {}) networkSecurityGroup: { id: nsg.id } @@ -171,7 +186,10 @@ resource nsg 'Microsoft.Network/networkSecurityGroups@2023-11-01' = if (hub.opti resource vNet 'Microsoft.Network/virtualNetworks@2023-11-01' = if (hub.options.privateRouting) { name: hub.routing.networkName location: hub.location - tags: getHubTags(hub, 'Microsoft.Storage/virtualNetworks') + tags: getHubTags(hub, 'Microsoft.Network/virtualNetworks') + dependsOn: hub.options.natGateway ? [ + natGateway + ] : [] properties: { addressSpace: { addressPrefixes: [hub.options.networkAddressPrefix] @@ -192,6 +210,42 @@ resource vNet 'Microsoft.Network/virtualNetworks@2023-11-01' = if (hub.options.p } } +//------------------------------------------------------------------------------ +// NAT Gateway (provides outbound for script-subnet + dataExplorer-subnet when +// defaultOutboundAccess is disabled; required by the 'Subnets should be private' +// policy and the September 2025 implicit-outbound retirement) +//------------------------------------------------------------------------------ + +resource natGatewayPublicIp 'Microsoft.Network/publicIPAddresses@2023-11-01' = if (hub.options.natGateway) { + name: natGatewayPipName + location: hub.location + tags: getHubTags(hub, 'Microsoft.Network/publicIPAddresses') + sku: { + name: 'Standard' + } + properties: { + publicIPAllocationMethod: 'Static' + publicIPAddressVersion: 'IPv4' + } +} + +resource natGateway 'Microsoft.Network/natGateways@2023-11-01' = if (hub.options.natGateway) { + name: natGatewayName + location: hub.location + tags: getHubTags(hub, 'Microsoft.Network/natGateways') + sku: { + name: 'Standard' + } + properties: { + idleTimeoutInMinutes: 4 + publicIpAddresses: [ + { + id: natGatewayPublicIp.id + } + ] + } +} + //------------------------------------------------------------------------------ // Storage DNS zones //------------------------------------------------------------------------------ diff --git a/src/templates/finops-hub/modules/fx/hub-types.bicep b/src/templates/finops-hub/modules/fx/hub-types.bicep index 2cb8e050e..d9b197e75 100644 --- a/src/templates/finops-hub/modules/fx/hub-types.bicep +++ b/src/templates/finops-hub/modules/fx/hub-types.bicep @@ -72,6 +72,7 @@ type HubRoutingProperties = { keyVaultSku: 'KeyVault SKU. Allowed values: "standard", "premium".' keyVaultEnablePurgeProtection: 'Indicates whether purge protection is enabled for the Key Vault. When enabled, deleted Key Vault and its secrets cannot be permanently deleted until the retention period expires, which is required for compliance in some environments.' networkAddressPrefix: 'Address prefix for the FinOps hub isolated virtual network, if private network routing is enabled.' + natGateway: 'Indicates whether a NAT Gateway should be deployed for controlled outbound internet access. When enabled, subnets disable Azure default outbound access and route through the NAT Gateway.' privateRouting: 'Indicates whether private network routing is enabled.' publisherIsolation: 'Indicates whether FinOps hub resources should be separated by publisher for advanced security.' storageInfrastructureEncryption: 'Indicates whether infrastructure encryption is enabled for the storage account.' @@ -96,6 +97,7 @@ type HubProperties = { keyVaultSku: string keyVaultEnablePurgeProtection: bool networkAddressPrefix: string + natGateway: bool privateRouting: bool publisherIsolation: bool storageInfrastructureEncryption: bool @@ -192,6 +194,7 @@ func newHubInternal( keyVaultEnablePurgeProtection bool, enableInfrastructureEncryption bool, enablePublicAccess bool, + enableNatGateway bool, networkName string, networkAddressPrefix string, isTelemetryEnabled bool, @@ -211,6 +214,7 @@ func newHubInternal( keyVaultSku: keyVaultSku keyVaultEnablePurgeProtection: keyVaultEnablePurgeProtection networkAddressPrefix: networkAddressPrefix + natGateway: !enablePublicAccess && enableNatGateway privateRouting: !enablePublicAccess publisherIsolation: false // TODO: Expose publisher isolation option storageInfrastructureEncryption: enableInfrastructureEncryption @@ -251,6 +255,7 @@ func newHub( keyVaultEnablePurgeProtection bool, enableInfrastructureEncryption bool, enablePublicAccess bool, + enableNatGateway bool, networkAddressPrefix string, isTelemetryEnabled bool, ) HubProperties => newHubInternal( @@ -265,6 +270,7 @@ func newHub( keyVaultEnablePurgeProtection, enableInfrastructureEncryption, enablePublicAccess, + enableNatGateway, '${safeStorageName(name)}-vnet-${location}', // networkName, cSpell:ignore vnet networkAddressPrefix, isTelemetryEnabled ?? true diff --git a/src/templates/finops-hub/modules/hub.bicep b/src/templates/finops-hub/modules/hub.bicep index 2b5603372..0c9e6b9e5 100644 --- a/src/templates/finops-hub/modules/hub.bicep +++ b/src/templates/finops-hub/modules/hub.bicep @@ -170,6 +170,9 @@ param dataExplorerFinalRetentionInMonths int = 13 @description('Optional. Enable public access to the data lake. Default: true.') param enablePublicAccess bool = true +@description('Optional. Deploy a NAT Gateway for controlled outbound access when private routing is enabled. When true, subnets disable Azure default outbound access and route through the NAT Gateway. Ignored when enablePublicAccess is true. Default: false.') +param enableNatGateway bool = false + @description('Optional. Address space for the workload. Minimum /26 subnet size is required for the workload. Default: "10.20.30.0/26".') param virtualNetworkAddressPrefix string = '10.20.30.0/26' @@ -194,6 +197,7 @@ var hub = newHub( enablePurgeProtection, enableInfrastructureEncryption, enablePublicAccess, + enableNatGateway, virtualNetworkAddressPrefix, enableDefaultTelemetry )