Private/Environments.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 Environments
#
# Multi-environment support: environments.json holds an array of named SDDC Manager targets
# (Id/Name/SddcManagerFqdn/SddcManagerUser/EnableRootCredentialChecks/CreatedAt/UpdatedAt) so the
# browser UI can manage more than one target without re-typing the FQDN/username every time. Per
# Get-VcfCheckSettings's existing rule (Private/Settings.ps1) and requirements, a password
# is NEVER part of this file - Save-VcfCheckEnvironments hard-fails (not just warns) if any
# environment object (including nested Integrations/Endpoints entries) carries a password-shaped
# key, since this file is reachable from the browser's Add/Edit form and not just from a
# hand-edited config.
#
# Integrations is an optional array of components attached to the environment that SDDC Manager
# has zero knowledge of (e.g. a standalone Aria Operations instance deployed outside SDDC
# Manager/VRSLCM). Each entry: Type ('AriaOperations' today), SharedCredentials (bool),
# Username (used by every endpoint when SharedCredentials is true), and Endpoints (array of
# Name/Fqdn, plus its own Username when SharedCredentials is false). See
# Get-VcfCheckEnvironmentAriaOpsEndpoints (Private/AriaOpsHelpers.ps1) for how a check resolves
# this into connectable targets. Passwords are never stored here either way - always resolved at
# run time (session-only prompt/launcher param), keyed by whichever username applies.
#
# CRUD for the browser UI itself lives in Start-VcfCheckServer.py (plain JSON list edits, no
# subprocess needed) - the functions here exist so Invoke-VcfCheck/CLI users get the same
# capability from the module directly, both sides agreeing on the same file/schema.

