Private/Invoke-DeviceManager.ps1

function ConvertFrom-DMCasedHashtable {
    # Recursively converts a Hashtable (e.g. from ConvertFrom-Json -AsHashtable) to PSCustomObject,
    # deduplicating case-conflicting keys by preferring the all-uppercase variant.
    param([object]$InputObject)
    if ($InputObject -is [System.Collections.IDictionary]) {
        $seen   = @{}
        $result = [ordered]@{}
        foreach ($key in @($InputObject.Keys)) {
            $lower = $key.ToLowerInvariant()
            if ($seen.ContainsKey($lower)) {
                if ($key -ceq $key.ToUpperInvariant()) {
                    $existing = $seen[$lower]
                    $result.Remove($existing)
                    $result[$key] = ConvertFrom-DMCasedHashtable $InputObject[$key]
                    $seen[$lower] = $key
                }
            } else {
                $seen[$lower] = $key
                $result[$key] = ConvertFrom-DMCasedHashtable $InputObject[$key]
            }
        }
        return [pscustomobject]$result
    }
    if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
        return @($InputObject | ForEach-Object { ConvertFrom-DMCasedHashtable $_ })
    }
    return $InputObject
}

function Copy-DMTraceValue {
    param(
        [Parameter(ValueFromPipeline = $true)]
        [object]$Value
    )

    if ($null -eq $Value -or $Value -is [string] -or $Value.GetType().IsValueType) {
        return $Value
    }

    if ($Value -is [System.Collections.IDictionary]) {
        $copy = [ordered]@{}
        foreach ($key in $Value.Keys) {
            if ([string]$key -match '(?i)(password|passwd|pwd|token|secret|community)') {
                $copy[$key] = '[REDACTED]'
            }
            else {
                $copy[$key] = Copy-DMTraceValue -Value $Value[$key]
            }
        }
        return [pscustomobject]$copy
    }

    if ($Value -is [System.Collections.IEnumerable]) {
        return @($Value | ForEach-Object { Copy-DMTraceValue -Value $_ })
    }

    $copy = [ordered]@{}
    foreach ($property in $Value.PSObject.Properties) {
        if ($property.Name -match '(?i)(password|passwd|pwd|token|secret|community)') {
            $copy[$property.Name] = '[REDACTED]'
        }
        else {
            $copy[$property.Name] = Copy-DMTraceValue -Value $property.Value
        }
    }
    return [pscustomobject]$copy
}

function Write-DMRequestTrace {
    param(
        [Parameter(Mandatory)][datetime]$StartedAt,
        [Parameter(Mandatory)][string]$Method,
        [Parameter(Mandatory)][string]$Resource,
        [Parameter(Mandatory)][string]$Uri,
        [object]$BodyData,
        [switch]$ApiV2,
        [object]$Response,
        [string]$Exception,
        [object]$Session,
        [object]$StatusCode,
        [string]$RawJsonBody,
        [object]$Headers
    )

    $traceAction = Get-Variable -Name DeviceManagerTraceAction -Scope Script -ErrorAction SilentlyContinue
    if ($null -eq $traceAction -or $null -eq $traceAction.Value) {
        return
    }

    $contextVariable = Get-Variable -Name DeviceManagerTraceContext -Scope Script -ErrorAction SilentlyContinue
    $context = if ($null -ne $contextVariable) { $contextVariable.Value } else { $null }

    $depthVariable = Get-Variable -Name DeviceManagerTraceDepth -Scope Script -ErrorAction SilentlyContinue
    $depth = if ($null -ne $depthVariable -and $depthVariable.Value) { [int]$depthVariable.Value } else { 1 }

    # Read Hostname/Version defensively: a partial or legacy session object may lack them,
    # and direct member access throws under Set-StrictMode.
    $sessionHostname = $null
    $sessionVersion = $null
    if ($Session) {
        $hostProp = $Session.PSObject.Properties['Hostname']
        if ($hostProp) { $sessionHostname = $hostProp.Value }
        $versionProp = $Session.PSObject.Properties['Version']
        if ($versionProp) { $sessionVersion = $versionProp.Value }
    }

    $entry = [pscustomobject]@{
        Timestamp  = $StartedAt.ToString('o')
        DurationMs = [math]::Round(((Get-Date) - $StartedAt).TotalMilliseconds, 2)
        Step       = if ($context) { $context.Name } else { $null }
        Category   = if ($context) { $context.Category } else { $null }
        Vendor     = 'Huawei OceanStor'
        Hostname   = $sessionHostname
        Version    = $sessionVersion
        Method     = $Method
        Resource   = $Resource
        ApiV2      = [bool]$ApiV2
        Uri        = $Uri
        StatusCode = $StatusCode
        Request    = Copy-DMTraceValue -Value $BodyData
        Response   = Copy-DMTraceValue -Value $Response
        Exception  = $Exception
    }

    # Depth 2 captures the exact wire representation: the JSON string actually sent, the
    # request headers (iBaseToken redacted via Copy-DMTraceValue), and the response
    # re-serialized to JSON. Only added when explicitly requested to keep depth-1 entries lean.
    if ($depth -ge 2) {
        $rawResponse = $null
        if ($null -ne $Response) {
            try { $rawResponse = $Response | ConvertTo-Json -Depth 12 } catch { $rawResponse = "$Response" }
        }
        Add-Member -InputObject $entry -MemberType NoteProperty -Name RawJsonBody -Value $RawJsonBody
        Add-Member -InputObject $entry -MemberType NoteProperty -Name RawResponse -Value $rawResponse
        Add-Member -InputObject $entry -MemberType NoteProperty -Name Headers -Value (Copy-DMTraceValue -Value $Headers)
    }

    try {
        & $traceAction.Value $entry
    }
    catch {
        Write-Verbose "DeviceManager trace action failed: $($_.Exception.Message)"
    }
}

