Private/Deployment.ps1

# What the hand-written deployment cmdlets share: reading a template and its parameters, the what-if text,
# refusing an invalid template with its diagnostics, and running a deployment (in the foreground or as a job).

$script:VMetricTemplateMaxBytes = 1MB

function Get-VMetricTemplateText {
    # The template's text: -Template as given, or -TemplateFile read as UTF-8. At most 1 MiB, as the API allows.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.PSCmdlet] $Cmdlet,

        [AllowNull()]
        [AllowEmptyString()]
        [string] $TemplateFile,

        [AllowNull()]
        [AllowEmptyString()]
        [string] $Template
    )

    if ($TemplateFile) {
        $path = $Cmdlet.GetUnresolvedProviderPathFromPSPath($TemplateFile)
        if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
            throw (New-VMetricException -Message "The template file '$TemplateFile' was not found." -Code 'TemplateFileNotFound' -Category ObjectNotFound)
        }
        $text = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
    }
    else {
        $text = [string]$Template
    }
    if ([string]::IsNullOrWhiteSpace($text)) {
        throw (New-VMetricException -Message 'The template is empty.' -Code 'TemplateEmpty' -Category InvalidArgument)
    }
    if ([System.Text.Encoding]::UTF8.GetByteCount($text) -gt $script:VMetricTemplateMaxBytes) {
        throw (New-VMetricException -Message 'The template is larger than 1 MiB, the most a deployment takes.' -Code 'TemplateTooLarge' -Category LimitsExceeded)
    }
    $text
}

function Get-VMetricTemplateParameter {
    # The deployment's parameters: -TemplateParameterFile first (a deployment parameters file,
    # {"parameters": {"name": {"value": ...}}}, or a plain JSON object), then -TemplateParameterObject over it.
    # SecureStrings stay SecureStrings until the request is serialized.
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.PSCmdlet] $Cmdlet,

        [AllowNull()]
        [System.Collections.IDictionary] $ParameterObject,

        [AllowNull()]
        [AllowEmptyString()]
        [string] $ParameterFile
    )

    $parameters = [ordered]@{}
    if ($ParameterFile) {
        $path = $Cmdlet.GetUnresolvedProviderPathFromPSPath($ParameterFile)
        if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
            throw (New-VMetricException -Message "The parameters file '$ParameterFile' was not found." -Code 'ParameterFileNotFound' -Category ObjectNotFound)
        }
        if ($path.EndsWith('.bicepparam', [System.StringComparison]::OrdinalIgnoreCase)) {
            throw (New-VMetricException -Message 'Bicep parameter files (.bicepparam) are not supported; give a JSON parameters file or -TemplateParameterObject.' `
                    -Code 'ParameterFileUnsupported' -Category InvalidArgument)
        }
        $content = ConvertFrom-VMetricJson -Json ([System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)) -AsHashtable
        if ($content -isnot [System.Collections.IDictionary]) {
            throw (New-VMetricException -Message "The parameters file '$ParameterFile' does not hold a JSON object." -Code 'ParameterFileInvalid' -Category InvalidData)
        }
        $source = $content
        $wrapped = Get-VMetricMember -InputObject $content -Name 'parameters'
        if ($wrapped -is [System.Collections.IDictionary]) {
            $source = [ordered]@{}
            foreach ($key in $wrapped.Keys) {
                $entry = $wrapped[$key]
                if ($entry -is [System.Collections.IDictionary] -and (Get-VMetricDictionaryKey -Dictionary $entry -Name 'value')) {
                    $source[$key] = $entry[(Get-VMetricDictionaryKey -Dictionary $entry -Name 'value')]
                }
                elseif ($entry -is [System.Collections.IDictionary] -and (Get-VMetricDictionaryKey -Dictionary $entry -Name 'reference')) {
                    throw (New-VMetricException -Message "Parameter '$key' is a Key Vault reference, which a VirtualMetric deployment cannot resolve; give its value." `
                            -Code 'ParameterFileUnsupported' -Category InvalidArgument)
                }
                else {
                    $source[$key] = $entry
                }
            }
        }
        foreach ($key in $source.Keys) {
            if ($key -notin @('$schema', 'contentVersion')) {
                $parameters[$key] = $source[$key]
            }
        }
    }
    if ($ParameterObject) {
        foreach ($key in $ParameterObject.Keys) {
            $existing = Get-VMetricDictionaryKey -Dictionary $parameters -Name ([string]$key)
            if ($existing) {
                $parameters.Remove($existing)
            }
            $parameters[[string]$key] = $ParameterObject[$key]
        }
    }
    $parameters
}

