lib/SubjectTasks.ps1

function Invoke-SubjectTaskActionParallel {
    <#
    .SYNOPSIS
    Provides the Actionrequest to the TMConsole PowerShell Session Manager for invocation.
 
    .DESCRIPTION
    This function will collect the ActionRequest object from TransitionManager, and will send it to SessionManager for invocation.
 
    .PARAMETER TMSession
    The name of the TMSession that the Broker and Subject belong to
 
    .PARAMETER Subject
    The TMBrokerSubject object representing a Broker's Subject Task
 
    .PARAMETER Cache
    The Broker's cache if available
 
    .EXAMPLE
    Invoke-SubjectTaskActionParallel -TMSession 'Broker' -Subject $Broker.Subjects[0][1] -Cache $Broker.Cache
 
    .OUTPUTS
    None
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 0)]
        [Object]$TMSession = 'Default',

        [Parameter(Mandatory = $true)]
        [TMBrokerSubject]$Subject,

        [Parameter(Mandatory = $false)]
        [Object]$Cache
    )

    if ($TMSession -is [String]) {
        $TMSession = $global:TMSessions[$TMSession]
        if (-not $TMSession) {
            throw "TMSession '$TMSession' not found. Check name or provide a [TMSession] object."
        }
    }

    if (-not ($TMSession -is [TMSession])) {
        throw "The value for the TMSession parameter must be of type [String] or [TMSession]"
    }

    # Tell TM that the Task's Action is starting and receive an ActionRequest with Params
    $Subject.ActionRequest = Start-TMTaskAction -TMSession $TMSession.Name -TaskId $Subject.Task.Id -Force -ErrorAction 'SilentlyContinue'

    # An Error invoking the task may have been handled, and no SubjectActionRequest was returned.
    if (-not $Subject.ActionRequest) {
        $Subject.Action.ExecutionStatus = 'Started'
        return
    }

    # Check the localsettings file to see if this action needs to be logged
    try {
        $LocalSettingsFilePath = Join-Path $Env:APPDATA 'tmconsole' 'localsettings'
        $LocalSettings = Get-Content -LiteralPath $LocalSettingsFilePath -Raw | ConvertFrom-Json
    } catch {
        $LocalSettings = $null
        Write-Warning "Could not read local settings: $($_.Exception.Message)"
    }

    # Create a hashtable to hold all of the NoteProperty objects that will be used with Add-Member.
    # These are all properties that would normally be added by electron/TMC
    $PropertiesToAdd = @{
        # The type will be used by TMC's Write-Host handler to know that it is an Action Request to be invoked
        Type          = 'ActionRequest'

        # Add the Broker ID to the Action Request so its output can be interpered by TMConsole
        BrokerId      = "TMTaskID_$($Broker.Task.Id)"

        # Add the log path if it was configured in the local settings
        logPath       = $LocalSettings.logging.enabled ? $LocalSettings.logging.path : $null

        # Add the User account to the Action Request to be used when importing the session
        userAccount   = $global:ActionRequest.userAccount ?? $TMSession.UserAccount

        # Add the TM User Session to the Action Request so that the default TMSession can be created
        tmUserSession = $global:ActionRequest.TMUserSession ?? @{
            url         = "https://$($TMSession.TMServer)"
            tmVersion   = $TMSession.TMVersion.ToString()
            userContext = $TMSession.UserContext
            jsessionid  = $TMSession.Authentication.JSessionId
            csrf        = @{
                tokenHeaderName = $TMSession.Authentication.CsrfHeaderName
                token           = $TMSession.Authentication.CsrfToken
            }
            reporting   = @{
                ssoToken = $TMSession.Authentication.SsoToken
            }
        }
    }

    # Add all of the properties to the Action Request
    $Subject.ActionRequest | Add-Member -Force -NotePropertyMembers $PropertiesToAdd

    # Pass the TMSession's auth tokens along with the action request
    $Subject.ActionRequest.options.callback | Add-Member -Force -NotePropertyMembers @{
        refreshToken      = $TMSession.Authentication.OAuth.RefreshToken
        expirationSeconds = $TMSession.Authentication.OAuth.ExpirationSeconds
        grantedDate       = $TMSession.Authentication.OAuth.GrantedUnixTime
    }

    # Notify invocation of the next task
    Write-Host "[$(Get-Date -Format "HH:mm:ss.ffff")] Queueing Parallel Task: " -NoNewline
    Write-Host "#$($Subject.Task.TaskNumber)" -NoNewline -ForegroundColor Cyan
    Write-Host ', Title: ' -NoNewline
    Write-Host $Subject.Task.Title -ForegroundColor Yellow

    # Create a TMC message to be forwarded to SessionManager by the Write-Host handler
    $QueueActionRequestString = "||TMC:$($Subject.ActionRequest | ConvertTo-Json -Depth 10 -Compress)"

    # Record the time that this Action was invoked
    $Subject.Action.InvokedAt = Get-Date

    # The following command will execute 'Normally' in debug mode, but when run in a TMC runspace, it has a special handling,
    # and will result in the ActionRequest object being sent for invocation
    Write-Host $QueueActionRequestString
}


