public/Invoke-PSRemotely.ps1

Function Invoke-PSRemotely {
<#
    .SYNOPSIS
        Invoke PSRemotely

    .DESCRIPTION
        Invoke PSRemotely

        Searches for .PSRemotely.ps1 files in the current and nested paths, and invokes the remote ops validation.
        By default PSRemotely would run all the

    .PARAMETER Script
        Path to a specific .PSRemotely.ps1 file, or to a folder that is recursively search for *.PSRemotely.ps1 files
        You can also use the Script parameter to pass parameter names and values to a script that contains
        PSRemotely + Pester tests. The value of the Script parameter can be a string, a hash table, or a collection
        of hash tables and strings. Wildcard characters are supported.

        The Script parameter is optional. If you omit it, Invoke-PSRemotely runs all
        *.PSRemotely.ps1 files in the local directory and its subdirectories recursively.
            
        To run tests in other files, such as .ps1 files, enter the path and file name of
        the file. (The file name is required. Name patterns that end in "*.ps1" run only
        *.PSRemotely.ps1 files.)

        To run a PSRemotely file with parameter names and/or values, use a hash table as the
        value of the script parameter. The keys in the hash table are:

        -- Path [string] (required): Specifies a test to run. The value is a path\file
        name or name pattern. Wildcards are permitted. All hash tables in a Script
        parameter value must have a Path key.
            
        -- Parameters [hashtable]: Runs the script with the specified parameters. The
        value is a nested hash table with parameter name and value pairs, such as
        @{UserName = 'User01'; Id = '28'}.
            
        -- Arguments [array]: An array or comma-separated list of parameter values
        without names, such as 'User01', 28. Use this key to pass values to positional
        parameters.
    
        Defaults to the current path.

    .PARAMETER JSONInput
        JSON String input which contains the nodename and testnames to run.
         Below is a sample JSON string, for invoking tests named TestDNSConnectivity & TestADConnectivity
         on node BLRompute1 :-
         {
            "NodeName": "BLRompute1",
            "Tests": [
                            {
                                "Name": "TestDNSConnectivity"
                            },
                            {
                                "Name" : "TestADConnectivity"
                            }
            ]
        }

    
    .EXAMPLE
        PS> Invoke-PSRemotely

        # Run remote ops validation from any file named *.PSRemotely.ps1 found under the current folder or any nested folders.
        # Prompts to confirm

    .EXAMPLE
        PS> Invoke-PSRemotely -Script C:\InfraTests\ComputeTests.PSRemotely.ps1

        # Run remote ops validation from mymodule.PSRemotely.ps1.
        # Don't prompt to confirm.

    .EXAMPLE
        PS> Invoke-PSRemotely -Script @{
                Path='C:\InfraTests\ComputeTests.PSRemotely.ps1';
                Parameters = @{Credential=$(Get-Credential)};
                Arguments = @(.\EnvironmentData.json)
            }

        # Run remote ops validation from file ComputeTests.PSRemotely.ps1.
        # This command runs C:\InfraTests\ComputeTests.PSRemotely.ps1 file. The PSRemotely file is expecting the path to
        # configuration data file (.json or .psd1) and Credential to be used to open PSSession to the nodes.
        # It runs the tests in the ComputeTests.PSRemotely.ps1 file using the following parameters:

        C:\InfraTests\ComputeTests.PSRemotely.ps1 .\EnvironmentData.json -Credential <CredentialObject>
    
    .EXAMPLE
        If suppose you ran tests on a remote node like below :-
        PS> Invoke-PSRemotely -Script C:\InfraTests\ComputeTests.PSRemotely.ps1

        And one of the tests named 'TestDNSConnectivity' (Describe Pester block) on the remote node failed. You fixed the issue and
        want to just run the failed test, you could do something like below :

        PS> $TestsTobeRunObejct = [pscustomobject]@{NodeName='ComputeNode1';Tests=@(@{Name='TestDNSConnectivity'})}
        PS> Invoke-PSRemotely -JSONInput $($TestsTobeRunObejct | ConvertTo-Json)

    .LINK
        PSRemotely

    .LINK
        https://github.com/DexterPOSH/PSRemotely

#>

    [CmdletBinding(DefaultParameterSetName='BootStrap',SupportsShouldProcess=$True)]
    param(
        [Parameter(Position=-0,
                    Mandatory=$False,
                    ParameterSetName='BootStrap',
                    ValueFromPipeline=$true)]
        [Object[]]$Script='.',
        
        [Parameter(Position=0,
                    Mandatory=$true,
                    ParameterSetName='JSON')]
        [String]$JSONInput

    )
    BEGIN {
        
        # verbose logging goes here
    }
    PROCESS {
        Switch -Exact ($PSCmdlet.ParameterSetName) {

            'JSON' {
                # provided the JSON input, run the required tests on the PSRemotely node
                Write-VerboseLog -Message 'ParameterSet - JSON'
                # construct the object from the input
                $Object = ConvertFrom-Json -InputObject $JSONInput
                
                If (-not $Object.NodeName ) {
                    Throw "JSON Input must specify the NodeName property."
                }
                # Check if the node is already bootstrapped and session info maintained in the PSRemotely variable
                if (TestRemotelyNodeBootStrapped -ComputerName $Object.NodeName) {
                    # Node is bootstrapped, get the corresponding session object
                    $session = $PSRemotely['sessionHashTable'].GetEnumerator() | 
                                Where-Object -Property Name -like "*$($Object.NodeName)*" |
                                Select-Object -ExpandProperty Value | 
                                Select-Object -ExpandProperty Session
                    
                    # build the splat hashtable
                   $invokeTestParams = @{
                        Session = $session;
                        ArgumentList = $JSONInput, $Object.NodeName #@(,$Object.Tests.Name);
                        ScriptBlock = {
                            param(
                                [String]$JSONString,
                                [String]$NodeName
                                )
                            $Object = ConvertFrom-Json -InputObject $JSONString
                            foreach ($test in @($Object.Tests.Name)) {
                                Write-Verbose -Message "Processing $test" -Verbose
                                if ($NodeName) {
                                    $testFileName = "{0}.{1}.Tests.ps1" -f $NodeName, $test.replace(' ','_')
                                    # Check that the filename does not contain invalid file characters e.g ::1 is the nodename in case of link local ipv6 address
                                    $IndexOfInvalidChar = $testFileName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())
                                    # IndexOfAny() returns the value -1 to indicate no such character was found
                                    if($IndexOfInvalidChar -ne -1){
                                        # if there is an invalid character in the filename, fall back to using the computername
                                        Write-Warning -Message "Invalid character found in the file name > $($testFileName). Switching to using env:computername for filename"
                                        $testFileName = "{0}.{1}.Tests.ps1" -f $Env:COMPUTERNAME, $test.replace(' ','_')
                                    }
                                }
                                else {
                                    $testFileName = "{0}.{1}.Tests.ps1" -f $env:COMPUTERNAME, $test.replace(' ','_')
                                }
                                
                                $testFile = "$($Global:PSRemotely.PSRemotelyNodePath)\$testFileName"
                                $outPutFile = "{0}\{1}.{2}.xml" -f      $PSRemotely.PSRemotelyNodePath, $nodeName, $test
                                if ($Node) {
                                    Invoke-Pester -Script @{Path=$($TestFile); Parameters=@{Node=$Node}} -PassThru -Quiet -OutputFormat NUnitXML -OutputFile $outPutFile
                                }
                                else {
                                    Invoke-Pester -Script $testFile  -PassThru -Quiet -OutputFormat NUnitXML -OutputFile $outPutFile
                                }
                            }
                        }; # end scriptBlock
                    }


                    # invoke the tests
                    $testJob = Invoke-Command @invokeTestParams -AsJob

                }
                else {
                    # Node is not bootstrapped. Throw an error
                    throw "$($object.NodeName) is not bootstrapped"
                }
                break
            }
            'BootStrap' {
                # Path to a Script housing PSRemotely tests, it should run the script as it script
                # Credits to Pester's Invoke-Pester for the below logic
                Write-VerboseLog -Message 'ParameterSet - BootStrap'
                $invokeTestScript = {
                    param (
                        [Parameter(Position = 0)]
                        [string] $Path,

                        [object[]] $Arguments = @(),
                        [System.Collections.IDictionary] $Parameters = @{}
                    )

                    & $Path @Parameters @Arguments
                }
                
                $testScripts = ResolveTestScripts $Script

                foreach ($testScript in $testScripts){
                    try {
                        do{
                            Write-VerboseLog -Message "Invoking test script -> $($testscript.path)"
                            & $invokeTestScript -Path $testScript.Path -Arguments $testScript.Arguments -Parameters $testScript.Parameters
                        } until ($true)
                    }
                    catch{
                        $firstStackTraceLine = $_.ScriptStackTrace -split '\r?\n' | Select-Object -First 1
                        Write-VerboseLog -Message "Error occurred in test script '$($testScript.Path) -> $firstStackTraceLine"
                        
                    }
                }
            }
        }
    }
    END {
        if ($PSCmdlet.ParameterSetName -eq 'JSON') {
            $null = $testjob | Wait-Job
            $results = @(ProcessRemotelyJob -InputObject $([System.Collections.DictionaryEntry]::New($Object.NodeName,$TestJob)))
            $testjob | Remove-Job -Force
            ProcessRemotelyOutputToJSON -InputObject $results
        }
        
    }
}