function Assert-VMetricTemplateValid {
    # Refuses a template the engine finds invalid, with every error diagnostic in the message; writes its
    # warnings. Answers the validate result.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.PSCmdlet] $Cmdlet,

        [Parameter(Mandatory)]
        [string] $Template,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Parameters,

        [System.Collections.IDictionary] $Session,

        # The text of the secure parameters, masked in what is written.
        [AllowNull()]
        [string[]] $Secret
    )

    $request = @{ Method = 'POST'; Path = '/deployments/validate'; Body = [ordered]@{ template = $Template; parameters = $Parameters } }
    if ($Session) {
        $request.Session = $Session
    }
    $result = Invoke-VMetricRequest @request
    $errors = [System.Collections.Generic.List[string]]::new()
    foreach ($diagnostic in (ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $result -Name 'diagnostics'))) {
        $severity = [string](Get-VMetricMember -InputObject $diagnostic -Name 'severity')
        $text = Hide-VMetricSecretText -Text (Format-VMetricDiagnosticText -Diagnostic $diagnostic) -Secret $Secret
        if ($severity -eq 'error') {
            $errors.Add($text)
        }
        elseif ($severity -eq 'warning') {
            $Cmdlet.WriteWarning($text)
        }
    }
    if (-not [bool](Get-VMetricMember -InputObject $result -Name 'valid') -or $errors.Count -gt 0) {
        $lines = @('The template is invalid:') + @($errors | ForEach-Object { " $_" })
        $details = $result
        if ($Secret) {
            $details = $null
        }
        throw (New-VMetricException -Message ($lines -join [Environment]::NewLine) -Code 'DEPLOYMENT_TEMPLATE_INVALID' `
                -Category InvalidData -Details $details)
    }
    $result
}

function Format-VMetricWhatIfText {
    # A what-if as the -WhatIf and -Confirm text shows it, one line per resource and one per property change:
    # + create VirtualMetric/devices 'syslog-udp'
    # ~ modify VirtualMetric/targets 'sentinel'
    # properties.port: 514 -> 1514
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [AllowNull()]
        [object] $Result,

        [AllowNull()]
        [string[]] $Secret
    )

    $arrow = [string][char]0x2192
    $symbols = @{ create = '+'; modify = '~'; noChange = '='; read = '>'; action = '!'; ignore = '*'; unknown = '?' }
    $counts = [ordered]@{}
    $lines = [System.Collections.Generic.List[string]]::new()
    foreach ($change in (ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $Result -Name 'changes'))) {
        $changeType = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $change -Name 'changeType'))
        $symbol = $symbols[$changeType]
        if (-not $symbol) {
            $symbol = '?'
        }
        $counts[$changeType] = 1 + [int]$counts[$changeType]
        $type = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $change -Name 'type'))
        $name = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $change -Name 'name'))
        if (-not $name) {
            $name = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $change -Name 'symbolicName'))
        }
        $lines.Add(('{0} {1,-9} {2} ''{3}''' -f $symbol, $changeType, $type, $name))
        foreach ($delta in (ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $change -Name 'delta'))) {
            $path = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $delta -Name 'path'))
            # Masked before encoding, so a secret JSON would escape, or a short one, is masked too.
            $before = Format-VMetricValue -InputObject (Hide-VMetricSecretValue -InputObject (Get-VMetricMember -InputObject $delta -Name 'before') -Secret $Secret)
            $after = Format-VMetricValue -InputObject (Hide-VMetricSecretValue -InputObject (Get-VMetricMember -InputObject $delta -Name 'after') -Secret $Secret)
            $lines.Add(" ${path}: $before $arrow $after")
        }
        $failure = [string](Get-VMetricMember -InputObject $change -Name 'error')
        if ($failure) {
            $lines.Add(" error: $(ConvertTo-VMetricSafeText -Text $failure)")
        }
    }
    $words = @{ create = 'to create'; modify = 'to modify'; noChange = 'unchanged'; read = 'read'; action = 'actions'; ignore = 'ignored'; unknown = 'unknown' }
    $summary = foreach ($key in $counts.Keys) {
        $word = $words[$key]
        if (-not $word) {
            $word = $key
        }
        "$($counts[$key]) $word"
    }
    if ($lines.Count -eq 0) {
        $lines.Add('No resources.')
    }
    else {
        $lines.Add('Changes: ' + (@($summary) -join ', ') + '.')
    }
    $masked = foreach ($line in $lines) {
        Hide-VMetricSecretText -Text $line -Secret $Secret
    }
    return , [string[]]@($masked)
}

function Invoke-VMetricDeploymentRun {
    # POST /deployments, then (unless -NoWait) poll until it ends. Answers the raw deployment.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session,

        [AllowNull()]
        [AllowEmptyString()]
        [string] $Name,

        [Parameter(Mandatory)]
        [string] $Template,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Parameters,

        [switch] $NoWait
    )

    $body = [ordered]@{}
    if ($Name) {
        $body['name'] = $Name
    }
    $body['template'] = $Template
    $body['parameters'] = $Parameters
    $deployment = Invoke-VMetricRequest -Method POST -Path '/deployments' -Body $body -Session $Session
    if ($NoWait) {
        return $deployment
    }
    try {
        Wait-VMetricDeploymentCompletion -Deployment $deployment -Session $Session
    }
    catch {
        if (Test-VMetricFlowControl -ErrorRecord $_) {
            throw
        }
        # The deployment goes on on the server whatever stopped the polling here (the job's session ended, the
        # network failed): say how to follow it.
        $inner = $_.Exception
        $id = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $deployment -Name 'id'))
        if (-not $id -or ($inner -is [VMetricException] -and $inner.Code -eq 'DeploymentStillRunning')) {
            throw
        }
        $name = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $deployment -Name 'name'))
        $code = 'DeploymentNotFollowed'
        $category = [System.Management.Automation.ErrorCategory]::NotSpecified
        $terminating = $false
        if ($inner -is [VMetricException]) {
            $code = $inner.Code
            $category = $inner.Category
            $terminating = $inner.IsTerminating
        }
        throw (New-VMetricException -Message "$($inner.Message) Deployment '$name' ($id) goes on running on the server: follow it with Get-VMetricDeployment -Id $id." `
                -Code $code -Category $category -InnerException $inner -Terminating:$terminating)
    }
}

