Sampler.PowerShellUniversalTasks.psm1

#Region './prefix.ps1' -1

<#
    Defines aliases used by InvokeBuild task discovery. These aliases point to
    task files shipped in the built module's Tasks directory and do not depend
    on functions declared later in the merged root module.
#>


$taskPath = Join-Path -Path $PSScriptRoot -ChildPath 'Tasks'
$taskPath = Join-Path -Path $taskPath -ChildPath 'Publish.PowerShellUniversal.build.ps1'
Set-Alias -Name 'Task.Publish_PowerShellUniversal' -Value $taskPath
#EndRegion './prefix.ps1' 10
#Region './Private/Copy-UniversalRepositoryModule.ps1' -1

function Copy-UniversalRepositoryModule
{
    <#
        .SYNOPSIS
            Copies a module and its dependencies into a PSU repository layout.

        .DESCRIPTION
            Uses a queue to copy a module and each transitive RequiredModules
            dependency into versioned folders below the repository Modules directory.
            First-discovery order is preserved, and a selected module is replaced
            only when a later requirement resolves to a higher version.

        .PARAMETER Module
            Module information for the module that is copied.

        .PARAMETER ModulesDestinationPath
            Destination Modules directory in the staged repository.

        .PARAMETER Visited
            Hashtable used to prevent duplicate copies and dependency cycles.

        .EXAMPLE
            Copy-UniversalRepositoryModule -Module $module -ModulesDestinationPath $path -Visited @{}
    #>

    [CmdletBinding()]
    [OutputType([System.Void])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSModuleInfo]
        $Module,

        [Parameter(Mandatory = $true)]
        [System.String]
        $ModulesDestinationPath,

        [Parameter(Mandatory = $true)]
        [System.Collections.Hashtable]
        $Visited
    )

    $moduleQueue = [System.Collections.Queue]::new()
    $selectedModules = @{ }
    $moduleDiscoveryOrder = [System.Collections.ArrayList]::new()
    $inspectedModuleVersions = @{ }
    $moduleQueue.Enqueue($Module)

    while ($moduleQueue.Count -gt 0)
    {
        $currentModule = [System.Management.Automation.PSModuleInfo] $moduleQueue.Dequeue()
        $moduleVersionKey = '{0}|{1}' -f $currentModule.Name, $currentModule.Version
        if ($inspectedModuleVersions.ContainsKey($moduleVersionKey))
        {
            continue
        }

        $inspectedModuleVersions[$moduleVersionKey] = $true
        if (-not $selectedModules.ContainsKey($currentModule.Name))
        {
            $selectedModules[$currentModule.Name] = $currentModule
            $null = $moduleDiscoveryOrder.Add($currentModule.Name)
        }
        elseif ($currentModule.Version -gt $selectedModules[$currentModule.Name].Version)
        {
            $selectedModules[$currentModule.Name] = $currentModule
        }

        $manifestPath = Join-Path -Path $currentModule.ModuleBase -ChildPath ('{0}.psd1' -f $currentModule.Name)
        if (-not (Test-Path -Path $manifestPath))
        {
            continue
        }

        $moduleInfo = Get-SamplerModuleInfo -ModuleManifestPath $manifestPath
        foreach ($requiredModule in @($moduleInfo.RequiredModules))
        {
            if (-not $requiredModule)
            {
                continue
            }

            $moduleSpecification = [Microsoft.PowerShell.Commands.ModuleSpecification] $requiredModule
            if ($selectedModules.ContainsKey($moduleSpecification.Name))
            {
                $requiredVersion = $moduleSpecification.RequiredVersion
                if (-not $requiredVersion)
                {
                    $requiredVersion = $moduleSpecification.Version
                }

                if (-not $requiredVersion -or
                    $selectedModules[$moduleSpecification.Name].Version -ge $requiredVersion)
                {
                    continue
                }
            }

            $resolvedModule = Get-Module -ListAvailable -FullyQualifiedName $moduleSpecification |
                Sort-Object -Property Version -Descending |
                Select-Object -First 1

            if (-not $resolvedModule)
            {
                throw "Required module '$($moduleSpecification.Name)' for '$($currentModule.Name)' was not found on PSModulePath."
            }

            $moduleQueue.Enqueue($resolvedModule)
        }
    }

    foreach ($selectedModuleName in $moduleDiscoveryOrder)
    {
        $selectedModule = [System.Management.Automation.PSModuleInfo] $selectedModules[$selectedModuleName]
        $Visited[$selectedModule.Name] = $true
        $moduleDestination = Join-Path -Path $ModulesDestinationPath -ChildPath $selectedModule.Name
        $moduleDestination = Join-Path -Path $moduleDestination -ChildPath $selectedModule.Version.ToString()

        $null = New-Item -Path $moduleDestination -ItemType Directory -Force
        Copy-Item -Path (Join-Path -Path $selectedModule.ModuleBase -ChildPath '*') -Destination $moduleDestination -Recurse -Force
    }
}
#EndRegion './Private/Copy-UniversalRepositoryModule.ps1' 122
#Region './Private/New-UniversalAutomationRepositoryManifest.ps1' -1

