Private/Tools.ps1
|
# Copyright (c) 2026 Broadcom. All Rights Reserved. # Broadcom Confidential. The term "Broadcom" refers to Broadcom Inc. # and/or its subsidiaries. # # ============================================================================= # # SOFTWARE LICENSE AGREEMENT # # Copyright (c) CA, Inc. All rights reserved. # # You are hereby granted a non-exclusive, worldwide, royalty-free license # under CA, Inc.'s copyrights to use, copy, modify, and distribute this # software in source code or binary form for use in connection with CA, Inc. # products. # # This copyright notice shall be included in all copies or substantial # portions of the software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. # # ============================================================================= #region Tools Management function Get-VcfPatchScannerToolsPath { <# .SYNOPSIS Get the path to the active VcfPatchScanner Tools directory. .DESCRIPTION Returns the full path to the Tools directory that contains the Python server, HTML UI, and PowerShell wrapper. When VcfPatchScannerBaseDirectory is set and the Tools subdirectory exists there (populated by Initialize-VcfPatchScanner), that user copy is returned so that updates from Initialize always take effect. Falls back to the module-installed Tools directory when the env var is unset or the subdir is absent. .EXAMPLE $toolsPath = Get-VcfPatchScannerToolsPath $serverScript = Join-Path -Path $toolsPath -ChildPath "Start-VCFPatchScannerServer.py" .OUTPUTS [String] Full path to the active Tools directory. .NOTES Returns the Tools directory path inside the module installation. Used by Initialize-VcfPatchScanner to locate files for copying. #> [CmdletBinding()] [OutputType([String])] Param () $baseDirEnv = $env:VcfPatchScannerBaseDirectory if ([String]::IsNullOrWhiteSpace($baseDirEnv)) { $err = "$($Script:VCF_PATCH_SCANNER_ENV_VAR) is not set. Run Initialize-VcfPatchScanner before using the scanner." Write-LogMessage -Type ERROR -Message $err throw [System.InvalidOperationException]::new($err) } $trimmedBase = $baseDirEnv.Trim() if (-not (Test-Path -LiteralPath $trimmedBase -PathType Container)) { $err = "$($Script:VCF_PATCH_SCANNER_ENV_VAR) points to a path that does not exist: '$trimmedBase'. Re-run Initialize-VcfPatchScanner." Write-LogMessage -Type ERROR -Message $err throw [System.InvalidOperationException]::new($err) } $userToolsPath = Join-Path -Path $trimmedBase -ChildPath $Script:SCAN_TOOLS_DIR_NAME if (-not (Test-Path -LiteralPath $userToolsPath -PathType Container)) { $err = "Tools directory not found under '$trimmedBase'. Re-run Initialize-VcfPatchScanner to recreate it." Write-LogMessage -Type ERROR -Message $err throw [System.InvalidOperationException]::new($err) } return (Resolve-Path -LiteralPath $userToolsPath -ErrorAction Stop).Path } function Start-VCFPatchScannerServer { <# .SYNOPSIS Start the VCF Patch Scan web server. .DESCRIPTION Starts the Python-based web server for the VCF Patch Scanner. By default the server runs in the foreground and blocks until Ctrl+C is pressed. When -Background is specified, the server is launched as a detached background process (cross-platform: setsid on macOS/Linux, DETACHED_PROCESS on Windows). Use Stop-VCFPatchScannerServer to stop a background server and Get-VCFPatchScannerServerStatus to check whether it is running. Credentials are collected through the web UI and never passed on the command line. .PARAMETER Background Start the server as a background process. Returns immediately after confirming startup. Use Stop-VCFPatchScannerServer to stop a background server. .PARAMETER NoBrowser Suppress the automatic browser launch on startup. Useful when starting as a background process from a login script or CI pipeline. .PARAMETER Port TCP port for the web server. Default: 8765. Must be between 1 and 65535. .EXAMPLE Start-VCFPatchScannerServer .EXAMPLE Start-VCFPatchScannerServer -Port 9000 .EXAMPLE Start-VCFPatchScannerServer -Background Get-VCFPatchScannerServerStatus Stop-VCFPatchScannerServer .EXAMPLE Start-VCFPatchScannerServer -Background -NoBrowser -Port 9000 .NOTES The server binds to 127.0.0.1 (localhost only), not 0.0.0.0. It is NOT accessible from remote networks or other machines. See README.md for security architecture details and required network access. Background mode delegates to Manage-VCFPatchScannerServer.py, which writes a PID file to <VcfPatchScannerBaseDirectory>/Logs/vcfpatch-server.pid. #> [CmdletBinding()] [OutputType([Int])] Param ( [Parameter(Mandatory = $false)] [Switch]$Background, [Parameter(Mandatory = $false)] [Switch]$NoBrowser, [Parameter(Mandatory = $false)] [ValidateRange(1, 65535)] [Int]$Port = 8765 ) $process = $null try { $pythonPath = Get-Command -Name python3 -ErrorAction SilentlyContinue if ($null -eq $pythonPath) { $pythonPath = Get-Command -Name python -ErrorAction SilentlyContinue if ($null -eq $pythonPath) { throw [System.InvalidOperationException]::new("Python 3 was not found. Install Python 3 and ensure it is in your PATH$(if ($IsWindows) { ' ($env:PATH)' }).") } } $toolsPath = Get-VcfPatchScannerToolsPath $serverScript = Join-Path -Path $toolsPath -ChildPath "Start-VCFPatchScannerServer.py" if (-not (Test-Path -LiteralPath $serverScript -PathType Leaf)) { throw [System.IO.FileNotFoundException]::new("Python server not found: $serverScript") } $moduleToolsPath = Join-Path -Path $MyInvocation.MyCommand.Module.ModuleBase -ChildPath "Tools" $staleFiles = [System.Collections.Generic.List[String]]::new() foreach ($toolFile in $Script:SCAN_TOOL_FILE_NAMES) { $src = Join-Path -Path $moduleToolsPath -ChildPath $toolFile $dst = Join-Path -Path $toolsPath -ChildPath $toolFile if ((Test-Path -LiteralPath $src -PathType Leaf) -and (Test-Path -LiteralPath $dst -PathType Leaf)) { $srcHash = (Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash $dstHash = (Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash if ($srcHash -ne $dstHash) { $staleFiles.Add($toolFile) } } } if ($staleFiles.Count -gt 0) { Write-LogMessage -Type INFO -Message "Deployed tool files are out of sync with the current module ($($staleFiles -join ', ')). Updating before starting the server..." foreach ($toolFile in $Script:SCAN_TOOL_FILE_NAMES) { $src = Join-Path -Path $moduleToolsPath -ChildPath $toolFile $dst = Join-Path -Path $toolsPath -ChildPath $toolFile if (Test-Path -LiteralPath $src -PathType Leaf) { Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue Write-LogMessage -Type DEBUG -Message "Refreshed tool file: $toolFile" } } Write-LogMessage -Type INFO -Message "Tool files refreshed. Server will use the current module version." } $modulePsd1 = Join-Path -Path $MyInvocation.MyCommand.Module.ModuleBase -ChildPath 'VcfPatchScanner.psd1' if (Test-Path -LiteralPath $modulePsd1 -PathType Leaf) { # Set in the current session so the background manage script inherits it via $env:. $env:VCFPATCHSCANNER_MODULE_PSD1 = $modulePsd1 } if ($Background) { $manageScript = Join-Path -Path $toolsPath -ChildPath "Manage-VCFPatchScannerServer.py" if (-not (Test-Path -LiteralPath $manageScript -PathType Leaf)) { throw [System.IO.FileNotFoundException]::new("Management script not found: $manageScript. Re-run Initialize-VcfPatchScanner.") } Write-LogMessage -Type INFO -Message "Starting VCF Patch Scan Server in background on port $Port..." $manageArgs = [System.Collections.Generic.List[String]]::new() $manageArgs.Add("--port=$Port") if ($NoBrowser) { $manageArgs.Add("--no-browser") } & $pythonPath.Source $manageScript "start" @manageArgs $backgroundExitCode = $LASTEXITCODE if ($backgroundExitCode -eq 0) { Write-LogMessage -Type INFO -Message "Background server started. Use Stop-VCFPatchScannerServer to stop it." } return $backgroundExitCode } Write-LogMessage -Type INFO -Message "Starting VCF Patch Scan Server on port $Port..." Write-LogMessage -Type INFO -Message "Web UI will be available at http://localhost:$Port" Write-LogMessage -Type DEBUG -Message "Server script: $serverScript" $env_vars = @{} $processInfo = New-Object System.Diagnostics.ProcessStartInfo $processInfo.FileName = $pythonPath.Source $processInfo.Arguments = "`"$serverScript`" --port $Port$(if ($NoBrowser) { ' --no-browser' })" $processInfo.UseShellExecute = $false $processInfo.CreateNoWindow = $false $processInfo.WorkingDirectory = $toolsPath if (-not [String]::IsNullOrWhiteSpace($env:VcfPatchScannerBaseDirectory)) { $env_vars[$Script:VCF_PATCH_SCANNER_ENV_VAR] = $env:VcfPatchScannerBaseDirectory.Trim() } if (-not [String]::IsNullOrWhiteSpace($env:VCFPATCHSCANNER_MODULE_PSD1)) { $env_vars['VCFPATCHSCANNER_MODULE_PSD1'] = $env:VCFPATCHSCANNER_MODULE_PSD1 } foreach ($key in $env_vars.Keys) { $processInfo.Environment[$key] = $env_vars[$key] } $process = [System.Diagnostics.Process]::Start($processInfo) Write-LogMessage -Type INFO -Message "Server started (PID: $($process.Id))" Write-LogMessage -Type INFO -Message "Press Ctrl+C to stop the server" $process.WaitForExit() $exitCode = $process.ExitCode Write-LogMessage -Type INFO -Message "Server stopped (exit code: $exitCode)" return $exitCode } catch { Write-LogMessage -Type ERROR -Message "Failed to start server: $($_.Exception.Message)" return -1 } finally { if ($null -ne $process) { $process.Dispose() } } } function Stop-VCFPatchScannerServer { <# .SYNOPSIS Stop a running VCF Patch Scan Server background process. .DESCRIPTION Reads the PID written by the background server to <VcfPatchScannerBaseDirectory>/Logs/vcfpatch-server.pid and stops the process. On macOS and Linux the process receives SIGTERM so the server exits cleanly. On Windows the process is terminated immediately. If the server is not running (no PID file or stale PID) the function returns $true without error — stopping an already-stopped server is idempotent. .EXAMPLE Stop-VCFPatchScannerServer .EXAMPLE if (-not (Stop-VCFPatchScannerServer)) { Write-LogMessage -Type WARNING -Message "Server stop command failed." } .OUTPUTS [Bool] $true when the server was stopped (or was already stopped); $false on error. .NOTES Only effective when the server was started with -Background (which writes the PID file). A foreground server (started without -Background) must be stopped with Ctrl+C. #> [CmdletBinding()] [OutputType([Bool])] Param () $status = Get-VCFPatchScannerServerStatus if ($null -eq $status) { Write-LogMessage -Type ERROR -Message "Could not read server status. Ensure $($Script:VCF_PATCH_SCANNER_ENV_VAR) is set." return $false } if (-not $status.IsRunning) { Write-LogMessage -Type INFO -Message "Server is not running." return $true } Write-LogMessage -Type INFO -Message "Stopping VCF Patch Scan Server (PID $($status.ProcessId))..." Stop-Process -Id $status.ProcessId -ErrorAction SilentlyContinue $deadline = (Get-Date).AddSeconds(10) while ((Get-Date) -lt $deadline) { if ($null -eq (Get-Process -Id $status.ProcessId -ErrorAction SilentlyContinue)) { Write-LogMessage -Type INFO -Message "Server stopped." return $true } Start-Sleep -Milliseconds 200 } Write-LogMessage -Type WARNING -Message "Server did not stop within 10 seconds — sending force-stop." Stop-Process -Id $status.ProcessId -Force -ErrorAction SilentlyContinue Write-LogMessage -Type INFO -Message "Server force-stopped." return $true } function Get-VCFPatchScannerServerStatus { <# .SYNOPSIS Report whether the VCF Patch Scan Server background process is currently running. .DESCRIPTION Reads the PID file written by the background server to <VcfPatchScannerBaseDirectory>/Logs/vcfpatch-server.pid and verifies that the process is still alive. Stale PID files (process no longer exists) are removed automatically. Returns a PSCustomObject describing the server state. .PARAMETER Port Port the server was started on, used to construct the Url field in the returned object. Defaults to 8765 (the server default). Has no effect on whether the server is detected as running — detection is based solely on the PID file. .EXAMPLE $status = Get-VCFPatchScannerServerStatus if ($status.IsRunning) { Write-Host "Server is running at $($status.Url) (PID $($status.ProcessId))" } .EXAMPLE Get-VCFPatchScannerServerStatus -Port 9000 .OUTPUTS [PSCustomObject] Object with IsRunning ([Bool]), ProcessId ([Int] or $null), and Url ([String] or $null). Returns $null when the base directory is not configured. .NOTES Only reflects background-mode servers (started with Start-VCFPatchScannerServer -Background). Foreground servers do not write a PID file and will not be detected. #> [CmdletBinding()] [OutputType([PSCustomObject])] Param ( [Parameter(Mandatory = $false)] [ValidateRange(1, 65535)] [Int]$Port = 8765 ) $basePath = $env:VcfPatchScannerBaseDirectory if ([String]::IsNullOrWhiteSpace($basePath)) { Write-LogMessage -Type ERROR -Message "$($Script:VCF_PATCH_SCANNER_ENV_VAR) is not set. Run Initialize-VcfPatchScanner first." return $null } $pidFile = Join-Path -Path (Join-Path -Path $basePath.Trim() -ChildPath "Logs") -ChildPath "vcfpatch-server.pid" if (-not (Test-Path -LiteralPath $pidFile -PathType Leaf)) { return [PSCustomObject]@{ IsRunning = $false; ProcessId = $null; Url = $null } } $pidText = Get-Content -LiteralPath $pidFile -Raw -ErrorAction SilentlyContinue [Int]$serverPid = 0 if ([String]::IsNullOrWhiteSpace($pidText) -or -not [Int]::TryParse($pidText.Trim(), [ref]$serverPid)) { Write-LogMessage -Type WARNING -Message "PID file contains invalid content — removing: $pidFile" Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue return [PSCustomObject]@{ IsRunning = $false; ProcessId = $null; Url = $null } } if ($null -eq (Get-Process -Id $serverPid -ErrorAction SilentlyContinue)) { Write-LogMessage -Type DEBUG -Message "PID $serverPid not found — removing stale PID file." Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue return [PSCustomObject]@{ IsRunning = $false; ProcessId = $null; Url = $null } } return [PSCustomObject]@{ IsRunning = $true ProcessId = $serverPid Url = "http://localhost:$Port" } } function Restart-VCFPatchScannerServer { <# .SYNOPSIS Restart the VCF Patch Scan Server background process. .DESCRIPTION Stops the running background server (if any) and starts a new background process on the specified port. Equivalent to calling Stop-VCFPatchScannerServer followed by Start-VCFPatchScannerServer -Background. Always starts in background mode — use Start-VCFPatchScannerServer without -Background for a foreground server. .PARAMETER NoBrowser Suppress the automatic browser launch on startup. .PARAMETER Port TCP port for the restarted server. Default: 8765. Must be between 1 and 65535. .EXAMPLE Restart-VCFPatchScannerServer .EXAMPLE Restart-VCFPatchScannerServer -Port 9000 -NoBrowser .OUTPUTS [Int] Exit code from the background server start (0 = success, non-zero = failure). .NOTES If no background server is currently running, the stop step is a no-op and the start proceeds normally. The 500 ms pause between stop and start ensures the port is fully released before the new process binds it. #> [CmdletBinding()] [OutputType([Int])] Param ( [Parameter(Mandatory = $false)] [Switch]$NoBrowser, [Parameter(Mandatory = $false)] [ValidateRange(1, 65535)] [Int]$Port = 8765 ) $null = Stop-VCFPatchScannerServer Start-Sleep -Milliseconds 500 return Start-VCFPatchScannerServer -Background -Port $Port -NoBrowser:$NoBrowser.IsPresent } function Resolve-HtmlAwareErrorMessage { <# .SYNOPSIS Translate a raw exception message into a human-readable error, detecting HTML responses. .DESCRIPTION PowerCLI Connect-Vcf* cmdlets and Invoke-RestMethod both throw a JSON-parse exception whose message starts with "Unexpected character encountered while parsing value: <" when the remote server returns an HTML page (wrong address, SSO redirect, reverse-proxy error). This function maps that pattern to a clear, actionable message and passes all other exception messages through unchanged. .PARAMETER ExceptionMessage Raw exception message string from $_.Exception.Message. .PARAMETER Server Hostname or FQDN of the server that was contacted, included in the returned message. .PARAMETER Context Short product/role label inserted into the message (e.g. "VCF Operations", "SDDC Manager"). .EXAMPLE catch { $err = Resolve-HtmlAwareErrorMessage -ExceptionMessage $_.Exception.Message ` -Server $Server -Context "VCF Operations" Write-LogMessage -Type ERROR -Message $err throw [System.InvalidOperationException]::new($err) } .OUTPUTS [String] Human-readable error message. .NOTES Pure string transformation. Does not mutate any module-scope variables. Strips HTML tags when the error message appears to be an HTML response body. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$ExceptionMessage, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Server, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Context ) if ($ExceptionMessage -match 'Unexpected character.*<') { return "$Context server at $Server returned an HTML page instead of an API response. Verify the address points to the correct product endpoint and is not a proxy, SDDC Manager, or SSO redirect URL." } # PowerCLI Write-Error output embeds the invocation timestamp and cmdlet name before the # actual message: "M/d/yyyy H:mm:ss CmdletName actual message". Strip that prefix # so only the actionable text reaches the log. $stripped = $ExceptionMessage -replace '^\d{1,2}/\d{1,2}/\d{4} \d+:\d{2}:\d{2}\s{2,}\S+\s{2,}', '' if ($stripped.Length -gt 0) { return $stripped } return $ExceptionMessage } #endregion |