function Find-VcfCheckPasswordLikePropertyName {

    <#
        .SYNOPSIS
        Recursively searches an object (including nested Integrations/Endpoints arrays) for a
        password-shaped property name.

        .DESCRIPTION
        Shared by Get-VcfCheckEnvironments (read-time warning) and Save-VcfCheckEnvironments
        (write-time hard failure) so both scan the same way. A flat, top-level-only scan would
        miss a password entered on a nested Integrations[].Endpoints[] object, which is reachable
        from the browser's Add/Edit form exactly like every other field on an environment.

        .PARAMETER InputObject
        The object (or array) to scan.

        .OUTPUTS
        [String] the first matching property name found, or $null if none.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $false)] [AllowNull()] [Object]$InputObject
    )

    if ($null -eq $InputObject) {
        return $null
    }

    if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [String]) {
        foreach ($item in $InputObject) {
            $found = Find-VcfCheckPasswordLikePropertyName -InputObject $item
            if ($found) {
                return $found
            }
        }
        return $null
    }

    if ($InputObject.PSObject -and $InputObject.PSObject.Properties) {
        # Only NoteProperty members are followed - the kind ConvertFrom-Json/PSCustomObject
        # literals produce for actual data fields. Adapted .NET properties (e.g. DateTime.Date,
        # which itself returns a DateTime) are excluded on purpose: recursing into those causes
        # infinite recursion (DateTime.Date.Date.Date...), confirmed live when CreatedAt/UpdatedAt
        # round-tripped through ConvertFrom-Json as [DateTime] rather than [String].
        foreach ($property in $InputObject.PSObject.Properties) {
            if ($property.MemberType -ne 'NoteProperty') {
                continue
            }
            if ($property.Name -match '(?i)password') {
                return $property.Name
            }
            $found = Find-VcfCheckPasswordLikePropertyName -InputObject $property.Value
            if ($found) {
                return $found
            }
        }
    }

    return $null
}
function Get-VcfCheckEnvironments {

    <#
        .SYNOPSIS
        Loads the list of saved environments from environments.json.

        .DESCRIPTION
        Returns an empty array if environments.json does not exist and there is nothing to
        migrate. If environments.json does not exist yet but settings.json (Get-VcfCheckSettings)
        has SddcManagerFqdn/SddcManagerUser, synthesizes a single environment from it and persists
        that migration immediately, so an existing single-environment user's setup is not lost.

        .PARAMETER Path
        Path to environments.json. When omitted, resolved from
        $env:VcfCheckBaseDirectory\Config\environments.json.

        .PARAMETER SettingsPath
        Path to settings.json, used only for the one-time migration described above. When
        omitted, resolved the same way Get-VcfCheckSettings resolves it by default.

        .OUTPUTS
        [Object[]] of environment PSCustomObjects (Id, Name, SddcManagerFqdn, SddcManagerUser,
        EnableRootCredentialChecks, CreatedAt, UpdatedAt), or an empty array.

        .EXAMPLE
        $environments = Get-VcfCheckEnvironments
    #>


    [CmdletBinding()]
    [OutputType([Object[]])]
    Param (
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$Path = '',
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$SettingsPath = ''
    )

    $resolvedPath = Resolve-VcfCheckEnvironmentsPath -Path $Path

    if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) {
        $settings = $null
        try {
            $settings = Get-VcfCheckSettings -Path $SettingsPath
        } catch {
            Write-LogMessage -Type DEBUG -Message "Could not read settings.json while checking for an environments migration: $($_.Exception.Message)"
        }

        if (-not $settings -or [String]::IsNullOrWhiteSpace($settings.SddcManagerFqdn) -or [String]::IsNullOrWhiteSpace($settings.SddcManagerUser)) {
            return @()
        }

        $migratedAt = (Get-Date).ToUniversalTime().ToString('o')
        $migrated = @([PSCustomObject]@{
            Id                         = New-VcfCheckEnvironmentId
            Name                       = $settings.SddcManagerFqdn
            SddcManagerFqdn            = $settings.SddcManagerFqdn
            SddcManagerUser            = $settings.SddcManagerUser
            EnableRootCredentialChecks = $false
            CreatedAt                  = $migratedAt
            UpdatedAt                  = $migratedAt
        })

        Write-LogMessage -Type INFO -Message "Migrated settings.json's single SDDC Manager target into environments.json as `"$($migrated[0].Name)`"."
        Save-VcfCheckEnvironments -Environments $migrated -Path $resolvedPath
        return $migrated
    }

    try {
        $raw = @(Get-Content -LiteralPath $resolvedPath -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 10 -ErrorAction Stop)
    } catch {
        throw [System.InvalidOperationException]::new("Failed to parse environments file `"$resolvedPath`": $($_.Exception.Message)")
    }

    foreach ($environment in $raw) {
        $passwordLikeProperties = @($environment.PSObject.Properties.Name | Where-Object { $_ -match '(?i)password' })
        foreach ($passwordLikeProperty in $passwordLikeProperties) {
            Write-LogMessage -Type WARNING -Message "environments.json at `"$resolvedPath`" contains a `"$passwordLikeProperty`" field on environment `"$($environment.Name)`". This value will be ignored - VcfCheck never reads a password from disk."
            $environment.PSObject.Properties.Remove($passwordLikeProperty)
        }

        $nestedPasswordLikeProperty = Find-VcfCheckPasswordLikePropertyName -InputObject $environment.Integrations
        if ($nestedPasswordLikeProperty) {
            Write-LogMessage -Type WARNING -Message "environments.json at `"$resolvedPath`" contains a `"$nestedPasswordLikeProperty`" field nested under Integrations on environment `"$($environment.Name)`". This value will be ignored - VcfCheck never reads a password from disk. Remove it by re-saving the environment via the UI."
        }
    }

    return $raw
}
function Save-VcfCheckEnvironments {

    <#
        .SYNOPSIS
        Persists the full list of environments to environments.json.

        .DESCRIPTION
        Writes atomically (temp file in the same directory, then Move-Item -Force) so a reader
        never observes a partially-written file. Hard-fails if any environment object carries a
        password-shaped property name - this is a write-time guardrail (not just the read-time
        warning Get-VcfCheckEnvironments/Get-VcfCheckSettings give), because this path is
        reachable from the browser's Add/Edit form, not only from a hand-edited config file.

        .PARAMETER Environments
        The full array of environment objects to write (not a delta - callers read, mutate, and
        pass back the complete list).

        .PARAMETER Path
        Path to environments.json. When omitted, resolved from
        $env:VcfCheckBaseDirectory\Config\environments.json.

        .OUTPUTS
        None.

        .EXAMPLE
        Save-VcfCheckEnvironments -Environments $environments
    #>


    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Object[]]$Environments,
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$Path = ''
    )

    foreach ($environment in $Environments) {
        $passwordLikeProperty = Find-VcfCheckPasswordLikePropertyName -InputObject $environment
        if ($passwordLikeProperty) {
            throw [System.InvalidOperationException]::new("Environment `"$($environment.Name)`" carries a `"$passwordLikeProperty`" field (top-level or nested under Integrations). environments.json may never contain a password - resolve it interactively or via the launcher's session-only credential fields instead.")
        }
    }

    $resolvedPath = Resolve-VcfCheckEnvironmentsPath -Path $Path
    $parentDirectory = Split-Path -Path $resolvedPath -Parent
    if (-not (Test-Path -LiteralPath $parentDirectory -PathType Container)) {
        $null = New-VcfCheckOwnerOnlyDirectory -Path $parentDirectory -Force
    }

    $json = $null
    foreach ($depth in @(5, 3, 2)) {
        try {
            $json = $Environments | ConvertTo-Json -Depth $depth -ErrorAction Stop
            break
        }
        catch {
            if ($depth -eq 2) {
                throw $_
            }
        }
    }
    $tempPath = Join-Path -Path $parentDirectory -ChildPath "environments.$([Guid]::NewGuid().ToString('N')).tmp"
    try {
        Set-Content -LiteralPath $tempPath -Value $json -ErrorAction Stop
        Move-Item -LiteralPath $tempPath -Destination $resolvedPath -Force -ErrorAction Stop
    } finally {
        if (Test-Path -LiteralPath $tempPath) {
            Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
        }
    }
}
function New-VcfCheckEnvironmentId {

    <#
        .SYNOPSIS
        Generates a short, unique environment id.

        .DESCRIPTION
        Matches the run-id length convention used server-side
        (Tools/Start-VcfCheckServer.py's uuid.uuid4().hex[:12]).

        .OUTPUTS
        [String] a 12-character lowercase hex id.

        .EXAMPLE
        $id = New-VcfCheckEnvironmentId
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param ()

    return [Guid]::NewGuid().ToString('N').Substring(0, 12)
}
function Test-VcfCheckEnvironmentIsValid {

    <#
        .SYNOPSIS
        Validates an environment's required fields.

        .DESCRIPTION
        Shared by Add-VcfCheckEnvironment and Set-VcfCheckEnvironment so both paths apply
        the same rules: Name, SddcManagerFqdn, and SddcManagerUser must all be non-empty.

        .PARAMETER Environment
        The environment object to validate.

        .OUTPUTS
        [PSObject] with IsValid (bool) and Errors (string[]).

        .EXAMPLE
        $validation = Test-VcfCheckEnvironmentIsValid -Environment $environment
    #>


    [CmdletBinding()]
    [OutputType([PSObject])]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$Environment
    )

    $errors = [System.Collections.Generic.List[String]]::new()

    if ([String]::IsNullOrWhiteSpace($Environment.Name)) {
        $errors.Add('Name must not be empty.')
    }
    if ([String]::IsNullOrWhiteSpace($Environment.SddcManagerFqdn)) {
        $errors.Add('SddcManagerFqdn must not be empty.')
    }
    if ([String]::IsNullOrWhiteSpace($Environment.SddcManagerUser)) {
        $errors.Add('SddcManagerUser must not be empty.')
    }

    foreach ($integration in @($Environment.Integrations)) {
        if (-not $integration) {
            continue
        }
        if ([String]::IsNullOrWhiteSpace($integration.Type)) {
            $errors.Add('Each integration must have a Type.')
        }
        if ($integration.SharedCredentials -and [String]::IsNullOrWhiteSpace($integration.Username)) {
            $errors.Add("Integration `"$($integration.Type)`" has SharedCredentials enabled but no Username.")
        }
        $endpoints = @($integration.Endpoints)
        if ($endpoints.Count -eq 0) {
            $errors.Add("Integration `"$($integration.Type)`" must have at least one endpoint.")
        }
        foreach ($endpoint in $endpoints) {
            if ([String]::IsNullOrWhiteSpace($endpoint.Name)) {
                $errors.Add("Integration `"$($integration.Type)`" has an endpoint with no Name.")
            }
            if ([String]::IsNullOrWhiteSpace($endpoint.Fqdn)) {
                $errors.Add("Integration `"$($integration.Type)`" endpoint `"$($endpoint.Name)`" has no Fqdn.")
            }
            if (-not $integration.SharedCredentials -and [String]::IsNullOrWhiteSpace($endpoint.Username)) {
                $errors.Add("Integration `"$($integration.Type)`" endpoint `"$($endpoint.Name)`" has no Username (SharedCredentials is disabled, so each endpoint needs its own).")
            }
        }
    }

    return [PSCustomObject]@{
        IsValid = ($errors.Count -eq 0)
        Errors  = $errors.ToArray()
    }
}
function Add-VcfCheckEnvironment {

    <#
        .SYNOPSIS
        Creates and persists a new saved environment.

        .PARAMETER Name
        Friendly display name.

        .PARAMETER SddcManagerFqdn
        SDDC Manager FQDN.

        .PARAMETER SddcManagerUser
        SDDC Manager username.

        .PARAMETER EnableRootCredentialChecks
        Whether checks that require the SDDC Manager appliance root credential (VMware Tools
        guest operations) should be offered/run for this environment.

        .PARAMETER Path
        Path to environments.json. When omitted, resolved from
        $env:VcfCheckBaseDirectory\Config\environments.json.

        .OUTPUTS
        [PSObject] the newly created environment.

        .EXAMPLE
        Add-VcfCheckEnvironment -Name 'Production East' -SddcManagerFqdn 'sddc.example.com' -SddcManagerUser 'administrator@vsphere.local'
    #>


    [CmdletBinding()]
    [OutputType([PSObject])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Name,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$SddcManagerFqdn,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$SddcManagerUser,
        [Parameter(Mandatory = $false)] [Bool]$EnableRootCredentialChecks = $false,
        [Parameter(Mandatory = $false)] [AllowNull()] [Object[]]$Integrations = @(),
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$Path = ''
    )

    $resolvedPath = Resolve-VcfCheckEnvironmentsPath -Path $Path
    $environments = [System.Collections.Generic.List[Object]]::new()
    # @(...) wraps the call before assignment - Get-VcfCheckEnvironments returning an empty
    # array is otherwise unrolled to $null by PowerShell's pipeline output semantics, which would
    # make AddRange($null) throw ([Object[]]$null stays $null, it does not coerce to an empty
    # array the way @($null-returning-call) does).
    $environments.AddRange(@(Get-VcfCheckEnvironments -Path $resolvedPath -SettingsPath (Resolve-VcfCheckSettingsPathSibling -EnvironmentsPath $resolvedPath)))

    $now = (Get-Date).ToUniversalTime().ToString('o')
    $newEnvironment = [PSCustomObject]@{
        Id                         = New-VcfCheckEnvironmentId
        Name                       = $Name
        SddcManagerFqdn            = $SddcManagerFqdn
        SddcManagerUser            = $SddcManagerUser
        EnableRootCredentialChecks = $EnableRootCredentialChecks
        Integrations               = @($Integrations)
        CreatedAt                  = $now
        UpdatedAt                  = $now
    }

    $validation = Test-VcfCheckEnvironmentIsValid -Environment $newEnvironment
    if (-not $validation.IsValid) {
        throw [System.InvalidOperationException]::new("Cannot add environment: $($validation.Errors -join ' ')")
    }

    $environments.Add($newEnvironment)
    Save-VcfCheckEnvironments -Environments $environments.ToArray() -Path $resolvedPath
    return $newEnvironment
}
function Set-VcfCheckEnvironment {

    <#
        .SYNOPSIS
        Updates and persists an existing saved environment's fields.

        .DESCRIPTION
        Only fields actually supplied by the caller are changed - $PSBoundParameters is checked
        rather than defaulting every parameter, so a partial update (e.g. renaming only) does not
        clobber the other fields with empty defaults.

        .PARAMETER Id
        Id of the environment to update.

        .PARAMETER Name
        New friendly display name.

        .PARAMETER SddcManagerFqdn
        New SDDC Manager FQDN.

        .PARAMETER SddcManagerUser
        New SDDC Manager username.

        .PARAMETER EnableRootCredentialChecks
        New root-credential-checks setting.

        .PARAMETER Path
        Path to environments.json. When omitted, resolved from
        $env:VcfCheckBaseDirectory\Config\environments.json.

        .OUTPUTS
        [PSObject] the updated environment.

        .EXAMPLE
        Set-VcfCheckEnvironment -Id 'a1b2c3d4e5f6' -Name 'Production East (renamed)'
    #>


    [CmdletBinding()]
    [OutputType([PSObject])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Id,
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$Name = '',
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$SddcManagerFqdn = '',
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$SddcManagerUser = '',
        [Parameter(Mandatory = $false)] [Bool]$EnableRootCredentialChecks,
        [Parameter(Mandatory = $false)] [AllowNull()] [Object[]]$Integrations,
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$Path = ''
    )

    $resolvedPath = Resolve-VcfCheckEnvironmentsPath -Path $Path
    $environments = @(Get-VcfCheckEnvironments -Path $resolvedPath -SettingsPath (Resolve-VcfCheckSettingsPathSibling -EnvironmentsPath $resolvedPath))
    $existing = $environments | Where-Object { $_.Id -eq $Id } | Select-Object -First 1
    if (-not $existing) {
        throw [System.InvalidOperationException]::new("No environment found with id `"$Id`".")
    }

    if ($PSBoundParameters.ContainsKey('Name')) { $existing.Name = $Name }
    if ($PSBoundParameters.ContainsKey('SddcManagerFqdn')) { $existing.SddcManagerFqdn = $SddcManagerFqdn }
    if ($PSBoundParameters.ContainsKey('SddcManagerUser')) { $existing.SddcManagerUser = $SddcManagerUser }
    if ($PSBoundParameters.ContainsKey('EnableRootCredentialChecks')) { $existing.EnableRootCredentialChecks = $EnableRootCredentialChecks }
    if ($PSBoundParameters.ContainsKey('Integrations')) {
        if ($existing.PSObject.Properties.Name -contains 'Integrations') {
            $existing.Integrations = @($Integrations)
        } else {
            $existing | Add-Member -MemberType NoteProperty -Name Integrations -Value @($Integrations)
        }
    }
    $existing.UpdatedAt = (Get-Date).ToUniversalTime().ToString('o')

    $validation = Test-VcfCheckEnvironmentIsValid -Environment $existing
    if (-not $validation.IsValid) {
        throw [System.InvalidOperationException]::new("Cannot update environment: $($validation.Errors -join ' ')")
    }

    Save-VcfCheckEnvironments -Environments $environments -Path $resolvedPath
    return $existing
}
function Remove-VcfCheckEnvironment {

    <#
        .SYNOPSIS
        Removes and persists the removal of a saved environment.

        .PARAMETER Id
        Id of the environment to remove.

        .PARAMETER Path
        Path to environments.json. When omitted, resolved from
        $env:VcfCheckBaseDirectory\Config\environments.json.

        .OUTPUTS
        None.

        .EXAMPLE
        Remove-VcfCheckEnvironment -Id 'a1b2c3d4e5f6'
    #>


    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Id,
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$Path = ''
    )

    $resolvedPath = Resolve-VcfCheckEnvironmentsPath -Path $Path
    $environments = @(Get-VcfCheckEnvironments -Path $resolvedPath -SettingsPath (Resolve-VcfCheckSettingsPathSibling -EnvironmentsPath $resolvedPath))
    $remaining = @($environments | Where-Object { $_.Id -ne $Id })
    if ($remaining.Count -eq $environments.Count) {
        throw [System.InvalidOperationException]::new("No environment found with id `"$Id`".")
    }

    Save-VcfCheckEnvironments -Environments $remaining -Path $resolvedPath
}
function Resolve-VcfCheckEnvironmentsPath {

    <#
        .SYNOPSIS
        Resolves the effective environments.json path.

        .DESCRIPTION
        Internal helper shared by every function in this file - not exported (absent from
        VcfCheck.psd1's FunctionsToExport), matching how path-resolution logic elsewhere
        in this module is inlined per-function rather than sharing an unexported helper; separated
        here only because five functions in this file all need the identical resolution.

        .PARAMETER Path
        Explicit override. When empty, resolved from
        $env:VcfCheckBaseDirectory\Config\environments.json.

        .OUTPUTS
        [String]
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$Path = ''
    )

    if (-not [String]::IsNullOrWhiteSpace($Path)) {
        return $Path
    }

    if ([String]::IsNullOrWhiteSpace($env:VcfCheckBaseDirectory)) {
        throw [System.InvalidOperationException]::new('No environments.json path was provided and $env:VcfCheckBaseDirectory is not set.')
    }

    return Join-Path -Path $env:VcfCheckBaseDirectory.Trim() -ChildPath (Join-Path -Path $Script:CHECK_CONFIG_DIR_NAME -ChildPath $Script:CHECK_ENVIRONMENTS_FILE_NAME)
}
function Resolve-VcfCheckSettingsPathSibling {

    <#
        .SYNOPSIS
        Resolves the settings.json path that lives alongside a given (already-resolved)
        environments.json path.

        .DESCRIPTION
        Add-/Set-/Remove-VcfCheckEnvironment must pass this to Get-VcfCheckEnvironments's
        -SettingsPath explicitly rather than letting it default - Get-VcfCheckSettings's own
        default falls back to $env:VcfCheckBaseDirectory, which is only correct when
        -EnvironmentsPath also came from that same env var. Calling
        Add-VcfCheckEnvironment with an explicit -Path elsewhere (e.g. a test's own tempdir, or
        any caller not using the default base directory) still silently migrated the ambient
        default settings.json's SddcManagerFqdn/User into the explicitly-targeted environments
        file, mixing two unrelated targets. Deriving the sibling path from the resolved
        environments path instead keeps both files scoped to the same base directory in every
        case, matching Get-VcfCheckSettings' own default when neither override is given.

        .PARAMETER EnvironmentsPath
        The already-resolved (non-empty) environments.json path.

        .OUTPUTS
        [String]
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$EnvironmentsPath
    )

    return Join-Path -Path (Split-Path -Path $EnvironmentsPath -Parent) -ChildPath $Script:CHECK_SETTINGS_FILE_NAME
}

#endregion Environments