function New-UniversalAutomationRepositoryManifest
{
    <#
        .SYNOPSIS
            Creates the descriptor for a PowerShell Universal repository package.

        .DESCRIPTION
            Writes repository.psd1 with the module name and metadata required by
            PowerShell Universal when activating an offline automation repository.

        .PARAMETER Path
            Destination path for repository.psd1.

        .PARAMETER Module
            Built module information used to populate repository metadata.

        .PARAMETER ModuleVersion
            Module version written to the repository descriptor.

        .EXAMPLE
            New-UniversalAutomationRepositoryManifest -Path $path -Module $module -ModuleVersion $version
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([System.Void])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Path,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSModuleInfo]
        $Module,

        [Parameter(Mandatory = $true)]
        [System.String]
        $ModuleVersion
    )

    $manifestParameters = @{
        Path              = $Path
        RootModule        = $Module.Name
        ModuleVersion     = ($ModuleVersion -replace '-.*$', '')
        Guid              = (New-Guid)
        Author            = $Module.Author
        CompanyName       = $Module.CompanyName
        Copyright         = $Module.Copyright
        Description       = $Module.Description
        FunctionsToExport = '*'
        CmdletsToExport   = '*'
        VariablesToExport = '*'
        AliasesToExport   = '*'
    }

    if ($PSCmdlet.ShouldProcess($Path, 'Create PowerShell Universal repository manifest'))
    {
        New-ModuleManifest @manifestParameters
    }
}
#EndRegion './Private/New-UniversalAutomationRepositoryManifest.ps1' 60
#Region './Public/Get-UniversalDeploymentError.ps1' -1

function Get-UniversalDeploymentError
{
    <#
        .SYNOPSIS
            Gets PowerShell Universal deployment error notifications.

        .DESCRIPTION
            Queries recent PowerShell Universal notifications and returns
            deployment-related errors created on or after a specified time.

        .PARAMETER ServerUrl
            Base URL of the PowerShell Universal server.

        .PARAMETER AppToken
            Application token used to authenticate the notification request.

        .PARAMETER Since
            Earliest notification creation time to include.

        .PARAMETER FilterText
            Optional text that must appear in the notification title or description.

        .EXAMPLE
            Get-UniversalDeploymentError -ServerUrl $url -AppToken $token -Since $startedAt
    #>

    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSObject])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ServerUrl,

        [Parameter(Mandatory = $true)]
        [System.String]
        $AppToken,

        [Parameter(Mandatory = $true)]
        [System.DateTimeOffset]
        $Since,

        [Parameter()]
        [System.String]
        $FilterText
    )

    $requestParameters = @{
        Uri     = '{0}/api/v1/notification/last' -f $ServerUrl.TrimEnd('/')
        Headers = @{
            Authorization = 'Bearer {0}' -f $AppToken
        }
        Method  = 'Get'
    }
    $response = Invoke-RestMethod @requestParameters
    $notifications = if ($null -ne $response.page)
    {
        @($response.page)
    }
    elseif ($response -is [System.Array])
    {
        @($response)
    }
    elseif ($null -ne $response)
    {
        @($response)
    }
    else
    {
        @()
    }

    foreach ($notification in $notifications)
    {
        $createdTime = [System.DateTimeOffset]::MinValue
        if (-not [System.DateTimeOffset]::TryParse(
                [System.String] $notification.CreatedTime,
                [System.Globalization.CultureInfo]::InvariantCulture,
                [System.Globalization.DateTimeStyles]::AssumeUniversal,
                [ref] $createdTime
            ))
        {
            continue
        }

        if ($createdTime -lt $Since)
        {
            continue
        }

        $title = [System.String] $notification.Title
        $description = [System.String] $notification.Description
        $level = [System.String] $notification.Level
        $notificationText = '{0} {1}' -f $title, $description
        $isDeploymentNotification = $notificationText -match '(?i)deployment|module|configuration'
        $isErrorNotification = $level -match '(?i)^error$' -or
            $notificationText -match '(?i)\bfailed\b|\berror\b|invalid configuration'

        if (-not $isDeploymentNotification -or -not $isErrorNotification)
        {
            continue
        }

        if (-not [System.String]::IsNullOrWhiteSpace($FilterText) -and
            $notificationText -notmatch [System.Text.RegularExpressions.Regex]::Escape($FilterText))
        {
            continue
        }

        $notification
    }
}
#EndRegion './Public/Get-UniversalDeploymentError.ps1' 112
#Region './Public/Install-UniversalModuleFromRepository.ps1' -1