function Write-VMetricDeploymentOutcome {
    # After a deployment: the one-time outputs warning for each operation that has them, and an error when the
    # deployment did not succeed (the object has already been written).
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.PSCmdlet] $Cmdlet,

        [Parameter(Mandatory)]
        [object] $Deployment,

        # The text of the secure parameters, masked in the error.
        [AllowNull()]
        [string[]] $Secret
    )

    $name = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $Deployment -Name 'name'))
    $status = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $Deployment -Name 'status'))
    $operations = ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $Deployment -Name 'operations')
    for ($index = 0; $index -lt $operations.Count; $index++) {
        $operation = $operations[$index]
        $names = [System.Collections.Generic.List[string]]::new()
        foreach ($entry in (Get-VMetricEntryList -InputObject (Get-VMetricMember -InputObject $operation -Name 'outputs'))) {
            $names.Add((ConvertTo-VMetricSafeText -Text $entry.Name))
        }
        if ($names.Count -gt 0) {
            $label = "$(ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operation -Name 'type'))) '$(ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operation -Name 'name')))'"
            $Cmdlet.WriteWarning("$label returned $($names -join ' and ') (in operations[$index].outputs): shown once; copy it now. It cannot be shown again.")
        }
    }
    if ($status -in @('succeeded', 'running')) {
        return
    }

    $code = 'DEPLOYMENT_FAILED'
    $message = "The deployment ended $status."
    $failure = Get-VMetricMember -InputObject $Deployment -Name 'error'
    if ($failure) {
        $failureCode = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $failure -Name 'code'))
        $failureMessage = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $failure -Name 'message'))
        if ($failureCode) {
            $code = $failureCode
        }
        if ($failureMessage) {
            $message = $failureMessage
        }
    }
    foreach ($operation in $operations) {
        $operationError = Get-VMetricMember -InputObject $operation -Name 'error'
        if ((Get-VMetricMember -InputObject $operation -Name 'status') -eq 'failed' -and $operationError) {
            $message += [Environment]::NewLine + (" {0} '{1}': {2}: {3}" -f (ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operation -Name 'type'))),
                (ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operation -Name 'name'))),
                (ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operationError -Name 'code'))),
                (ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operationError -Name 'message'))))
        }
    }
    $category = if ($status -eq 'canceled') { 'OperationStopped' } else { 'InvalidOperation' }
    $message = Hide-VMetricSecretText -Text $message -Secret $Secret
    $exception = New-VMetricException -Message "Deployment '$name' $($status): $message ($code)" -Code $code -Category $category
    $Cmdlet.WriteError((New-VMetricErrorRecord -Exception $exception -TargetObject $Deployment))
}