function Format-DMTraceConsole {
    <#
    .SYNOPSIS
        Renders a single DeviceManager trace entry as a colored console block.
    .DESCRIPTION
        Called by the trace action installed by Enable-DMRequestTrace when console output is
        enabled. Kept inside Invoke-DeviceManager.ps1 (rather than a standalone Private/*.ps1)
        so it is always dot-sourced alongside the tracer without needing an entry in the
        integrity harness's private-helper whitelist.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Deliberate colored live-debug console output; suppression is controlled by Enable-DMRequestTrace -Quiet.')]
    param(
        [Parameter(Mandatory)][object]$Entry
    )

    $depthVariable = Get-Variable -Name DeviceManagerTraceDepth -Scope Script -ErrorAction SilentlyContinue
    $depth = if ($null -ne $depthVariable -and $depthVariable.Value) { [int]$depthVariable.Value } else { 1 }

    $header = "[$($Entry.Vendor)"
    if ($Entry.Hostname) { $header += " | $($Entry.Hostname)" }
    if ($Entry.Version)  { $header += " | v$($Entry.Version)" }
    $header += ']'
    if ($Entry.Step) { $header += " ($($Entry.Step))" }
    Write-Host $header -ForegroundColor Cyan

    # --- Request ---
    Write-Host " -> $($Entry.Method) $($Entry.Uri)" -ForegroundColor Yellow
    if ($depth -ge 2 -and $Entry.PSObject.Properties['RawJsonBody'] -and $Entry.RawJsonBody) {
        Write-Host " body: $($Entry.RawJsonBody)" -ForegroundColor DarkYellow
    }
    elseif ($null -ne $Entry.Request) {
        $reqJson = try { $Entry.Request | ConvertTo-Json -Depth 10 -Compress } catch { "$($Entry.Request)" }
        Write-Host " body: $reqJson" -ForegroundColor DarkYellow
    }
    if ($depth -ge 2 -and $Entry.PSObject.Properties['Headers'] -and $Entry.Headers) {
        $hdrJson = try { $Entry.Headers | ConvertTo-Json -Depth 6 -Compress } catch { "$($Entry.Headers)" }
        Write-Host " headers: $hdrJson" -ForegroundColor DarkGray
    }

    # --- Response ---
    if ($Entry.Exception) {
        Write-Host " <- HTTP $($Entry.StatusCode) FAILED ($($Entry.DurationMs) ms)" -ForegroundColor Red
        Write-Host " $($Entry.Exception)" -ForegroundColor Red
        return
    }

    # Extract the OceanStor business result defensively -- error, code and description are all
    # optional on a given response, and direct access to a missing member throws under StrictMode.
    $businessCode = $null
    $businessDesc = $null
    if ($null -ne $Entry.Response) {
        $errorProp = $Entry.Response.PSObject.Properties['error']
        if ($errorProp -and $null -ne $errorProp.Value) {
            $codeProp = $errorProp.Value.PSObject.Properties['code']
            if ($codeProp) { $businessCode = $codeProp.Value }
            $descProp = $errorProp.Value.PSObject.Properties['description']
            if ($descProp) { $businessDesc = $descProp.Value }
        }
    }
    $respHeader = " <- HTTP $($Entry.StatusCode)"
    if ($null -ne $businessCode) { $respHeader += " error.code=$businessCode" }
    $respHeader += " ($($Entry.DurationMs) ms)"
    $respColor = if ($null -ne $businessCode -and $businessCode -ne 0) { 'Red' } else { 'Green' }
    Write-Host $respHeader -ForegroundColor $respColor
    # OceanStor returns description "0" (or empty) on success -- only surface a real message.
    if ($businessDesc -and "$businessDesc" -ne '0') { Write-Host " $businessDesc" -ForegroundColor $respColor }

    if ($depth -ge 2 -and $Entry.PSObject.Properties['RawResponse'] -and $Entry.RawResponse) {
        Write-Host " response: $($Entry.RawResponse)" -ForegroundColor DarkGray
    }
    elseif ($null -ne $Entry.Response) {
        $respJson = try { $Entry.Response | ConvertTo-Json -Depth 10 -Compress } catch { "$($Entry.Response)" }
        Write-Host " response: $respJson" -ForegroundColor DarkGray
    }
}

function Invoke-DeviceManager{
    <#
    .SYNOPSIS
        Invokes a the Huawei Oceanstor Rest API
 
    .DESCRIPTION
        Function to to invoke the Huawei Oceanstor REST API, by method "GET","PUT","POST","DELETE" for any of the resources.
 
    .PARAMETER webSession
        Optional parameter to define the session to be use on the REST call. If not defined, the module-scoped $script:CurrentOceanstorSession variable will be used
    .PARAMETER method
        Mandatory parameter to define the REST call method to be used. Acceptable Values "GET","PUT","POST","DELETE"
    .PARAMETER resource
        Mandatory parameter to define the resource to be invoke. Any resource defines by Huawei Oceanstor REST API is acceptable
 
    .INPUTS
 
    .OUTPUTS
        returns the results of Huawei Oceanstor REST API call
 
    .EXAMPLE
 
        PS C:\> Invoke-DeviceManager -webSession $session -method "GET" -resource "lun"
 
        OR
 
        PS C:\> $hosts = Invoke-DeviceManager -method "GET" -resource "host"
 
    .NOTES
        Filename: Invoke-DeviceManager.ps1
        Author: Joao Carmona
        Modified date: 2022-05-22
        Version 0.2
 
    .LINK
    #>

    [Cmdletbinding()]
    Param(
    [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Position=0,Mandatory=$false)]
        [pscustomobject]$WebSession,
    [Parameter(Position=1,Mandatory=$true)]
        [ValidateSet("GET","POST","PUT","DELETE")]
        [string]$Method,
    [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Position=2,Mandatory=$true)]
        [String]$Resource,
    [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Mandatory=$false)]
        [object]$BodyData,
    [Parameter(Mandatory=$false)]
        [ValidateRange(1, 3600)]
        [int]$TimeoutSec,
    [Parameter(Mandatory=$false)]
        [switch]$ApiV2
    )

    if ($WebSession){
        $session = $WebSession
    } else {
        $session = $script:CurrentOceanstorSession
    }

    if ($null -eq $session) {
        throw 'No OceanStor session available. Call Connect-deviceManager first, or pass a session via -WebSession.'
    }

    if ($ApiV2) {
        $RestURI = "https://$($session.hostname):8088/api/v2/$resource"
    } else {
        $RestURI = "https://$($session.hostname):8088/deviceManager/rest/$($session.DeviceId)/$resource"
    }

    $startedAt = Get-Date
    $JsonBody = $null
    $dmStatus = $null
    try {
        # Preserve the certificate validation choice made when the session was created.
        $invokeParams = @{
            Method      = $Method
            Uri         = $RestURI
            Headers     = $session.Headers
            WebSession  = $session.WebSession
            ContentType = 'application/json'
        }
        if ($PSBoundParameters.ContainsKey('TimeoutSec')) {
            $invokeParams.TimeoutSec = $TimeoutSec
        }
        if ($session.SkipCertificateCheck) {
            $invokeParams.SkipCertificateCheck = $true
        }

        if ($BodyData){
            $JsonBody = ConvertTo-Json $BodyData -Depth 10
            $invokeParams.Body = $JsonBody
        }

        # StatusCodeVariable captures the real HTTP status but only exists on PS 7.4+ (the
        # module targets PS 6.0). Add it only to the Invoke-RestMethod call via a cloned splat
        # so the Invoke-WebRequest fallback below, which reuses $invokeParams, is never handed a
        # parameter it may not support. On hosts without it, success falls back to HTTP 200 --
        # OceanStor reports real failures via the body's error.code, not the HTTP status.
        $restParams = $invokeParams
        if ((Get-Command Invoke-RestMethod).Parameters.ContainsKey('StatusCodeVariable')) {
            $restParams = $invokeParams.Clone()
            $restParams.StatusCodeVariable = 'dmStatus'
        }
        $result = Invoke-RestMethod @restParams

        # Some PS7 builds fall back to returning the raw JSON string instead of throwing when
        # the response body contains case-conflicting duplicate keys (e.g. 'snapType'/'SNAPTYPE'
        # on the fssnapshot endpoint). Convert to PSCustomObject using the case-aware helper so
        # callers always receive a consistent object type.
        if ($result -is [string] -and $result) {
            try {
                $result = ConvertFrom-DMCasedHashtable ($result | ConvertFrom-Json -AsHashtable)
            } catch {
                Write-Verbose "String response from '$RestURI' could not be re-parsed: $($_.Exception.Message)"
            }
        }

        $successStatus = if ($dmStatus) { [int]$dmStatus } else { 200 }
        Write-DMRequestTrace -StartedAt $startedAt -Method $Method -Resource $Resource -Uri $RestURI `
            -BodyData $BodyData -ApiV2:$ApiV2 -Response $result `
            -Session $session -StatusCode $successStatus -RawJsonBody $JsonBody -Headers $session.Headers
        return $result
    }
    catch {
        $originalError = $_
        # Some OceanStor endpoints return JSON bodies with the same key in different cases
        # (e.g. "snapType" and "SNAPTYPE"). Invoke-RestMethod cannot parse these; fall back
        # to Invoke-WebRequest + ConvertFrom-Json -AsHashtable, which uses a case-sensitive
        # hashtable, then normalize to a PSCustomObject preferring the uppercase key.
        # PS7 may surface the duplicate-key parse error in different ways depending on version.
        # Check the FullyQualifiedErrorId (most stable), the exception message, and the full
        # ErrorRecord string representation as a belt-and-suspenders guard.
        $isDuplicateKeyParseError = (
            $_.FullyQualifiedErrorId -like '*CannotConvertContent*' -or
            $_.Exception.Message     -like '*keys with different casing*' -or
            "$_"                     -like '*keys with different casing*'
        )
        if ($isDuplicateKeyParseError) {
            try {
                $rawResponse = Invoke-WebRequest @invokeParams
                $result = ConvertFrom-DMCasedHashtable ($rawResponse.Content | ConvertFrom-Json -AsHashtable)
                $fallbackStatus = if ($rawResponse.PSObject.Properties['StatusCode']) { [int]$rawResponse.StatusCode } else { $null }
                Write-DMRequestTrace -StartedAt $startedAt -Method $Method -Resource $Resource -Uri $RestURI `
                    -BodyData $BodyData -ApiV2:$ApiV2 -Response $result `
                    -Session $session -StatusCode $fallbackStatus -RawJsonBody $JsonBody -Headers $session.Headers
                return $result
            } catch {
                Write-Verbose "Invoke-WebRequest fallback also failed for '$RestURI': $($_.Exception.Message)"
            }
        }
        # Extract the HTTP status defensively: many exception types (e.g. ArgumentException)
        # have no Response member, which throws under Set-StrictMode if accessed directly.
        $httpStatus = $null
        $responseProp = $originalError.Exception.PSObject.Properties['Response']
        if ($responseProp -and $responseProp.Value) {
            $statusProp = $responseProp.Value.PSObject.Properties['StatusCode']
            if ($statusProp) { try { $httpStatus = [int]$statusProp.Value } catch { $httpStatus = $null } }
        }
        Write-DMRequestTrace -StartedAt $startedAt -Method $Method -Resource $Resource -Uri $RestURI `
            -BodyData $BodyData -ApiV2:$ApiV2 -Exception $originalError.Exception.Message `
            -Session $session -StatusCode $httpStatus -RawJsonBody $JsonBody -Headers $session.Headers
        throw $originalError
    }
}