function Install-UniversalModuleFromRepository
{
    <#
        .SYNOPSIS
            Installs a module on PowerShell Universal from a resource repository.

        .DESCRIPTION
            Reconciles the requested PowerShell resource repository, triggers a
            synchronous module deployment, and optionally removes the repository.

        .PARAMETER ServerUrl
            Base URL of the PowerShell Universal server.

        .PARAMETER AppToken
            Application token used to authenticate API requests.

        .PARAMETER ModuleName
            Name of the module to install.

        .PARAMETER ModuleVersion
            Version of the module to install.

        .PARAMETER RepositoryName
            Name of the PowerShell resource repository on the server.

        .PARAMETER RepositoryUrl
            URL or local path used by the PowerShell resource repository.

        .PARAMETER RepositoryAutoRemove
            Whether to remove the repository after the deployment attempt.

        .EXAMPLE
            Install-UniversalModuleFromRepository @installParameters
    #>

    [CmdletBinding()]
    [OutputType([System.Void])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ServerUrl,

        [Parameter(Mandatory = $true)]
        [System.String]
        $AppToken,

        [Parameter(Mandatory = $true)]
        [System.String]
        $ModuleName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $ModuleVersion,

        [Parameter(Mandatory = $true)]
        [System.String]
        $RepositoryName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $RepositoryUrl,

        [Parameter()]
        [System.Boolean]
        $RepositoryAutoRemove = $true
    )

    $headers = @{
        Authorization = 'Bearer {0}' -f $AppToken
        Accept        = 'application/json'
    }
    $repositoryEndpoint = '{0}/api/v1/resourceRepository' -f $ServerUrl
    $repositories = @(Invoke-RestMethod -Uri $repositoryEndpoint -Headers $headers -Method Get)
    $existingRepository = $repositories |
        Where-Object -FilterScript { $_.name -eq $RepositoryName } |
        Select-Object -First 1

    if ($existingRepository)
    {
        $sameUrl = [System.String]::Equals(
            [System.String] $existingRepository.url,
            $RepositoryUrl,
            [System.StringComparison]::OrdinalIgnoreCase
        )

        if ((-not $sameUrl -or -not [System.Boolean] $existingRepository.trusted) -and $RepositoryAutoRemove)
        {
            $deleteUri = '{0}/{1}' -f $repositoryEndpoint, [System.Uri]::EscapeDataString($RepositoryName)
            $null = Invoke-RestMethod -Uri $deleteUri -Headers $headers -Method Delete
            $existingRepository = $null
        }
    }

    if (-not $existingRepository)
    {
        $body = @{
            name    = $RepositoryName
            url     = $RepositoryUrl
            trusted = $true
            id      = 0
        } | ConvertTo-Json -Depth 5

        $null = Invoke-RestMethod -Uri $repositoryEndpoint -Headers $headers -Method Post -Body $body -ContentType 'application/json; charset=utf-8'
    }

    try
    {
        $deployUri = '{0}/api/v1/deployment/module/{1}/{2}?repository={3}&synchronous=true' -f @(
            $ServerUrl
            $ModuleName
            $ModuleVersion
            [System.Uri]::EscapeDataString($RepositoryName)
        )
        $null = Invoke-RestMethod -Uri $deployUri -Headers $headers -Method Put -ContentType 'application/octet-stream; charset=utf-8'
    }
    finally
    {
        if ($RepositoryAutoRemove)
        {
            $deleteUri = '{0}/{1}' -f $repositoryEndpoint, [System.Uri]::EscapeDataString($RepositoryName)
            try
            {
                $null = Invoke-RestMethod -Uri $deleteUri -Headers $headers -Method Delete
            }
            catch
            {
                Write-Warning -Message ("Failed to remove PowerShell resource repository '{0}': {1}" -f $RepositoryName, $_.Exception.Message)
            }
        }
    }
}
#EndRegion './Public/Install-UniversalModuleFromRepository.ps1' 132
#Region './Public/Invoke-UniversalDeploymentUpload.ps1' -1