function Invoke-SubjectTaskAction {
    <#
    .SYNOPSIS
    Invokes the Action associated with a Broker's Subject Task
 
    .DESCRIPTION
    This function will invoke the PowerShell Action associated with a Broker's Subject Task
 
    .PARAMETER TMSession
    The name of the TMSession that the Broker and Subject belong to
 
    .PARAMETER Subject
    The TMBrokerSubject object representing a Broker's Subject Task
 
    .PARAMETER Cache
    The Broker's cache if available
 
    .EXAMPLE
    Invoke-SubjectTaskAction -TMSession 'Broker' -Subject $Broker.Subjects[0][1] -Cache $Broker.Cache
 
    .OUTPUTS
    None
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 0)]
        [Object]$TMSession = 'Default',

        [Parameter(Mandatory = $true)]
        [TMBrokerSubject]$Subject,

        [Parameter(Mandatory = $false)]
        [Object]$Cache
    )


    if ($TMSession -is [String]) {
        $TMSession = $global:TMSessions[$TMSession]
        if (-not $TMSession) {
            throw "TMSession '$TMSession' not found. Check name or provide a [TMSession] object."
        }
    }

    if (-not ($TMSession -is [TMSession])) {
        throw "The value for the TMSession parameter must be of type [String] or [TMSession]"
    }

    # Tell TM that the Task's Action is starting and receive an ActionRequest with Params
    $Subject.ActionRequest = Start-TMTaskAction -TMSession $TMSession.Name -TaskId $Subject.Task.Id -Force -ErrorAction 'SilentlyContinue'

    # An Error ivoking the task may have been handled, and no SubjectActionRequest was returned.
    if (-not $Subject.ActionRequest) {
        $Subject.Action.ExecutionStatus = 'Started'
        return
    }

    # Add the Broker ID to the ActionRequest so its output can be interpered by TMConsole
    $Subject.ActionRequest | Add-Member -NotePropertyName 'BrokerId' -NotePropertyValue "TMTaskID_$($Broker.Task.Id)" -Force

    #
    # Invoke the Action in this runspace
    #
    $InvocationRunspaceScriptBlock = [scriptblock] {
        param([PSCustomObject]$ActionRequest, [TMSession]$TMSession)
        try {
            # Complete the root activity to provide a consistent experience for all tasks
            $StartingProgressActivity = @{
                Id               = 0
                ParentId         = -1
                Activity         = 'Broker is Running Task: ' + $ActionRequest.task.taskNumber + ' - ' + $ActionRequest.task.title
                PercentComplete  = 5
                SecondsRemaining = -1
                Completed        = $False
            }
            Write-Progress @StartingProgressActivity

            # Preserve the Broker's Parameters
            $BrokerParams = $Params
            $BrokerTM = $TM

            # Import the ActionRequest to create necessary objects in the pipeline
            . Import-TMCSubjectActionRequest -ActionRequest $ActionRequest -BrokerTaskId 1 -TMSession $TMSession

            # Start logging if required
            if ($ActionRequest.logPath) {

                # Trim any quote characters from the Log Path
                $RootLogPath = $ActionRequest.logPath.trim('"').trim("'")
                Write-Verbose "Creating Logging Folder at: $RootLogPath"
                Test-FolderPath -FolderPath $RootLogPath

                # Create the Transcript Folder Path
                $ProjectFolder = Join-Path $RootLogPath ($Global:TM.Server.Url -replace '.transitionmanager.net', '') $Global:TM.Project.Name $Global:TM.Event.Name
                Test-FolderPath -FolderPath $ProjectFolder
                Write-Verbose "Transcript Folder: $($ProjectFolder)"

                # Create a unique file in the Transcript Folder
                $TranscriptFileName = (
                    (Get-Date -Format FileDateTimeUniversal) +
                    '_TaskNumber-' + $Global:TM.Task.TaskNumber +
                    '_TaskId-' + $Global:TM.Task.Id + '.txt'
                )
                Write-Verbose "File Name: $TranscriptFileName"
                $TranscriptFilePath = Join-Path $ProjectFolder $TranscriptFileName

                # Start a transcript for this session
                $TranscriptSplat = @{
                    Path                    = $TranscriptFilePath
                    IncludeInvocationHeader = $True
                    Confirm                 = $False
                    Force                   = $True
                    Append                  = $True
                }
                Start-Transcript @TranscriptSplat

                # Write a verbose message for transcript
                Write-Verbose "Invoking TransitionManager ActionRequest at: $(Get-Date)"
                Write-Verbose ($Global:TM | ConvertTo-Json -Depth 100)

                # Write a verbose message for transcript
                Write-Verbose 'Action Request Parameters'
                Write-Verbose ($Params | ConvertTo-Json -Depth 100)
            }

            # Invoke the Action's Script block
            $ActionScriptBlock = [scriptblock]::Create($ActionRequest.options.apiAction.script)
            Invoke-Command -ScriptBlock $ActionScriptBlock -ErrorAction 'Stop' -NoNewScope

            # Create a Data Options parameter for the Complete-TMTask command
            $CompleteTaskParameters = @{}

            # Check the Global Variable for any TMAssetUpdates to send to TransitionManager during the task completion
            if ($Global:TMAssetUpdates) {
                $CompleteTaskParameters = @{
                    Data = @{
                        assetUpdates = $Global:TMAssetUpdates
                    }
                }
                # Clear the Global variable now that it's assigned into the respsonse belonging
                # To the subject task. Otherwise, the next subject gets the same data
                Remove-Variable -Name 'TMAssetUpdates' -Scope Global
            }

            # Add SSL Exception
            $CompleteTaskParameters | Add-Member -NotePropertyName 'AllowInsecureSSL' -NotePropertyValue $TMSession.AllowInsecureSSL -Force

            # Complete the TM Task, sending Updated Data values for the task Asset
            if ($ActionRequest.HostPID -ne 0) {
                Complete-TMTask -ActionRequest $ActionRequest @CompleteTaskParameters
            }

            # Complete the root activity to provide a consistent experience for all tasks
            $CompleteProgressActivity = @{
                Id               = 0
                ParentId         = -1
                Activity         = 'Task Complete: ' + $ActionRequest.task.taskNumber + ' - ' + $ActionRequest.task.title
                PercentComplete  = 100
                SecondsRemaining = -1
                Completed        = $True
            }
            Write-Progress @CompleteProgressActivity

            $Subject.Action.ExecutionStatus = 'Successful'
        } catch {

            # Get the Exception message
            $ExceptionMessage = $_.Exception.Message

            # Send the Error Message (Only send if TMD started the process)
            if ($ActionRequest.HostPID -ne 0) {
                Set-TMTaskOnHold -ActionRequest $ActionRequest -Message ('Action Error: ' + $ExceptionMessage)
            }

            # Throw the full error message
            Write-Host $_.Exception.Message -ForegroundColor Red

            $Subject.Action.ExecutionStatus = 'Failed'
            $Subject.Action.Errors = @($ExceptionMessage)
        }

        if ($ActionRequest.logPath) {
            Stop-Transcript
        }

        # Return the Broker Params and TM scope
        New-Variable -Name Params -Scope Global -Value $BrokerParams -Force
        New-Variable -Name TM -Scope Global -Value $BrokerTM -Force
    }

    # Notify invocation of the next task
    Write-Host "[$(Get-Date -Format "HH:mm:ss.ffff")] Invoking Task: " -NoNewline
    Write-Host "#$($Subject.Task.TaskNumber)" -NoNewline -ForegroundColor Cyan
    Write-Host ', Title: ' -NoNewline
    Write-Host $Subject.Task.Title -ForegroundColor Yellow

    # Run the Script Block
    $RunningTime = Measure-Command {
        $InvokeSplat = @{
            ScriptBlock  = $InvocationRunspaceScriptBlock
            ArgumentList = @($Subject.ActionRequest, $TMSession)
            NoNewScope   = $true
        }
        $Subject.Action.InvokedAt = Get-Date
        Invoke-Command @InvokeSplat
    }
    $RuntimeSeconds = [Math]::Ceiling($RunningTime.TotalSeconds)

    # Produce Broker output to finish the line of output
    if ($Subject.Action.ExecutionStatus -eq 'Failed') {
        Write-Host "[$(Get-Date -Format "HH:mm:ss.ffff")] Action completed with errors after running for " -NoNewline -ForegroundColor Red
        Write-Host $RuntimeSeconds -NoNewline -ForegroundColor Red
        Write-Host " seconds. Error: $($Subject.Action.Errors -join ', ')" -ForegroundColor Red
    } else {
        Write-Host "[$(Get-Date -Format "HH:mm:ss.ffff")] Action completed successfully in: " -NoNewline
        Write-Host $RuntimeSeconds -NoNewline -ForegroundColor Cyan
        Write-Host ' seconds'
    }
}


