From 46b4e36a0de7495d6f3fff6668ae420769452a99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 07:52:41 +0000 Subject: [PATCH 1/2] Initial plan From dddb8eacc1b987b0194a378d275aecd3cb4df2a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 07:56:55 +0000 Subject: [PATCH 2/2] Fix review comments: add Get-NFSDatastoreNConnectValue with Write-Host fix, export in psd1, add tests with AVSAttribute and happy path coverage --- Microsoft.AVS.NFS/Microsoft.AVS.NFS.psd1 | 3 +- Microsoft.AVS.NFS/Microsoft.AVS.NFS.psm1 | 138 +++++++++ tests/Microsoft.AVS.NFS.Tests.ps1 | 340 +++++++++++++++++++++++ 3 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 tests/Microsoft.AVS.NFS.Tests.ps1 diff --git a/Microsoft.AVS.NFS/Microsoft.AVS.NFS.psd1 b/Microsoft.AVS.NFS/Microsoft.AVS.NFS.psd1 index 65c57e01..2c6be7d6 100644 --- a/Microsoft.AVS.NFS/Microsoft.AVS.NFS.psd1 +++ b/Microsoft.AVS.NFS/Microsoft.AVS.NFS.psd1 @@ -69,7 +69,8 @@ # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. FunctionsToExport = @( "New-NFSDatastore", - "Remove-NFSDatastore" + "Remove-NFSDatastore", + "Get-NFSDatastoreNConnectValue" ) # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. diff --git a/Microsoft.AVS.NFS/Microsoft.AVS.NFS.psm1 b/Microsoft.AVS.NFS/Microsoft.AVS.NFS.psm1 index e67fc6c3..9180b4d0 100644 --- a/Microsoft.AVS.NFS/Microsoft.AVS.NFS.psm1 +++ b/Microsoft.AVS.NFS/Microsoft.AVS.NFS.psm1 @@ -158,3 +158,141 @@ function Remove-NFSDatastore { Write-Host "Datastore $($DatastoreName) unmounted successfully on $HostUnmountedCount/$HostCount hosts in cluster $($ClusterName)." } +<# + .SYNOPSIS + Gets NFS NConnect value for a specific datastore across all hosts in a cluster. + + .DESCRIPTION + Retrieves the NConnect (number of TCP connections) configuration for the specified NFS datastore + mounted on each host in the cluster. + + .PARAMETER ClusterName + Name of the vSphere cluster to query for NFS NConnect information. + + .PARAMETER DatastoreName + Name of the NFS datastore to retrieve NConnect value for. + + .EXAMPLE + Get-NFSDatastoreNConnectValue -ClusterName "Cluster1" -DatastoreName "nfs-datastore-01" + + .INPUTS + vCenter cluster name, NFS datastore name. + + .OUTPUTS + NamedOutputs hashtable containing NConnect details per host. +#> +function Get-NFSDatastoreNConnectValue { + [CmdletBinding()] + [AVSAttribute(10, UpdatesSDDC = $false)] + Param ( + [Parameter( + Mandatory = $true, + HelpMessage = 'vSphere Cluster name in vCenter')] + [ValidateNotNullOrEmpty()] + [String] + $ClusterName, + + [Parameter( + Mandatory = $true, + HelpMessage = 'Name of NFS datastore to get NConnect value for')] + [ValidateNotNullOrEmpty()] + [String] + $DatastoreName + ) + + $ClusterName = Limit-WildcardsandCodeInjectionCharacters -String $ClusterName + $DatastoreName = Limit-WildcardsandCodeInjectionCharacters -String $DatastoreName + + Write-Host "Collecting NConnect value for NFS datastore '$DatastoreName' across hosts in cluster '$ClusterName'" + Write-Host "" + + $Cluster = Get-Cluster -Name $ClusterName -ErrorAction Ignore + if (-not $Cluster) { + throw "Cluster '$ClusterName' does not exist." + } + + $Datastore = $Cluster | Get-Datastore -Name $DatastoreName -ErrorAction Ignore + if (-not $Datastore) { + throw "Datastore '$DatastoreName' not found on cluster '$ClusterName'." + } + + # NFS datastores have Type 'NFS' (v3) or 'NFS41' (v4.1) + if ($Datastore.Type -notin @('NFS', 'NFS41')) { + throw "Datastore '$DatastoreName' is of type '$($Datastore.Type)'. This cmdlet only supports NFS datastores (NFS or NFS41)." + } + + # Get only connected hosts (filter out disconnected/maintenance mode hosts) + $AllVMHosts = $Cluster | Get-VMHost -ErrorAction Ignore + if (-not $AllVMHosts) { + throw "No hosts found in cluster '$ClusterName'." + } + + $VMHosts = $AllVMHosts | Where-Object { $_.ConnectionState -eq 'Connected' } + if (-not $VMHosts) { + throw "No connected hosts found in cluster '$ClusterName'. All hosts are disconnected or in maintenance mode." + } + + $DisconnectedHosts = $AllVMHosts | Where-Object { $_.ConnectionState -ne 'Connected' } + if ($DisconnectedHosts) { + Write-Warning "Skipped $($DisconnectedHosts.Count) host(s) due to disconnected or maintenance state: $($DisconnectedHosts.Name -join ', ')" + } + + $NamedOutputs = @{} + $HostsNotMounted = @() + $HostsFailed = @() + + foreach ($VMHost in $VMHosts) { + try { + $EsxCli = Get-EsxCli -VMHost $VMHost -V2 -ErrorAction Stop + $NfsDatastores = $EsxCli.storage.nfs.list.invoke() + + if (-not $NfsDatastores) { + $HostsNotMounted += $VMHost.Name + continue + } + + $NfsDs = $NfsDatastores | Where-Object { $_.VolumeName -eq $DatastoreName } + if (-not $NfsDs) { + $HostsNotMounted += $VMHost.Name + continue + } + + $IsNfsV41 = $NfsDs.NFSv41 -eq $true + $NConnectValue = if ($null -ne $NfsDs.Connections) { $NfsDs.Connections } else { "N/A" } + + $NamedOutputs[$VMHost.Name] = " + { + DatastoreName : $($NfsDs.VolumeName), + NfsServerHost : $($NfsDs.Host), + SharePath : $($NfsDs.Share), + NfsVersion : $(if ($IsNfsV41) { '4.1' } else { '3' }), + NConnectValue : $NConnectValue, + Accessible : $($NfsDs.Accessible), + Mounted : $($NfsDs.Mounted) + }" + } + catch { + $HostsFailed += $VMHost.Name + Write-Error "Failed to query host '$($VMHost.Name)': $($_.Exception.Message)" + continue + } + } + + if ($HostsNotMounted.Count -gt 0) { + Write-Warning "Datastore '$DatastoreName' not mounted on $($HostsNotMounted.Count) host(s): $($HostsNotMounted -join ', ')" + } + + if ($HostsFailed.Count -gt 0) { + Write-Warning "Failed to query $($HostsFailed.Count) host(s): $($HostsFailed -join ', ')" + } + + if ($NamedOutputs.Count -eq 0) { + throw "Failed to query all hosts in cluster '$ClusterName'. Check hosts connectivity." + } + + Write-Host ($NamedOutputs | ConvertTo-Json -Depth 10) + + Set-Variable -Name NamedOutputs -Value $NamedOutputs -Scope Global + Write-Host " " +} + diff --git a/tests/Microsoft.AVS.NFS.Tests.ps1 b/tests/Microsoft.AVS.NFS.Tests.ps1 new file mode 100644 index 00000000..b8898012 --- /dev/null +++ b/tests/Microsoft.AVS.NFS.Tests.ps1 @@ -0,0 +1,340 @@ +BeforeAll { + # Define the AVSAttribute class that NFS module functions use + # This is a minimal definition matching what's in Microsoft.AVS.Management/Classes.ps1 + if (-not ('AVSAttribute' -as [type])) { + class AVSAttribute : Attribute { + [bool]$UpdatesSDDC = $false + [TimeSpan]$Timeout + [bool]$AutomationOnly = $false + AVSAttribute([int]$timeoutMinutes) { $this.Timeout = New-TimeSpan -Minutes $timeoutMinutes } + } + } + + # Define stub functions for VMware cmdlets so Pester can mock them + # These are only created when the real cmdlets are not available (e.g. no PowerCLI installed) + $vmwareCmdlets = @( + 'Get-Cluster', 'Get-VMHost', 'Get-Datastore', 'Get-EsxCli', + 'Get-VM', 'Remove-Datastore', 'New-Datastore' + ) + foreach ($cmdlet in $vmwareCmdlets) { + if (-not (Get-Command $cmdlet -ErrorAction SilentlyContinue)) { + Set-Item -Path "function:global:$cmdlet" -Value { param() $null } + } + } + + # Override Get-VMHost with a stub that accepts common parameters + function global:Get-VMHost { + param($Name, $Datastore, $State, $Id, + [Parameter(ValueFromPipeline=$true)]$InputObject) + process { $null } + } + + # Override Get-Datastore with a stub that accepts pipeline input and Name parameter + function global:Get-Datastore { + param($Name, + [Parameter(ValueFromPipeline=$true)]$InputObject) + process { $null } + } + + # Override Get-EsxCli with a permissive stub so PSCustomObject mock hosts pass parameter binding + function global:Get-EsxCli { + param($VMHost, [switch]$V2, $Server) + $null + } + + # Stub for Limit-WildcardsandCodeInjectionCharacters from Microsoft.AVS.Management + if (-not (Get-Command 'Limit-WildcardsandCodeInjectionCharacters' -ErrorAction SilentlyContinue)) { + function global:Limit-WildcardsandCodeInjectionCharacters { + param([string]$String) + return $String + } + } + + # Import the NFS module (use .psm1 directly to avoid RequiredModules dependency on VMware modules) + $modulePath = Join-Path (Join-Path (Join-Path $PSScriptRoot "..") "Microsoft.AVS.NFS") "Microsoft.AVS.NFS.psm1" + Import-Module $modulePath -Force +} + +AfterAll { + # Clean up + Get-Module Microsoft.AVS.NFS -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe "Microsoft.AVS.NFS Module" { + Context "Module Loading" { + It "Should import the module successfully" { + $module = Get-Module Microsoft.AVS.NFS + $module | Should -Not -BeNullOrEmpty + } + + It "Should export expected functions" { + $module = Get-Module Microsoft.AVS.NFS + $module.ExportedFunctions.Keys | Should -Contain 'New-NFSDatastore' + $module.ExportedFunctions.Keys | Should -Contain 'Remove-NFSDatastore' + $module.ExportedFunctions.Keys | Should -Contain 'Get-NFSDatastoreNConnectValue' + } + } +} + +Describe "Get-NFSDatastoreNConnectValue" { + Context "Parameter Validation" { + It "Should have ClusterName as mandatory parameter" { + $command = Get-Command Get-NFSDatastoreNConnectValue + $param = $command.Parameters['ClusterName'] + $param.Attributes.Mandatory | Should -Contain $true + } + + It "Should have DatastoreName as mandatory parameter" { + $command = Get-Command Get-NFSDatastoreNConnectValue + $param = $command.Parameters['DatastoreName'] + $param.Attributes.Mandatory | Should -Contain $true + } + + It "Should have ClusterName as String type" { + $command = Get-Command Get-NFSDatastoreNConnectValue + $param = $command.Parameters['ClusterName'] + $param.ParameterType.Name | Should -Be 'String' + } + + It "Should have DatastoreName as String type" { + $command = Get-Command Get-NFSDatastoreNConnectValue + $param = $command.Parameters['DatastoreName'] + $param.ParameterType.Name | Should -Be 'String' + } + + It "Should have ValidateNotNullOrEmpty on ClusterName" { + $command = Get-Command Get-NFSDatastoreNConnectValue + $param = $command.Parameters['ClusterName'] + $validateAttr = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateNotNullOrEmptyAttribute] } + $validateAttr | Should -Not -BeNullOrEmpty + } + + It "Should have ValidateNotNullOrEmpty on DatastoreName" { + $command = Get-Command Get-NFSDatastoreNConnectValue + $param = $command.Parameters['DatastoreName'] + $validateAttr = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateNotNullOrEmptyAttribute] } + $validateAttr | Should -Not -BeNullOrEmpty + } + } + + Context "Cluster Validation" { + BeforeAll { + Mock Get-Cluster { $null } -ModuleName Microsoft.AVS.NFS + } + + It "Should throw when cluster does not exist" { + { Get-NFSDatastoreNConnectValue -ClusterName "NonExistentCluster" -DatastoreName "TestDS" } | + Should -Throw -ExpectedMessage "*does not exist*" + } + } + + Context "Datastore Validation" { + BeforeAll { + # Create a mock cluster that returns null when piped to Get-Datastore + $mockCluster = [PSCustomObject]@{ Name = "TestCluster" } + Mock Get-Cluster { $mockCluster } -ModuleName Microsoft.AVS.NFS + Mock Get-Datastore { $null } -ModuleName Microsoft.AVS.NFS + } + + It "Should throw when datastore not found on cluster" { + { Get-NFSDatastoreNConnectValue -ClusterName "TestCluster" -DatastoreName "NonExistentDS" } | + Should -Throw -ExpectedMessage "*not found on cluster*" + } + } + + Context "NFS Type Validation" { + BeforeAll { + $mockCluster = [PSCustomObject]@{ Name = "TestCluster" } + Mock Get-Cluster { $mockCluster } -ModuleName Microsoft.AVS.NFS + Mock Get-Datastore { [PSCustomObject]@{ Name = "VmfsDS"; Type = "VMFS" } } -ModuleName Microsoft.AVS.NFS + } + + It "Should throw when datastore is VMFS type" { + { Get-NFSDatastoreNConnectValue -ClusterName "TestCluster" -DatastoreName "VmfsDS" } | + Should -Throw -ExpectedMessage "*only supports NFS datastores*" + } + } + + Context "Host Validation" { + BeforeAll { + $mockCluster = [PSCustomObject]@{ Name = "TestCluster" } + Mock Get-Cluster { $mockCluster } -ModuleName Microsoft.AVS.NFS + Mock Get-Datastore { [PSCustomObject]@{ Name = "NfsDS"; Type = "NFS" } } -ModuleName Microsoft.AVS.NFS + Mock Get-VMHost { $null } -ModuleName Microsoft.AVS.NFS + } + + It "Should throw when no hosts found in cluster" { + { Get-NFSDatastoreNConnectValue -ClusterName "TestCluster" -DatastoreName "NfsDS" } | + Should -Throw -ExpectedMessage "*No hosts found*" + } + } + + Context "Disconnected Host Handling" { + BeforeAll { + $mockCluster = [PSCustomObject]@{ Name = "TestCluster" } + Mock Get-Cluster { $mockCluster } -ModuleName Microsoft.AVS.NFS + Mock Get-Datastore { [PSCustomObject]@{ Name = "NfsDS"; Type = "NFS" } } -ModuleName Microsoft.AVS.NFS + # All hosts disconnected + Mock Get-VMHost { + @( + [PSCustomObject]@{ Name = "esxi-01"; ConnectionState = "Disconnected" }, + [PSCustomObject]@{ Name = "esxi-02"; ConnectionState = "Maintenance" } + ) + } -ModuleName Microsoft.AVS.NFS + } + + It "Should throw when all hosts are disconnected" { + { Get-NFSDatastoreNConnectValue -ClusterName "TestCluster" -DatastoreName "NfsDS" } | + Should -Throw -ExpectedMessage "*No connected hosts*" + } + } + + Context "Happy Path - NConnect Value Retrieval" { + BeforeAll { + $mockCluster = [PSCustomObject]@{ Name = "TestCluster" } + Mock Get-Cluster { $mockCluster } -ModuleName Microsoft.AVS.NFS + Mock Get-Datastore { [PSCustomObject]@{ Name = "NfsDS"; Type = "NFS" } } -ModuleName Microsoft.AVS.NFS + + # Two connected hosts and one disconnected host (which should be skipped) + Mock Get-VMHost { + @( + [PSCustomObject]@{ Name = "esxi-01"; ConnectionState = "Connected" }, + [PSCustomObject]@{ Name = "esxi-02"; ConnectionState = "Connected" }, + [PSCustomObject]@{ Name = "esxi-03"; ConnectionState = "Disconnected" } + ) + } -ModuleName Microsoft.AVS.NFS + + # Mock Get-EsxCli to return an object with a storage.nfs.list.invoke() chain + Mock Get-EsxCli { + $listObj = New-Object PSObject + $listObj | Add-Member -MemberType ScriptMethod -Name invoke -Value { + @( + [PSCustomObject]@{ + VolumeName = "NfsDS" + Host = "10.0.0.10" + Share = "/exports/nfsds" + NFSv41 = $false + Connections = 4 + Accessible = $true + Mounted = $true + } + ) + } + [PSCustomObject]@{ + storage = [PSCustomObject]@{ + nfs = [PSCustomObject]@{ + list = $listObj + } + } + } + } -ModuleName Microsoft.AVS.NFS + } + + AfterAll { + Remove-Variable -Name NamedOutputs -Scope Global -ErrorAction SilentlyContinue + } + + It "Should populate NamedOutputs for connected hosts and skip disconnected hosts" { + Remove-Variable -Name NamedOutputs -Scope Global -ErrorAction SilentlyContinue + + Get-NFSDatastoreNConnectValue -ClusterName "TestCluster" -DatastoreName "NfsDS" + + $global:NamedOutputs | Should -Not -BeNullOrEmpty + $global:NamedOutputs.Count | Should -Be 2 + $global:NamedOutputs.Keys | Should -Contain "esxi-01" + $global:NamedOutputs.Keys | Should -Contain "esxi-02" + $global:NamedOutputs.Keys | Should -Not -Contain "esxi-03" + + $global:NamedOutputs["esxi-01"] | Should -Match "NConnectValue : 4" + $global:NamedOutputs["esxi-01"] | Should -Match "DatastoreName : NfsDS" + $global:NamedOutputs["esxi-01"] | Should -Match "NfsServerHost : 10.0.0.10" + $global:NamedOutputs["esxi-01"] | Should -Match "SharePath : /exports/nfsds" + $global:NamedOutputs["esxi-01"] | Should -Match "NfsVersion : 3" + } + + It "Should call Get-EsxCli only for connected hosts" { + Get-NFSDatastoreNConnectValue -ClusterName "TestCluster" -DatastoreName "NfsDS" + Should -Invoke Get-EsxCli -ModuleName Microsoft.AVS.NFS -Times 2 -Exactly + } + } + + Context "AVSAttribute Verification" { + It "Should have AVSAttribute with 10 minute timeout" { + $command = Get-Command Get-NFSDatastoreNConnectValue + $avsAttr = $command.ScriptBlock.Attributes | Where-Object { $_.TypeId.Name -eq 'AVSAttribute' } + $avsAttr | Should -Not -BeNullOrEmpty + $avsAttr.Timeout.TotalMinutes | Should -Be 10 + } + + It "Should have AVSAttribute with UpdatesSDDC set to false" { + $command = Get-Command Get-NFSDatastoreNConnectValue + $avsAttr = $command.ScriptBlock.Attributes | Where-Object { $_.TypeId.Name -eq 'AVSAttribute' } + $avsAttr.UpdatesSDDC | Should -Be $false + } + + It "Should have AVSAttribute timeout <= 60 minutes" { + $command = Get-Command Get-NFSDatastoreNConnectValue + $avsAttr = $command.ScriptBlock.Attributes | Where-Object { $_.TypeId.Name -eq 'AVSAttribute' } + $avsAttr.Timeout.TotalMinutes | Should -BeLessOrEqual 60 + } + } + +} + +Describe "New-NFSDatastore" { + Context "AVSAttribute Verification" { + It "Should have AVSAttribute with 10 minute timeout" { + $command = Get-Command New-NFSDatastore + $avsAttr = $command.ScriptBlock.Attributes | Where-Object { $_.TypeId.Name -eq 'AVSAttribute' } + $avsAttr | Should -Not -BeNullOrEmpty + $avsAttr.Timeout.TotalMinutes | Should -Be 10 + } + + It "Should have AVSAttribute with UpdatesSDDC set to false" { + $command = Get-Command New-NFSDatastore + $avsAttr = $command.ScriptBlock.Attributes | Where-Object { $_.TypeId.Name -eq 'AVSAttribute' } + $avsAttr.UpdatesSDDC | Should -Be $false + } + + It "Should have AVSAttribute with AutomationOnly set to true" { + $command = Get-Command New-NFSDatastore + $avsAttr = $command.ScriptBlock.Attributes | Where-Object { $_.TypeId.Name -eq 'AVSAttribute' } + $avsAttr.AutomationOnly | Should -Be $true + } + + It "Should have AVSAttribute timeout <= 60 minutes" { + $command = Get-Command New-NFSDatastore + $avsAttr = $command.ScriptBlock.Attributes | Where-Object { $_.TypeId.Name -eq 'AVSAttribute' } + $avsAttr.Timeout.TotalMinutes | Should -BeLessOrEqual 60 + } + } +} + +Describe "Remove-NFSDatastore" { + Context "AVSAttribute Verification" { + It "Should have AVSAttribute with 10 minute timeout" { + $command = Get-Command Remove-NFSDatastore + $avsAttr = $command.ScriptBlock.Attributes | Where-Object { $_.TypeId.Name -eq 'AVSAttribute' } + $avsAttr | Should -Not -BeNullOrEmpty + $avsAttr.Timeout.TotalMinutes | Should -Be 10 + } + + It "Should have AVSAttribute with UpdatesSDDC set to false" { + $command = Get-Command Remove-NFSDatastore + $avsAttr = $command.ScriptBlock.Attributes | Where-Object { $_.TypeId.Name -eq 'AVSAttribute' } + $avsAttr.UpdatesSDDC | Should -Be $false + } + + It "Should have AVSAttribute with AutomationOnly set to true" { + $command = Get-Command Remove-NFSDatastore + $avsAttr = $command.ScriptBlock.Attributes | Where-Object { $_.TypeId.Name -eq 'AVSAttribute' } + $avsAttr.AutomationOnly | Should -Be $true + } + + It "Should have AVSAttribute timeout <= 60 minutes" { + $command = Get-Command Remove-NFSDatastore + $avsAttr = $command.ScriptBlock.Attributes | Where-Object { $_.TypeId.Name -eq 'AVSAttribute' } + $avsAttr.Timeout.TotalMinutes | Should -BeLessOrEqual 60 + } + } +}