function Invoke-UniversalDeploymentUpload
{
    <#
        .SYNOPSIS
            Uploads a deployment package to PowerShell Universal.

        .DESCRIPTION
            Sends a module package or automation repository archive to a
            PowerShell Universal deployment endpoint using bearer authentication.

        .PARAMETER Uri
            Complete PowerShell Universal deployment endpoint URI.

        .PARAMETER AppToken
            Application token used to authenticate the deployment request.

        .PARAMETER Path
            Path to the package that is uploaded as the request body.

        .EXAMPLE
            Invoke-UniversalDeploymentUpload -Uri $uri -AppToken $token -Path $packagePath
    #>

    [CmdletBinding()]
    [OutputType([System.Object])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Uri,

        [Parameter(Mandatory = $true)]
        [System.String]
        $AppToken,

        [Parameter(Mandatory = $true)]
        [System.String]
        $Path
    )

    if (-not (Test-Path -Path $Path))
    {
        throw "Deployment package '$Path' was not found."
    }

    $requestParameters = @{
        Uri         = $Uri
        Headers     = @{
            Authorization = 'Bearer {0}' -f $AppToken
        }
        InFile      = $Path
        Method      = 'Put'
        ContentType = 'application/octet-stream; charset=utf-8'
    }

    Invoke-RestMethod @requestParameters
}
#EndRegion './Public/Invoke-UniversalDeploymentUpload.ps1' 57
#Region './Public/New-UniversalAutomationRepositoryPackage.ps1' -1

function New-UniversalAutomationRepositoryPackage
{
    <#
        .SYNOPSIS
            Creates an offline PowerShell Universal automation repository package.

        .DESCRIPTION
            Stages the built module and its required modules using the PowerShell
            Universal repository layout, creates repository.psd1, and compresses it.

        .PARAMETER BuiltModuleManifest
            Path to the manifest of the built project module.

        .PARAMETER OutputDirectory
            Directory where the staging folder and zip package are created.

        .PARAMETER ModuleVersion
            Built module version used for the repository manifest and default zip name.

        .PARAMETER StagingDirectoryName
            Name of the temporary repository staging directory.

        .PARAMETER ZipName
            Optional explicit name for the generated zip package.

        .EXAMPLE
            New-UniversalAutomationRepositoryPackage @packageParameters -Confirm:$false
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([System.IO.FileInfo])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $BuiltModuleManifest,

        [Parameter(Mandatory = $true)]
        [System.String]
        $OutputDirectory,

        [Parameter(Mandatory = $true)]
        [System.String]
        $ModuleVersion,

        [Parameter()]
        [System.String]
        $StagingDirectoryName = 'PsuRepository',

        [Parameter()]
        [System.String]
        $ZipName
    )

    if (-not (Test-Path -Path $BuiltModuleManifest))
    {
        throw "Built module manifest '$BuiltModuleManifest' was not found."
    }

    $module = Test-ModuleManifest -Path $BuiltModuleManifest -ErrorAction Stop
    $stagingDirectory = Join-Path -Path $OutputDirectory -ChildPath $StagingDirectoryName
    $modulesDestination = Join-Path -Path $stagingDirectory -ChildPath 'Modules'
    $resolvedZipName = if ([System.String]::IsNullOrWhiteSpace($ZipName))
    {
        '{0}.{1}.zip' -f $module.Name, $ModuleVersion
    }
    else
    {
        $ZipName
    }
    $zipPath = Join-Path -Path $OutputDirectory -ChildPath $resolvedZipName

    if ($PSCmdlet.ShouldProcess($zipPath, 'Create PowerShell Universal automation repository package'))
    {
        if (Test-Path -Path $stagingDirectory)
        {
            Remove-Item -Path $stagingDirectory -Recurse -Force
        }

        $null = New-Item -Path $modulesDestination -ItemType Directory -Force
        Copy-UniversalRepositoryModule -Module $module -ModulesDestinationPath $modulesDestination -Visited @{ }

        $repositoryManifestPath = Join-Path -Path $stagingDirectory -ChildPath 'repository.psd1'
        New-UniversalAutomationRepositoryManifest -Path $repositoryManifestPath -Module $module -ModuleVersion $ModuleVersion -Confirm:$false

        if (Test-Path -Path $zipPath)
        {
            Remove-Item -Path $zipPath -Force
        }

        Compress-Archive -Path (Join-Path -Path $stagingDirectory -ChildPath '*') -DestinationPath $zipPath -Force
        Get-Item -Path $zipPath
    }
}
#EndRegion './Public/New-UniversalAutomationRepositoryPackage.ps1' 94
#Region './Public/Resolve-UniversalServerConfiguration.ps1' -1

