internal/functions/Get/Publish-StagingModuleRemote.ps1

function Publish-StagingModuleRemote {
    <#
        .SYNOPSIS
        Deploys modules to a remote path.
     
    .DESCRIPTION
        Deploys modules to a remote path.
        This happens in parallel using psframework Runspace Workflows.
     
    .PARAMETER Path
        The path from where modules are copied.
     
    .PARAMETER TargetPath
        The destination path information where to deploy the modules.
        Not a string, but the return objects from Resolve-RemotePath.
        This object also includes the PSSession objects needed to execute the transfer.
     
    .PARAMETER ThrottleLimit
        Up to how many computers to deploy the modules to in parallel.
        Defaults to: 5
        Default can be configured under the 'PSFramework.NuGet.Remoting.Throttling' setting.
 
    .PARAMETER Force
        Redeploy a module that already exists in the target path.
        By default it will skip modules that do already exist in the target path.
     
    .EXAMPLE
        PS C:\> Publish-StagingModuleRemote -Path $stagingDirectory -TargetPath $targets -Force:$Force -Cmdlet $PSCmdlet
 
        Deploys all modules under $stagingDirectory to the target paths/computers in $targets.
        Will overwrite existing modules if $Force is $true.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Path,

        $TargetPath,

        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $ThrottleLimit = 5,

        [switch]
        $Force
    )

    begin {
        #region Worker Code
        $code = {
            param (
                $TargetPath
            )

            <#
            Inherited Variables:
            - $Path - Where the modules to publish lie
            - $Force - Whether to overwrite/redeploy modules that already exist in that path in that version
            #>


            $PSDefaultParameterValues['Write-PSFMessage:ModuleName'] = 'PSFramework.NuGet'
            $PSDefaultParameterValues['Write-PSFMessage:FunctionName'] = 'Publish-StagingModule'

            #region Functions
            function Get-GlobalFailResult {
                [CmdletBinding()]
                param (
                    [string]
                    $ComputerName,

                    [string]
                    $Path,

                    [System.Management.Automation.ErrorRecord]
                    $ErrorRecord
                )

                foreach ($module in Get-ChildItem -Path $Path) {
                    foreach ($version in Get-ChildItem -Path $module.FullName) {
                        New-PublishResult -ComputerName $ComputerName -Module $module.Name -Version $version.Name -Success $false -Path 'n/a' -Message $ErrorRecord
                    }
                }
            }
            #endregion Functions

            trap {
                Write-PSFMessage -Level Error -String 'Publish-StagingModule.Error.General' -StringValues $TargetPath.ComputerName -ErrorRecord $_

                #region Cleanup Staging Directory
                if ($stagingDirectory) {
                    $null = Invoke-SessionCommand @sessionCommon -Code {
                        param ($Path)
                        Remove-Item -Path $Path -Recurse -Force -ErrorAction Ignore
                    } -ArgumentList $stagingDirectory
                }
                #endregion Cleanup Staging Directory

                Get-GlobalFailResult -Path $Path -ComputerName $TargetPath.ComputerName -ErrorRecord $_

                $__PSF_Workflow.Data.Failed[$TargetPath.ComputerName] = $true
                $__PSF_Workflow.Data.Completed[$TargetPath.ComputerName] = $true
                $null = $__PSF_Workflow.Data.InProgress.TryRemove($TargetPath.ComputerName, [ref]$null)
                return
            }

            $publishCommon = @{
                ComputerName = $TargetPath.ComputerName
            }
            $sessionCommon = @{
                Session = $TargetPath.Session.Session
            }

            $oldSuffix = "old_$(Get-Random -Minimum 100 -Maximum 999)"
            $anyFailed = $false

            #region Prepare Staging Directory
            # This allows us to minimize the cutover time, when replacing an existing module
            $stagingResult = Invoke-SessionCommand @sessionCommon -Code {
                $tempDir = $env:TEMP
                if (-not $tempDir) {
                    $localAppData = $env:LOCALAPPDATA
                    if (-not $localAppData -and -not $IsLinux -and -not $IsMacOS) { $localAppData = [Environment]::GetFolderPath("LocalApplicationData") }
                    if (-not $localAppData -and $Env:XDG_CONFIG_HOME) { $localAppData = $Env:XDG_CONFIG_HOME }
                    if (-not $localAppData) { $localAppData = Join-Path -Path $HOME -ChildPath '.config' }
                    $tempDir = Join-Path -Path $localAppData -ChildPath 'Temp'
                }
                if (-not (Test-Path -Path $tempDir)) {
                    New-Item -Path $tempDir -ItemType Directory -Force -ErrorAction Stop
                }
                $stagingPath = Join-Path -Path $tempDir -ChildPath "PsfGet-$(Get-Random)"
                (New-Item -Path $stagingPath -ItemType Directory -Force -ErrorAction Stop).FullName
            }
            if (-not $stagingResult.Success) {
                Write-PSFMessage -Level Warning -String 'Publish-StagingModule.Remote.Error.TempStagingSetup' -StringValues $TargetPath.ComputerName, $stagingResult.Error -Tag error, temp, setup
                throw $stagingResult.Error
            }
            $stagingDirectory = $stagingResult.Data
            #endregion Prepare Staging Directory

            #region Send Modules
            foreach ($module in Get-ChildItem -Path $Path) {
                foreach ($version in Get-ChildItem -Path $module.FullName) {
                    foreach ($destination in $TargetPath.Results) {
                        if (-not $destination.Exists) { continue }

                        #region Verify Existence
                        $publishCommon.Path = $destination.Path
                        $publishCommon.Module = $module.Name
                        $publishCommon.Version = $version.Name

                        $testPath = Join-Path -Path $destination.Path -ChildPath "$($module.Name)/$($version.Name)/$($module.Name).psd1"
                        $alreadyExists = Invoke-Command -Session $TargetPath.Session.Session -ScriptBlock {
                            param ($TestPath)
                            Test-Path -Path $TestPath
                        } -ArgumentList $testPath

                        if ($alreadyExists -and -not $Force) {
                            Write-PSFMessage -String 'Publish-StagingModule.Remote.Skipping.AlreadyExists' -StringValues $TargetPath.ComputerName, $module.Name, $version.Name -Target ("$($module.Name) ($($version.Name))")
                            New-PublishResult @publishCommon -Success $true -Message 'Module already deployed'
                            continue
                        }
                        #endregion Verify Existence
        
                        $targetStagingRoot = Join-Path -Path $stagingDirectory -ChildPath $module.Name
                        $targetStagingVersionDirectory = Join-Path -Path $targetStagingRoot -ChildPath $version.Name
                        $targetVersionRoot = Join-Path -Path $destination.Path -ChildPath $module.Name
                        $targetVersionDirectory = Join-Path -Path $targetVersionRoot -ChildPath $version.Name

                        #region Send Module to Staging
                        Write-PSFMessage -String 'Publish-StagingModule.Remote.DeployStaging' -StringValues $TargetPath.ComputerName, $module.Name, $version.Name, $targetStagingRoot -Target ("$($module.Name) ($($version.Name))")
                        $createResult = Invoke-SessionCommand @sessionCommon -Code {
                            param ($ModuleRoot)
                            New-Item -Path $ModuleRoot -ItemType Directory -Force -ErrorAction Stop
                        } -ArgumentList $targetStagingRoot
                        if (-not $createResult.Success) {
                            Write-PSFMessage -String 'Publish-StagingModule.Remote.DeployStaging.FailedDirectory' -StringValues $TargetPath.ComputerName, $module.Name, $version.Name, $targetStagingRoot, $createResult.Error -Target ("$($module.Name) ($($version.Name))")
                            New-PublishResult @publishCommon -Success $false -Message "Failed to create staging module folder $targetStagingRoot on $($TargetPath.ComputerName): $($createResult.Error)"
                            $anyFailed = $true
                            continue
                        }
                        try { Copy-Item -LiteralPath $version.FullName -Destination $targetStagingRoot -Recurse -Force -ToSession $TargetPath.Session.Session -ErrorAction Stop }
                        catch {
                            Write-PSFMessage -String 'Publish-StagingModule.Remote.DeployStaging.FailedCopy' -StringValues $TargetPath.ComputerName, $module.Name, $version.Name, $targetStagingRoot -Target ("$($module.Name) ($($version.Name))") -ErrorRecord $_
                            New-PublishResult @publishCommon -Success $false -Message "Failed to copy module to folder $targetStagingRoot on $($TargetPath.ComputerName): $_"
                            $anyFailed = $true
                            continue
                        }
                        #endregion Send Module to Staging
        
                        #region Rename old version
                        if ($alreadyExists) {
                            Write-PSFMessage -String 'Publish-StagingModule.Remote.Deploying.RenameOld' -StringValues $TargetPath.ComputerName, $module.Name, $version.Name, $testPath -Target ("$($module.Name) ($($version.Name))")
                            $renameResult = Invoke-SessionCommand @sessionCommon -Code {
                                param ($Path, $NewName)
                                Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop -Force
                            } -ArgumentList $targetVersionDirectory, "$($version.Name)_$oldSuffix"
                            if ($renameResult.Success) {
                                Write-PSFMessage -String 'Publish-StagingModule.Remote.Deploying.RenameOld.Success' -StringValues $TargetPath.ComputerName, $module.Name, $version.Name, $testPath -Target ("$($module.Name) ($($version.Name))")
                            }
                            else {
                                Write-PSFMessage -Level Warning -String 'Publish-StagingModule.Remote.Deploying.RenameOld.NoSuccess' -StringValues $TargetPath.ComputerName, $module.Name, $version.Name, $testPath, $renameResult.Error -Target ("$($module.Name) ($($version.Name))")
                                $anyFailed = $true
                                New-PublishResult @publishCommon -Success $false -Message "Failed to rename old version: $($renameResult.Error)"
                                continue
                            }
                        }
                        #endregion Rename old version
    
                        #region Deploy New Version
                        Write-PSFMessage -String 'Publish-StagingModule.Deploying.Remote' -StringValues $TargetPath.ComputerName, $module.Name, $version.Name, $targetVersionRoot -Target ("$($module.Name) ($($version.Name))")
                        $deployResult = Invoke-SessionCommand @sessionCommon -Code {
                            param ($Path, $Destination)
                            if (-not (Test-Path -Path $Destination)) { $null = New-Item -Path $Destination -ItemType Directory -Force -ErrorAction Stop }
                            Move-Item -Path $Path -Destination $Destination -Force -ErrorAction Stop
                        } -ArgumentList $targetStagingVersionDirectory, $targetVersionRoot

                        if (-not $deployResult.Success) {
                            Write-PSFMessage -Level Warning -String 'Publish-StagingModule.Deploying.Remote.Failed' -StringValues $TargetPath.ComputerName, $module.Name, $version.Name, $targetVersionRoot, $deployResult.Error -Target ("$($module.Name) ($($version.Name))")
                            $anyFailed = $true
                            if (-not $alreadyExists) {
                                New-PublishResult @publishCommon -Success $false -Message "Failed to deploy version: $($deployResult.Error)"
                                continue
                            }

                            $rollbackResult = Invoke-SessionCommand @sessionCommon -Code {
                                param ($Path, $TempName)
                                $parent, $name = Split-Path -Path $Path
                                if (Test-Path -Path $Path) { Remove-Item -Path $Path -Recurse -Force -ErrorAction Stop }

                                $original = Join-Path -Path $parent -ChildPath $TempName
                                Rename-Item -Path $original -NewName $name -Force -ErrorAction Stop
                            } -ArgumentList $targetVersionDirectory, "$($version.Name)_$oldSuffix"

                            if ($rollbackResult.Success) {
                                New-PublishResult @publishCommon -Success $false -Message "Failed to re-deploy version, rollback was successful: $($deployResult.Error)"
                            }
                            else {
                                New-PublishResult @publishCommon -Success $false -Message "Failed to re-deploy version, rollback failed: $($deployResult.Error)"
                            }
                            continue
                        }
                        #endregion Deploy New Version

                        #region Remove Old Version
                        if ($alreadyExists) {
                            $cleanupResult = Invoke-SessionCommand @sessionCommon -Code {
                                param ($Path)
                                Remove-Item -LiteralPath $Path -Force -ErrorAction Stop -Recurse
                            } -ArgumentList "$($targetVersionDirectory)_$oldSuffix"

                            if (-not $cleanupResult.Success) {
                                New-PublishResult @publishCommon -Success $true -Message "Failed to cleanup previous version: $($cleanupResult.Error)"
                                continue
                            }
                        }
                        #endregion Remove Old Version

                        New-PublishResult @publishCommon -Success $true
                    }
                }
            }
            #endregion Send Modules

            #region Cleanup Staging Directory
            $null = Invoke-SessionCommand @sessionCommon -Code {
                param ($Path)
                Remove-Item -Path $Path -Recurse -Force -ErrorAction Ignore
            } -ArgumentList $stagingDirectory
            #endregion Cleanup Staging Directory
        
            $__PSF_Workflow.Data.Completed[$TargetPath.ComputerName] = $true
            if ($anyFailed) { $__PSF_Workflow.Data.Failed[$TargetPath.ComputerName] = $true }
            else { $__PSF_Workflow.Data.Success[$TargetPath.ComputerName] = $true }
            $null = $__PSF_Workflow.Data.InProgress.TryRemove($TargetPath.ComputerName, [ref]$null)
        }
        #endregion Worker Code

        # Limit Worker Count to Target Count
        if ($TargetPath.Count -lt $ThrottleLimit) { $ThrottleLimit = $TargetPath.Count }

        $workflow = New-PSFRunspaceWorkflow -Name PublishModule -Force
        $null = $workflow | Add-PSFRunspaceWorker -Name Publisher -InQueue Input -OutQueue Results -ScriptBlock $code -CloseOutQueue -Count $ThrottleLimit -Functions @{
            'New-PublishResult' = [scriptblock]::Create((Get-Command New-PublishResult).Definition)
            'Invoke-SessionCommand' = [scriptblock]::Create((Get-Command Invoke-SessionCommand).Definition)
        } -Variables @{
            Path  = $Path
            Force = $Force
        }
        $workflow | Write-PSFRunspaceQueue -Name Input -BulkValues $TargetPath -Close

        # Add Tracking for Progress Information
        $workflow.Data['InProgress'] = [System.Collections.Concurrent.ConcurrentDictionary[string, string]]::new()
        $workflow.Data['Failed'] = [System.Collections.Concurrent.ConcurrentDictionary[string, string]]::new()
        $workflow.Data['Success'] = [System.Collections.Concurrent.ConcurrentDictionary[string, string]]::new()
        $workflow.Data['Completed'] = [System.Collections.Concurrent.ConcurrentDictionary[string, string]]::new()

        $progressId = Get-Random
    }
    process {
        try {
            $workflow | Start-PSFRunspaceWorkflow
            Write-Progress -Id $progressId -Activity 'Deploying Modules'

            while ($workflow.Data.Completed.Count -lt $TargetPath.Count) {
                Start-Sleep -Seconds 1
                $status = 'In Progress: {0} | Failed: {1} | Succeeded: {2} | Completed: {3}' -f $workflow.Data.InProgress.Count, $workflow.Data.Failed.Count, $workflow.Data.Success.Count, $workflow.Data.Completed.Count
                $percent = ($workflow.Data.Completed.Count / $TargetPath.Count * 100) -as [int]
                if ($percent -gt 100) { $percent = 100 }
                Write-Progress -Id $progressId -Activity 'Deploying Modules' -Status $status -PercentComplete $percent
            }
            $workflow | Wait-PSFRunspaceWorkflow -Queue Results -Closed
        }
        finally {
            # Ensure finally executes without interruption, lest an impatient admin leads to leftover state
            Disable-PSFConsoleInterrupt
            $workflow | Stop-PSFRunspaceWorkflow
            $results = $workflow | Read-PSFRunspaceQueue -Name Results -All | Write-Output # Needs to bextra enumerated if multiple results happen in a single worker
            $workflow | Remove-PSFRunspaceWorkflow
            Enable-PSFConsoleInterrupt
            $results
        }
    }
}