function ResolveTestScripts
{
    param ([object[]] $Path)

    $resolvedScriptInfo = @(
        foreach ($object in $Path)
        {
            if ($object -is [System.Collections.IDictionary])
            {
                $unresolvedPath = Get-DictionaryValueFromFirstKeyFound -Dictionary $object -Key 'Path', 'p'
                $arguments      = @(Get-DictionaryValueFromFirstKeyFound -Dictionary $object -Key 'Arguments', 'args', 'a')
                $parameters     = Get-DictionaryValueFromFirstKeyFound -Dictionary $object -Key 'Parameters', 'params'

                if ($unresolvedPath -isnot [string] -or $unresolvedPath -notmatch '\S')
                {
                    throw 'When passing hashtables to the -Path parameter, the Path key is mandatory, and must contain a single string.'
                }

                if ($null -ne $parameters -and $parameters -isnot [System.Collections.IDictionary])
                {
                    throw 'When passing hashtables to the -Path parameter, the Parameters key (if present) must be assigned an IDictionary object.'
                }
            }
            else
            {
                $unresolvedPath = [string] $object
                $arguments      = @()
                $parameters     = @{}
            }

            if ($unresolvedPath -notmatch '[\*\?\[\]]' -and
                (Test-Path -LiteralPath $unresolvedPath -PathType Leaf) -and
                (Get-Item -LiteralPath $unresolvedPath) -is [System.IO.FileInfo]){
                
                $extension = [System.IO.Path]::GetExtension($unresolvedPath)
                $IsPSRemotelyInName = [System.IO.Path]::GetFileNameWithoutExtension($unresolvedPath)
                $IsNameEndingwithPSRemotely = $IsPSRemotelyInName.EndsWith('PSRemotely',$true,[System.Globalization.CultureInfo]::InvariantCulture)
                if (($extension -ne '.ps1') -or ( -not $IsNameEndingwithPSRemotely)){
                    Write-Error "Script path '$unresolvedPath' is not a *.PSRemotely.ps1 file."
                }
                else
                {
                    New-Object -TypeName psobject -Property @{
                        Path       = $unresolvedPath
                        Arguments  = $arguments
                        Parameters = $parameters
                    }
                }
            }
            else
            {
                # World's longest pipeline?

                Resolve-Path -Path $unresolvedPath |
                    Where-Object { $_.Provider.Name -eq 'FileSystem' } |
                    Select-Object  -ExpandProperty ProviderPath |
                    Get-ChildItem -Include *.PSRemotely.ps1 -Recurse |
                    Where-Object { -not $_.PSIsContainer } |
                    Select-Object -ExpandProperty FullName -Unique |
                    ForEach-Object {
                        New-Object  psobject -Property @{
                            Path       = $_
                            Arguments  = $arguments
                            Parameters = $parameters
                        }
                    }
            }
        }
    )

    # Here, we have the option of trying to weed out duplicate file paths that also contain identical
    # Parameters / Arguments. However, we already make sure that each object in $Path didn't produce
    # any duplicate file paths, and if the caller happens to pass in a set of parameters that produce
    # dupes, maybe that's not our problem. For now, just return what we found.

    $resolvedScriptInfo
}


function Get-DictionaryValueFromFirstKeyFound
{
    param ([System.Collections.IDictionary] $Dictionary, [object[]] $Key)

    foreach ($keyToTry in $Key)
    {
        if ($Dictionary.Contains($keyToTry)) { return $Dictionary[$keyToTry] }
    }
}