function Resolve-VMetricExportReference {
    # The {type, id} an object from the pipeline stands for: a catalog type's object (its PSTypeName names the
    # resource type through the catalog), a deployment operation (type, resourceId), or anything with
    # resourceType and id (the GET answers of the newer public modules).
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(Mandatory)]
        [object] $InputObject
    )

    $typeNames = $InputObject.psobject.TypeNames
    if ($typeNames -contains 'VirtualMetric.DeploymentOperation') {
        $type = [string](Get-VMetricMember -InputObject $InputObject -Name 'type')
        $resourceId = [string](Get-VMetricMember -InputObject $InputObject -Name 'resourceId')
        if ($type -and $resourceId) {
            return [ordered]@{ type = $type; id = $resourceId }
        }
    }
    else {
        $declared = [string](Get-VMetricMember -InputObject $InputObject -Name 'resourceType')
        $id = [string](Get-VMetricMember -InputObject $InputObject -Name 'id')
        if ($declared -like 'VirtualMetric/*' -and $id) {
            return [ordered]@{ type = $declared; id = $id }
        }
        $catalog = Get-VMetricCatalog
        foreach ($typeName in $typeNames) {
            $resourceType = $null
            if ($catalog.ResourceTypes.TryGetValue($typeName, [ref] $resourceType)) {
                $id = [string](Get-VMetricMember -InputObject $InputObject -Name (Get-VMetricTypeField -TypeName $typeName -Field 'idField'))
                if ($id) {
                    return [ordered]@{ type = $resourceType; id = $id }
                }
            }
        }
    }
    throw (New-VMetricException -Message 'Cannot tell which resource an input object is. Pipe objects from the Get-VMetric cmdlets or deployment operations, or use -Type and -Id.' `
            -Code 'UnknownResource' -Category InvalidArgument)
}

$script:VMetricJobTokenSeconds = 50 * 60

function Start-VMetricDeploymentJob {
    # New-VMetricDeployment -AsJob: the deployment in a thread job that imports this module. The job gets a view
    # of the session, never a token of its own (New-VMetricJobSession): it reads the session's current access
    # token for every request and never refreshes, so the single-use refresh token is spent in this runspace
    # only (see Private/Session.ps1). A refresh here reaches the job; and since nothing may refresh while the
    # job runs, the token is refreshed first when less than 50 minutes are left, which outlasts the job's
    # 45 minutes of polling. Receive-Job gives the VirtualMetric.Deployment.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'New-VMetricDeployment asks ShouldProcess before it calls this.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '', Justification = 'The job block takes its values through -ArgumentList into its own param() block.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session,

        [AllowNull()]
        [AllowEmptyString()]
        [string] $Name,

        [Parameter(Mandatory)]
        [string] $Template,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Parameters,

        [string] $JobName
    )

    if (-not (Get-Command -Name 'Start-ThreadJob' -ErrorAction SilentlyContinue)) {
        throw (New-VMetricException -Message '-AsJob needs Start-ThreadJob, which ships with PowerShell 7 (the ThreadJob module). Install-Module ThreadJob, or run without -AsJob.' `
                -Code 'ThreadJobUnavailable' -Category NotInstalled)
    }
    if ($Session.RefreshToken) {
        Update-VMetricAccessToken -Session $Session -WithinSeconds $script:VMetricJobTokenSeconds
    }
    $view = New-VMetricJobSession -Session $Session
    $manifest = Join-Path -Path $script:ModuleRoot -ChildPath 'VirtualMetric.psd1'
    $request = [pscustomobject]@{ Name = $Name; Template = $Template; Parameters = $Parameters }
    if (-not $JobName) {
        $JobName = if ($Name) { "VMetricDeployment $Name" } else { 'VMetricDeployment' }
    }
    Start-ThreadJob -Name $JobName -ArgumentList $manifest, $view, $request -ScriptBlock {
        param($Manifest, $Snapshot, $Request)

        $module = Import-Module -Name $Manifest -PassThru -ErrorAction Stop
        & $module {
            param($JobSession, $JobRequest)
            Invoke-VMetricDeploymentJob -Session $JobSession -Request $JobRequest
        } $Snapshot $Request
    }
}

function Invoke-VMetricDeploymentJob {
    # Runs inside the thread job: the deployment, then the VirtualMetric.Deployment, then any failure as an error.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session,

        [Parameter(Mandatory)]
        [object] $Request
    )

    Set-VMetricCurrentSession -Session $Session
    $secrets = Get-VMetricSecretText -InputObject $Request.Parameters
    $raw = Invoke-VMetricDeploymentRun -Session $Session -Name $Request.Name -Template $Request.Template -Parameters $Request.Parameters
    $deployment = ConvertTo-VMetricDeploymentObject -InputObject $raw
    Hide-VMetricSecretInObject -InputObject $deployment -Secret $secrets
    $deployment
    Write-VMetricDeploymentOutcome -Cmdlet $PSCmdlet -Deployment $deployment -Secret $secrets
}