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 # # PowerShell-side lifecycle wrappers around Tools/Manage-VcfCheckServer.py, which # implements the actual start/stop/status logic (PID file at <base>/Logs/vcf-check-server.pid, # detached-process launch, SIGTERM-then-poll shutdown) - see that file's docstring. # Provides python3-then-python discovery, open-in-browser convenience, and a thin # PowerShell interface over a self-contained Python process manager. function Test-VcfCheckCatalogJson { <# .SYNOPSIS Validates and analyzes CheckCatalog.json, providing detailed error context and auto-remediation. .DESCRIPTION Attempts to parse CheckCatalog.json using PowerShell's ConvertFrom-Json. On success, returns $true. On failure, performs detailed analysis: - Extracts the exact line and column number from the parser error - Reads the file and displays the problematic stanza with surrounding context - Identifies likely causes (unescaped newlines in markdown links, missing commas, trailing commas, etc.) - Attempts automatic remediation where safe (e.g., consolidating split markdown links to single lines) - Suggests manual fixes for remaining issues This early validation (before Python tries to load the file) catches syntax errors with friendly, actionable error messages instead of cryptic server crashes. .PARAMETER CatalogPath Full path to CheckCatalog.json. .PARAMETER AutoFix Reserved for future use. Currently unused. Default $true. .OUTPUTS [Boolean] $true if the file is valid JSON (after any auto-fix), $false otherwise. .EXAMPLE if (-not (Test-VcfCheckCatalogJson -CatalogPath "$baseDirectory/Data/CheckCatalog.json")) { return 1 } #> [CmdletBinding()] [OutputType([Boolean])] Param ( [Parameter(Mandatory = $true)] [String]$CatalogPath, [Parameter(Mandatory = $false)] [Bool]$AutoFix = $true ) if (-not (Test-Path -LiteralPath $CatalogPath -PathType Leaf)) { Write-LogMessage -Type ERROR -Message "CheckCatalog.json not found at: $CatalogPath" return $false } try { $content = Get-Content -LiteralPath $CatalogPath -Raw -ErrorAction Stop $null = $content | ConvertFrom-Json -ErrorAction Stop return $true } catch { $errorMsg = $_.Exception.Message Write-LogMessage -Type ERROR -Message "CheckCatalog.json contains syntax errors and cannot be parsed." Write-LogMessage -Type ERROR -Message "File: $CatalogPath" $lines = @($content -split "`n") $lineNum = $null $colNum = $null if ($errorMsg -match "line (\d+)\D+(\d+)") { $lineNum = [Int]$matches[1] $colNum = [Int]$matches[2] } if ($null -ne $lineNum) { Write-LogMessage -Type ERROR -Message "Error at line $lineNum, column ${colNum}:" $startLine = [Math]::Max(1, $lineNum - 2) $endLine = [Math]::Min($lines.Count, $lineNum + 2) for ($i = $startLine; $i -le $endLine; $i++) { $line = $lines[$i - 1] $marker = if ($i -eq $lineNum) { ">>> " } else { " " } Write-LogMessage -Type ERROR -Message "$marker$($i.ToString().PadLeft(4)): $line" } if ($lineNum -le $lines.Count) { $probLine = $lines[$lineNum - 1] $prevLine = if ($lineNum -gt 1) { $lines[$lineNum - 2] } else { "" } $nextLine = if ($lineNum -lt $lines.Count) { $lines[$lineNum] } else { "" } $isMarkdownLinkSpanError = ($prevLine -match '\]\(https?://') -and -not ($prevLine -match '\)\s*[,"]?\s*$') if ($isMarkdownLinkSpanError) { Write-LogMessage -Type ERROR -Message "LIKELY CAUSE: Markdown link URL spans multiple lines without escaping the newline." Write-LogMessage -Type ERROR -Message "MANUAL FIX: Consolidate the markdown link on line $($lineNum - 1) to a single line without splitting the URL." } elseif ($errorMsg -match "unexpected character") { if ($probLine -match '^\s*"' -and $prevLine -match '"[^:]*:\s*["\[\{]?[^,]*["\]\}]?\s*$') { Write-LogMessage -Type ERROR -Message "LIKELY CAUSE: Missing comma after previous property value." Write-LogMessage -Type ERROR -Message "FIX: Add a comma at the end of line $($lineNum - 1) after the property value." } elseif ($probLine -notmatch ',$|[{[\:]$|^}' -and ($lineNum -lt $lines.Count) -and ($nextLine -match '^\s*[\w"]')) { Write-LogMessage -Type ERROR -Message "LIKELY CAUSE: Missing comma between object properties." Write-LogMessage -Type ERROR -Message "FIX: Add a comma at the end of line $lineNum after the property value." } else { Write-LogMessage -Type ERROR -Message "LIKELY CAUSE: Unexpected character in JSON structure (possibly a missing or misplaced comma)." } } elseif ($probLine -match ',$' -and ($lineNum -lt $lines.Count) -and ($nextLine -match '^\s*[}\]]')) { Write-LogMessage -Type ERROR -Message "LIKELY CAUSE: Trailing comma before closing bracket/brace." Write-LogMessage -Type ERROR -Message "FIX: Remove the comma at the end of line $lineNum." } elseif ($errorMsg -match "Invalid escape sequence") { Write-LogMessage -Type ERROR -Message "LIKELY CAUSE: Invalid escape sequence in a string." Write-LogMessage -Type ERROR -Message "FIX: Check line $lineNum for backslashes that need to be escaped as \\\\ or review the escape syntax." } else { Write-LogMessage -Type ERROR -Message "LIKELY CAUSE: $errorMsg" } } } else { Write-LogMessage -Type ERROR -Message "Parser error: $errorMsg" } return $false } } function Test-VcfCheckRequiredJsonFiles { <# .SYNOPSIS Validates settings.json and environments.json before the server starts. .DESCRIPTION Start-VcfCheckServer already refuses to launch on a malformed CheckCatalog.json (Test-VcfCheckCatalogJson) but previously left settings.json/environments.json unchecked at startup - a syntax error in either one only surfaced later, as a 500 from the Python server's own /api/settings or /api/environments endpoint (see Start-VcfCheckServer.py's _load_json_file/_run_safely), well after the operator had already been told the server started successfully. Calling Get-VcfCheckSettings and Get-VcfCheckEnvironments here reuses their existing parse/shape validation (Settings.ps1, Environments.ps1) and turns the same failure into a pre-flight message with the file path and cause, instead of a confusing in-browser 500. .PARAMETER BaseDirectory The resolved VcfCheck base directory containing Config/settings.json and Config/environments.json. .OUTPUTS [Boolean] $true if both files are absent-or-valid, $false if either is malformed. .EXAMPLE if (-not (Test-VcfCheckRequiredJsonFiles -BaseDirectory $baseDirectory)) { return 1 } #> [CmdletBinding()] [OutputType([Boolean])] Param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$BaseDirectory ) $isValid = $true try { $null = Get-VcfCheckSettings } catch { # Get-VcfCheckSettings also throws when settings.json is well-formed JSON but omits the # legacy single-environment SddcManagerFqdn/SddcManagerUser keys - the normal shape for a # multi-environment settings.json, which only ever carries browser preferences like Theme. # Only a genuine parse failure should block server startup here. if ($_.Exception.Message -like 'Failed to parse settings file*') { Write-LogMessage -Type ERROR -Message "settings.json is invalid and must be fixed before the server can start: $($_.Exception.Message)" $isValid = $false } } $environmentsPath = Join-Path -Path (Join-Path -Path $BaseDirectory -ChildPath 'Config') -ChildPath 'environments.json' try { $null = Get-VcfCheckEnvironments -Path $environmentsPath } catch { Write-LogMessage -Type ERROR -Message "environments.json is invalid and must be fixed before the server can start: $($_.Exception.Message)" $isValid = $false } return $isValid } function Find-VcfCheckPythonInterpreter { <# .SYNOPSIS Locates a usable python3 (or python) interpreter. .OUTPUTS [String] path to the interpreter. #> [CmdletBinding()] [OutputType([String])] Param () $candidate = Get-Command -Name python3 -ErrorAction SilentlyContinue if (-not $candidate) { $candidate = Get-Command -Name python -ErrorAction SilentlyContinue } if (-not $candidate) { if ($IsWindows) { $installGuidance = 'Install it with "winget install --id Python.Python.3.13 -e", from the Microsoft Store ("Python 3.13"), or from https://www.python.org/downloads/windows/.' } elseif ($IsMacOS -or $IsLinux) { $installGuidance = 'Python 3 is normally included by default on macOS and most Linux distributions; reinstall or repair your Python 3 package via your OS package manager.' } else { $installGuidance = 'Install Python 3.13+ for your platform from https://www.python.org/downloads/.' } throw [System.InvalidOperationException]::new("No python3/python interpreter found on PATH. $installGuidance") } return $candidate.Source } function Get-VcfCheckTcpListenerProcessId { <# .SYNOPSIS Returns the process id currently listening on a local TCP port, or $null. .DESCRIPTION Cross-platform port-owner lookup: Get-NetTCPConnection on Windows, lsof on macOS/Linux. Used by -Force on Start-/Stop-VcfCheckServer to detect and kill a stale server process even when it was started by invoking Start-VcfCheckServer.py directly (bypassing this wrapper entirely, so no PID file was ever written for it) - confirmed as a real gap via live testing, where a leftover process from an earlier direct invocation held the default port with no PID file to identify it. .PARAMETER Port TCP port to check. .OUTPUTS [Int] or $null if nothing is listening (or the lookup tool - lsof - is not installed). .EXAMPLE Get-VcfCheckTcpListenerProcessId -Port 8766 #> [CmdletBinding()] [OutputType([Int])] Param ( [Parameter(Mandatory = $true)] [ValidateRange(1, 65535)] [Int]$Port ) if ($IsWindows) { $conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -eq $conn) { return $null } return [Int]$conn.OwningProcess } if ($null -eq (Get-Command -Name lsof -ErrorAction SilentlyContinue)) { return $null } $pidLines = @(& lsof -nP "-iTCP:$Port" -sTCP:LISTEN -t 2>/dev/null) foreach ($line in $pidLines) { $ownerPid = 0 if ([Int]::TryParse($line.Trim(), [ref]$ownerPid) -and $ownerPid -gt 0) { return $ownerPid } } return $null } function Invoke-VcfCheckServerManager { <# .SYNOPSIS Thin wrapper around `python3 Manage-VcfCheckServer.py <action> ...` (see file header for why this wrapper exists - mirrors the project's established pattern of isolating external process calls behind a mockable function for unit testing). .PARAMETER Python Path to the python interpreter. .PARAMETER ManagerScript Path to Manage-VcfCheckServer.py. .PARAMETER Arguments Arguments to pass to the script. .OUTPUTS [PSCustomObject] with Output (string[]) and ExitCode. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [String]$Python, [Parameter(Mandatory = $true)] [String]$ManagerScript, [Parameter(Mandatory = $true)] [String[]]$Arguments ) $output = & $Python $ManagerScript @Arguments return [PSCustomObject]@{ Output = $output; ExitCode = $LASTEXITCODE } } function Start-VcfCheckServer { <# .SYNOPSIS Starts the bundled Python report viewer in foreground or background mode. .DESCRIPTION Refreshes the working directory's Tools/ and Data/ files from the module's own bundled copy (Initialize-VcfCheck -RefreshTools -RefreshData) before doing anything else. 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-VcfCheckServer to stop a background server and Get-VcfCheckServerStatus to check whether it is running. The refresh exists because Tools/ and Data/ are otherwise only ever copied once, on first Initialize-VcfCheck - a module upgrade with no explicit -RefreshTools/-RefreshData would silently keep running whatever server/launcher code and check catalog were bundled at initial setup time, no matter how many times -Force restarted the underlying process. Confirmed live: -Force alone (killing a stale process) gave no indication that the code being restarted was stale too, and a stale Data/CheckCatalog.json silently ran fewer checks than the module actually defines with no error of any kind. .PARAMETER Background Start the server as a background process. Returns immediately after confirming startup. Use Stop-VcfCheckServer to stop a background server. Without this switch, the server runs in the foreground and blocks until Ctrl+C is pressed. .PARAMETER Port TCP port to listen on. Default 8766. .PARAMETER NoBrowser Skip opening the report viewer in the default browser after starting. .PARAMETER Force If another process is already listening on Port, kill it first (via Get-VcfCheckTcpListenerProcessId + Stop-Process) instead of failing - even if that process has no PID file (e.g. it was started by invoking Start-VcfCheckServer.py directly). Without -Force, a port already in use throws instead of silently binding to/replacing an unrelated process. .OUTPUTS [Int] Exit code (foreground mode) or 0 (background mode successfully started). .EXAMPLE Start-VcfCheckServer .EXAMPLE Start-VcfCheckServer -Background .EXAMPLE Start-VcfCheckServer -Force .EXAMPLE Start-VcfCheckServer -Background -NoBrowser -Port 9000 #> [CmdletBinding()] [OutputType([Int])] Param ( [Parameter(Mandatory = $false)] [Switch]$Background, [Parameter(Mandatory = $false)] [ValidateRange(1, 65535)] [Int]$Port = 8766, [Parameter(Mandatory = $false)] [Switch]$NoBrowser, [Parameter(Mandatory = $false)] [Switch]$Force ) $baseDirectory = Resolve-VcfCheckActiveBaseDirectory if ([String]::IsNullOrWhiteSpace($baseDirectory)) { return 1 } Initialize-VcfCheck -RefreshTools -RefreshData | Out-Null $catalogPath = Join-Path -Path (Join-Path -Path $baseDirectory -ChildPath 'Data') -ChildPath 'CheckCatalog.json' if (-not (Test-VcfCheckCatalogJson -CatalogPath $catalogPath)) { return 1 } if (-not (Test-VcfCheckRequiredJsonFiles -BaseDirectory $baseDirectory)) { return 1 } $portOwner = Get-VcfCheckTcpListenerProcessId -Port $Port if ($null -ne $portOwner) { if (-not $Force.IsPresent) { Write-LogMessage -Type ERROR -Message "Port $Port is already in use by process $portOwner. Stop it first, or re-run with -Force to stop it automatically." return 1 } Write-LogMessage -Type WARNING -Message "Port $Port is held by process $portOwner - stopping it (-Force)." Stop-VcfCheckServer -Port $Port } $loadedModule = Get-Module -Name 'VcfCheck' -All | Sort-Object -Property Version -Descending | Select-Object -First 1 if ($loadedModule) { $env:VCFCHECK_MODULE_PSD1 = Join-Path -Path $loadedModule.ModuleBase -ChildPath 'VcfCheck.psd1' } $python = Find-VcfCheckPythonInterpreter $toolsPath = Join-Path -Path $baseDirectory -ChildPath 'Tools' $serverScript = Join-Path -Path $toolsPath -ChildPath 'Start-VcfCheckServer.py' if (-not (Test-Path -LiteralPath $serverScript)) { throw [System.InvalidOperationException]::new("Start-VcfCheckServer.py not found at `"$serverScript`" - run Initialize-VcfCheck to copy the bundled Tools/ files.") } $managerScript = Join-Path -Path $toolsPath -ChildPath 'Manage-VcfCheckServer.py' if (-not (Test-Path -LiteralPath $managerScript)) { throw [System.InvalidOperationException]::new("Manage-VcfCheckServer.py not found at `"$managerScript`" - run Initialize-VcfCheck to copy the bundled Tools/ files.") } if ($Background) { Write-LogMessage -Type INFO -Message "Starting VcfCheck Server in background on port $Port..." } else { Write-LogMessage -Type INFO -Message "Starting VcfCheck Server on port $Port..." Write-LogMessage -Type INFO -Message "Web UI will be available at http://127.0.0.1:$Port" } $serverArgs = @('start', "--port=$Port") if ($NoBrowser.IsPresent) { $serverArgs += '--no-browser' } $result = Invoke-VcfCheckServerManager -Python $python -ManagerScript $managerScript -Arguments $serverArgs if ($result.ExitCode -ne 0) { throw [System.Exception]::new("Manage-VcfCheckServer.py exited with code $($result.ExitCode): $($result.Output -join '; ')") } if ($Background) { Write-LogMessage -Type INFO -Message "Background server started. Use Stop-VcfCheckServer to stop it." return 0 } Write-LogMessage -Type INFO -Message "Server started" Write-LogMessage -Type INFO -Message "Press Ctrl+C to stop the server" if (-not $NoBrowser.IsPresent) { Start-Sleep -Milliseconds 500 $url = "http://127.0.0.1:$Port" if ($IsWindows) { Start-Process -FilePath $url } elseif ($IsMacOS) { Start-Process -FilePath 'open' -ArgumentList $url } elseif ($IsLinux) { Start-Process -FilePath 'xdg-open' -ArgumentList $url } else { Write-LogMessage -Type WARNING -Message "Unable to detect platform to auto-open a browser. Open $url manually." } } return 0 } function Stop-VcfCheckServer { <# .SYNOPSIS Stops the bundled Python report viewer if it is running. .DESCRIPTION Stops the PID-file-tracked process (via Manage-VcfCheckServer.py stop), then independently re-checks Port: if something is still listening on it - whether that's the same process not yet dead, or an entirely untracked process that never had a PID file (e.g. started by invoking Start-VcfCheckServer.py directly) - it is killed too. This second check is what makes -Force on Start-VcfCheckServer actually reliable. If $env:VcfCheckBaseDirectory is not set or no longer exists (e.g. a fresh session that never ran Initialize-VcfCheck), the tracked-server stop is skipped - there is no PID file to read - and only the Port-based check below runs. .PARAMETER Port TCP port to check for a lingering listener after the tracked process is stopped. Default 8766. .OUTPUTS None. .EXAMPLE Stop-VcfCheckServer #> [CmdletBinding()] Param ( [Parameter(Mandatory = $false)] [ValidateRange(1, 65535)] [Int]$Port = 8766 ) $baseDirectory = ([String]$env:VcfCheckBaseDirectory).Trim() if (-not [String]::IsNullOrWhiteSpace($baseDirectory) -and (Test-Path -LiteralPath $baseDirectory -PathType Container)) { $python = Find-VcfCheckPythonInterpreter $managerScript = Join-Path -Path (Join-Path -Path $baseDirectory -ChildPath 'Tools') -ChildPath 'Manage-VcfCheckServer.py' Invoke-VcfCheckServerManager -Python $python -ManagerScript $managerScript ` -Arguments @('stop') | Out-Null } else { Write-LogMessage -Type INFO -Message 'VcfCheck has not been set up in this session - checking the port directly instead of the tracked server state.' } $portOwner = Get-VcfCheckTcpListenerProcessId -Port $Port if ($null -ne $portOwner) { Write-LogMessage -Type WARNING -Message "Port $Port is still held by process $portOwner after stopping the tracked server (it may be an untracked process with no PID file) - stopping it too." Stop-Process -Id $portOwner -ErrorAction SilentlyContinue } } function Get-VcfCheckServerStatus { <# .SYNOPSIS Reports whether the bundled Python report viewer is currently running. .DESCRIPTION If $env:VcfCheckBaseDirectory is not set or no longer exists (e.g. a fresh session that never ran Initialize-VcfCheck), there is no PID-file-tracked server to report on - VcfCheck was never started from this working directory - so this reports Running = $false directly instead of erroring. .OUTPUTS [PSObject] with Running/Pid/Port properties. .EXAMPLE Get-VcfCheckServerStatus #> [CmdletBinding()] [OutputType([PSObject])] Param () $baseDirectory = ([String]$env:VcfCheckBaseDirectory).Trim() if ([String]::IsNullOrWhiteSpace($baseDirectory) -or -not (Test-Path -LiteralPath $baseDirectory -PathType Container)) { Write-LogMessage -Type INFO -Message 'VcfCheck has not been set up in this session - reporting no tracked server.' return [PSCustomObject]@{ Running = $false Pid = $null Port = $null } } $python = Find-VcfCheckPythonInterpreter $managerScript = Join-Path -Path (Join-Path -Path $baseDirectory -ChildPath 'Tools') -ChildPath 'Manage-VcfCheckServer.py' $result = Invoke-VcfCheckServerManager -Python $python -ManagerScript $managerScript ` -Arguments @('status') $parsed = $result.Output | ConvertFrom-Json -ErrorAction Stop return [PSCustomObject]@{ Running = [Bool]$parsed.running Pid = $parsed.pid Port = $parsed.port } } function Restart-VcfCheckServer { <# .SYNOPSIS Stops and restarts the bundled Python report viewer. .PARAMETER Port TCP port to listen on. Default 8766. .PARAMETER NoBrowser Skip opening the report viewer in the default browser after restarting. .PARAMETER Force Forwarded to Start-VcfCheckServer - kill anything still holding Port (tracked or not) instead of failing. .OUTPUTS None. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $false)] [ValidateRange(1, 65535)] [Int]$Port = 8766, [Parameter(Mandatory = $false)] [Switch]$NoBrowser, [Parameter(Mandatory = $false)] [Switch]$Force ) Stop-VcfCheckServer -Port $Port Start-VcfCheckServer -Port $Port -NoBrowser:$NoBrowser.IsPresent -Force:$Force.IsPresent } #endregion Tools |