function Resolve-UniversalServerConfiguration
{
    <#
        .SYNOPSIS
            Resolves PowerShell Universal deployment settings for build tasks.

        .DESCRIPTION
            Combines explicit task parameters, the UniversalServer section from
            build.yaml, and build environment variables into one validated object.

        .PARAMETER BuildInfo
            Build configuration data loaded by Sampler from build.yaml.

        .PARAMETER ServerUrl
            Explicit PowerShell Universal server URL.

        .PARAMETER AppToken
            Explicit PowerShell Universal application token.

        .PARAMETER RepositoryName
            Explicit PowerShell resource repository name.

        .PARAMETER RepositoryUrl
            Explicit PowerShell resource repository URL or local path.

        .PARAMETER RepositoryAutoRemove
            Whether the resource repository is removed after deployment.

        .PARAMETER Unpinned
            Whether the uploaded automation repository is deployed unpinned.

        .PARAMETER RepositoryAutoRemoveWasBound
            Indicates that RepositoryAutoRemove was supplied explicitly.

        .PARAMETER UnpinnedWasBound
            Indicates that Unpinned was supplied explicitly.

        .PARAMETER RequireRepository
            Requires and resolves resource repository settings.

        .EXAMPLE
            Resolve-UniversalServerConfiguration -BuildInfo $BuildInfo
    #>

    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param
    (
        [Parameter()]
        [System.Collections.Hashtable]
        $BuildInfo = @{ },

        [Parameter()]
        [System.String]
        $ServerUrl,

        [Parameter()]
        [System.String]
        $AppToken,

        [Parameter()]
        [System.String]
        $RepositoryName,

        [Parameter()]
        [System.String]
        $RepositoryUrl,

        [Parameter()]
        [System.Boolean]
        $RepositoryAutoRemove = $true,

        [Parameter()]
        [System.Boolean]
        $Unpinned = $true,

        [Parameter()]
        [System.Boolean]
        $RepositoryAutoRemoveWasBound = $false,

        [Parameter()]
        [System.Boolean]
        $UnpinnedWasBound = $false,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $RequireRepository
    )

    $server = $BuildInfo.UniversalServer

    if ([System.String]::IsNullOrWhiteSpace($ServerUrl))
    {
        $ServerUrl = $server.UniversalServerUrl
    }

    if ([System.String]::IsNullOrWhiteSpace($ServerUrl))
    {
        $ServerUrl = $env:UniversalServerUrl
    }

    if ([System.String]::IsNullOrWhiteSpace($AppToken))
    {
        $AppToken = $server.UniversalServerAppToken
    }

    if ([System.String]::IsNullOrWhiteSpace($AppToken))
    {
        $AppToken = $env:UniversalServerAppToken
    }

    if ([System.String]::IsNullOrWhiteSpace($ServerUrl))
    {
        throw 'UniversalServerUrl is required. Set UniversalServer.UniversalServerUrl in build.yaml or provide the build parameter.'
    }

    if ([System.String]::IsNullOrWhiteSpace($AppToken))
    {
        throw 'UniversalServerAppToken is required. Provide it through the build environment or a local secrets file.'
    }

    if ([System.String]::IsNullOrWhiteSpace($RepositoryName))
    {
        $RepositoryName = $server.UniversalPSResourceRepositoryName
    }

    if ([System.String]::IsNullOrWhiteSpace($RepositoryUrl))
    {
        $RepositoryUrl = $server.UniversalPSResourceRepositoryUrl
    }

    if ($RequireRepository -and [System.String]::IsNullOrWhiteSpace($RepositoryName))
    {
        $RepositoryName = 'output'
    }

    if ($RequireRepository -and [System.String]::IsNullOrWhiteSpace($RepositoryUrl))
    {
        $RepositoryUrl = './output/'
    }

    if (-not $RepositoryAutoRemoveWasBound -and $null -ne $server.UniversalPSResourceRepositoryAutoRemove)
    {
        $RepositoryAutoRemove = [System.Convert]::ToBoolean($server.UniversalPSResourceRepositoryAutoRemove)
    }

    if (-not $UnpinnedWasBound -and $null -ne $server.UniversalUnpinned)
    {
        $Unpinned = [System.Convert]::ToBoolean($server.UniversalUnpinned)
    }

    [PSCustomObject]@{
        ServerUrl            = $ServerUrl.TrimEnd('/')
        AppToken             = $AppToken
        RepositoryName       = $RepositoryName
        RepositoryUrl        = $RepositoryUrl
        RepositoryAutoRemove = $RepositoryAutoRemove
        Unpinned             = $Unpinned
    }
}
#EndRegion './Public/Resolve-UniversalServerConfiguration.ps1' 160