function Import-TMCSubjectActionRequest {
    param(
        [Parameter(Mandatory = $true)][PSObject]$ActionRequest,
        [Parameter(Mandatory = $true)][Int64]$BrokerTaskId,
        [Parameter(Mandatory = $true)][TMSession]$TMSession
    )

    # Remove any leftover 'get_' parameter names
    $ActionRequest.params.PSObject.Properties.Name | Where-Object {
        $_ -like 'get_*'
    } | ForEach-Object {
        $ActionRequest.params.PSObject.Properties.Remove($_)
    }

    # Check the localsettings file to see if this action needs to be logged
    try {
        $LocalSettingsFilePath = Join-Path $Env:APPDATA 'tmconsole' 'localsettings'
        $LocalSettings = Get-Content -LiteralPath $LocalSettingsFilePath -Raw | ConvertFrom-Json
        if ($LocalSettings.logging.enabled) {
            $Subject.ActionRequest | Add-Member -NotePropertyName 'logPath' -NotePropertyValue $LocalSettings.logging.path
        }
    } catch {
        Write-Host "Could not read local settings: $($_.Exception.Message)" -ForegroundColor DarkYellow
    }

    # Allow Required Modules to be imported automatically
    $PSModuleAutoloadingPreference = 'All'
    $Global:PSModuleAutoloadingPreference = 'All'

    # Import the remaining Variables
    New-Variable -Name 'ActionRequest' -Value $ActionRequest -Force -Scope Global
    New-Variable -Name 'Params' -Value $ActionRequest.params -Force -Scope Global
    if (-not $global:TMSessions) {
        New-Variable -Name 'TMSessions' -Value @{} -Force -Scope Global
    }
    $global:TMSessions.Default = $TMSession

    # Compute a Project root folder from the Server, Project and Event
    $TMServerUrl = ([uri]$ActionRequest.options.callback.siteUrl).Host
    $TMProjectName = $ActionRequest.task.project.name
    $TMEventName = $ActionRequest.task.event.name
    $ProjectRootFolder = Join-Path $Global:userPaths.root ($TMServerUrl -replace '.transitionmanager.net', '') $TMProjectName $TMEventName

    # Create a Convenient TM Object with useful details
    $TM = [pscustomobject]@{
        Server   = @{
            Url     = $TMServerUrl
            Version = $TMSession.TMVersion
        }
        Project  = @{
            Id   = $ActionRequest.task.project.id
            Name = $TMProjectName
        }
        Event    = [pscustomobject]@{
            Id      = $ActionRequest.task.event.id
            Name    = $TMEventName
            Bundles = $ActionRequest.task.event.bundles
        }
        Provider = @{
            Id   = $ActionRequest.options.apiAction.provider.id
            Name = $ActionRequest.options.apiAction.provider.name
        }
        Action   = @{
            Id   = $ActionRequest.options.apiAction.id
            Name = $ActionRequest.options.apiAction.name
        }
        Task     = [pscustomobject]@{
            Id         = $ActionRequest.task.id
            Title      = $ActionRequest.task.title
            TaskNumber = $ActionRequest.task.taskNumber
            Invoker    = $ActionRequest.task.assignedTo.name
            Team       = $ActionRequest.task.team
        }
        Asset    = $ActionRequest.task.asset

        User     = @{
            Id       = $TMSession.UserAccount.Id
            Username = $TMSession.UserAccount.UserName
            Name     = $TMSession.UserContext.Person.FullName
        }
        Paths    = @{
            debug            = Join-Path $ProjectRootFolder 'Debug'
            logs             = Join-Path $ProjectRootFolder 'Logs'
            queue            = Join-Path $ProjectRootFolder 'Queue'
            config           = Join-Path $ProjectRootFolder 'Config'
            input            = Join-Path $ProjectRootFolder 'Input'
            output           = Join-Path $ProjectRootFolder 'Output'
            credentials      = Join-Path $ProjectRootFolder 'Credentials'
            git              = Join-Path $ProjectRootFolder 'Git'
            referencedesigns = Join-Path $ProjectRootFolder 'Reference Designs'
        }
    }

    # Scope the variable as global so the user will have access to it
    New-Variable -Name 'TM' -Value $TM -Scope Global -Force
}