From b1061abb2c3acf81374fae40e89c463e7fe3ba35 Mon Sep 17 00:00:00 2001 From: Gael Colas Date: Mon, 20 Jul 2026 17:50:48 +0200 Subject: [PATCH] Refactor PowerShell Universal Tasks and enhance deployment error handling - Renamed `Copy-UniversalAutomationRepositoryModule` to `Copy-UniversalRepositoryModule` for clarity and consistency. - Removed the obsolete `Task.Publish_PowerShellUniversal.ps1` file. - Introduced new parameters for deployment notification delays and filters in `Publish.PowerShellUniversal.build.ps1`. - Added `assert_universal_deployment_succeeded` task to validate deployment notifications and errors. - Updated documentation to reflect new configuration options and usage examples for deployment validation. - Implemented `Get-UniversalDeploymentError` function to query and filter deployment error notifications from PowerShell Universal. - Created unit tests for new functions and tasks to ensure proper functionality and error handling. - Established a new `Copy-UniversalRepositoryModule` function to handle module copying and dependency resolution in a PSU repository layout. --- .../build-task-modules.instructions.md | 16 +- .../private-functions.instructions.md | 2 +- .../public-functions.instructions.md | 2 +- .../task-module-export.instructions.md | 6 +- .github/workflows/ci.yml | 18 +- CHANGELOG.md | 2 + README.md | 19 +- build.yaml | 2 +- ...py-UniversalAutomationRepositoryModule.ps1 | 78 --------- .../Copy-UniversalRepositoryModule.ps1 | 121 +++++++++++++ .../Public/Get-UniversalDeploymentError.ps1 | 111 ++++++++++++ ...w-UniversalAutomationRepositoryPackage.ps1 | 2 +- .../Task.Publish_PowerShellUniversal.ps1 | 15 -- .../Publish.PowerShellUniversal.build.ps1 | 126 ++++++++++++++ source/WikiSource/Configuration.md | 54 +++++- source/WikiSource/Getting-Started.md | 21 +-- source/WikiSource/Home.md | 3 + source/WikiSource/Task-Reference.md | 96 ++++++++++- source/prefix.ps1 | 9 + ...versalAutomationRepositoryModule.Tests.ps1 | 43 ----- .../Copy-UniversalRepositoryModule.Tests.ps1 | 163 ++++++++++++++++++ .../Get-UniversalDeploymentError.Tests.ps1 | 120 +++++++++++++ ...ersalAutomationRepositoryPackage.Tests.ps1 | 4 +- tests/Unit/TaskModule.Tests.ps1 | 24 +++ 24 files changed, 890 insertions(+), 167 deletions(-) delete mode 100644 source/Private/Copy-UniversalAutomationRepositoryModule.ps1 create mode 100644 source/Private/Copy-UniversalRepositoryModule.ps1 create mode 100644 source/Public/Get-UniversalDeploymentError.ps1 delete mode 100644 source/Public/Task.Publish_PowerShellUniversal.ps1 create mode 100644 source/prefix.ps1 delete mode 100644 tests/Unit/Private/Copy-UniversalAutomationRepositoryModule.Tests.ps1 create mode 100644 tests/Unit/Private/Copy-UniversalRepositoryModule.Tests.ps1 create mode 100644 tests/Unit/Public/Get-UniversalDeploymentError.Tests.ps1 diff --git a/.github/instructions/build-task-modules.instructions.md b/.github/instructions/build-task-modules.instructions.md index 8818156..bf51dca 100644 --- a/.github/instructions/build-task-modules.instructions.md +++ b/.github/instructions/build-task-modules.instructions.md @@ -50,11 +50,23 @@ task My_Task { } ``` +```powershell +# Preferred: compose tasks shipped by this module. +task Deploy_And_Validate Deploy_Module, Assert_Deployment + +# Avoid: consumer workflows and unrelated external tasks are not portable. +task Deploy_And_Validate build, package_module_nupkg, Deploy_Module, Assert_Deployment +``` + ## Task definitions - Prefix each task body with a `# Synopsis:` comment. - Use compound tasks without a body when a task only sequences other tasks. - Treat compound tasks as the stable public surface for local workflows. +- Keep compound and meta-task dependencies within tasks shipped by this module. +- Do not depend on consumer `build.yaml` workflow names such as `build`, `test`, `pack`, or `publish`. +- Do not depend on tasks from other task modules unless they are deliberate, stable Sampler atomic tasks or meta-task APIs. +- Prefer leaving external build/package prerequisites to the consumer workflow instead of coupling this module to them. - Use `Write-Build -Color -Text ` for task output. - Use `Green` for success, `Yellow` for warnings, and `DarkGray` for verbose detail. - Keep `Write-Build` calls in the task file, not in helper modules. @@ -76,6 +88,8 @@ task Link_Local_Workspace_Dependencies { ## Task export -- Add one `source/Public/Task.*.ps1` alias definition per shipped task file. +- Add task-file alias definitions to `source/prefix.ps1`. - Point the alias at the task file under the built module's `Tasks` directory. - Add the alias name to `AliasesToExport` in `build.yaml`. +- Keep `prefix: prefix.ps1` enabled. +- Do not use alias-only files under `source/Public`. diff --git a/.github/instructions/private-functions.instructions.md b/.github/instructions/private-functions.instructions.md index fee6414..0d05fe4 100644 --- a/.github/instructions/private-functions.instructions.md +++ b/.github/instructions/private-functions.instructions.md @@ -17,6 +17,7 @@ Private functions follow the same baseline engineering rules as public functions - Use `[CmdletBinding()]` and include `[OutputType(...)]`. - Use explicit .NET parameter types (`[System.String]`, `[System.Boolean]`, etc.). - Keep parameter names and defaults stable when consumed by public functions/tasks. +- Do not use `Automation` in new function names. Prefer specific terms such as `Universal`, `Repository`, `Deployment`, `Module`, or `Job`. - Follow DSC Community parameter style: `[Parameter()]` attribute, type, and variable name each on their own line, with a blank line between comma-separated parameter declarations: ```powershell @@ -61,4 +62,3 @@ param - Validate both happy path and input validation failures. - Call private functions via `InModuleScope` when testing them directly. - Follow repository test conventions from `.github/instructions/test-writing.instructions.md`. - diff --git a/.github/instructions/public-functions.instructions.md b/.github/instructions/public-functions.instructions.md index 585e2d3..e4d4d92 100644 --- a/.github/instructions/public-functions.instructions.md +++ b/.github/instructions/public-functions.instructions.md @@ -17,6 +17,7 @@ Public functions are user-facing and are the most critical API surface of Sample - Use `[CmdletBinding()]` and include `[OutputType(...)]`. - Use explicit .NET parameter types (`[System.String]`, `[System.Boolean]`, etc.). - Keep parameter names and defaults stable unless intentionally introducing a breaking change. +- Do not use `Automation` in new function names. Prefer specific terms such as `Universal`, `Repository`, `Deployment`, `Module`, or `Job`. - Follow DSC Community parameter style: `[Parameter()]` attribute, type, and variable name each on their own line, with a blank line between comma-separated parameter declarations: ```powershell @@ -101,4 +102,3 @@ $p = Join-Path -Path $p -ChildPath "$ModuleName.psd1" - When commands support both non-interactive and prompted flows, cover both modes. - Call the function under test with its module-qualified name (`Sampler.PowerShellUniversalTasks\Get-Foo`) to avoid accidentally calling a mock or a stale imported version. - Follow repository test conventions from `.github/instructions/test-writing.instructions.md`. - diff --git a/.github/instructions/task-module-export.instructions.md b/.github/instructions/task-module-export.instructions.md index 42de9fc..09b2973 100644 --- a/.github/instructions/task-module-export.instructions.md +++ b/.github/instructions/task-module-export.instructions.md @@ -1,6 +1,6 @@ --- description: 'Task module alias export instructions' -applyTo: '{source/Public/Task.*.ps1,source/Tasks/*.build.ps1,build.yaml}' +applyTo: '{source/prefix.ps1,source/Tasks/*.build.ps1,build.yaml}' --- # InvokeBuild Task Alias Export Guidelines @@ -8,7 +8,7 @@ applyTo: '{source/Public/Task.*.ps1,source/Tasks/*.build.ps1,build.yaml}' ## Purpose - Keep shipped InvokeBuild task files under `source/Tasks`. -- Define each exported task-file alias in a `source/Public/Task.*.ps1` file. +- Define exported task-file aliases in `source/prefix.ps1`. - The alias pattern must remain compatible with Sampler `ModuleBuildTasks`. ## Export rules @@ -16,11 +16,13 @@ applyTo: '{source/Public/Task.*.ps1,source/Tasks/*.build.ps1,build.yaml}' - Point each alias at its task file under the built module's `Tasks` directory. - Name aliases with the `Task.` prefix. - List every task alias under `AliasesToExport` in `build.yaml`. +- Do not place alias-only scripts under `source/Public`; ModuleBuilder can incorrectly add their basenames to `FunctionsToExport`. - Keep the implementation compatible with Windows PowerShell 5.1 and PowerShell 7. ## Build wiring - Keep `Tasks` in `CopyPaths`. +- Keep `prefix: prefix.ps1` enabled in `build.yaml`. - Put reusable task logic in one-function-per-file module functions under `source/Public` or `source/Private`. - Do not add sibling task helper modules. - Verify the built module exports every configured alias and ships every task file. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ff6965..75ccc35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: - main tags: - 'v*' + - '!v*-*' paths-ignore: - CHANGELOG.md pull_request: @@ -40,10 +41,19 @@ jobs: id: gitversion shell: pwsh run: | - dotnet tool install --global GitVersion.Tool --version 5.* - $gitVersion = dotnet-gitversion /output json | ConvertFrom-Json - "ModuleVersion=$($gitVersion.NuGetVersionV2)" >> $env:GITHUB_ENV - "module-version=$($gitVersion.NuGetVersionV2)" >> $env:GITHUB_OUTPUT + if ($env:GITHUB_REF_TYPE -eq 'tag') + { + $moduleVersion = $env:GITHUB_REF_NAME -replace '^v', '' + } + else + { + dotnet tool install --global GitVersion.Tool --version 5.* + $gitVersion = dotnet-gitversion /output json | ConvertFrom-Json + $moduleVersion = $gitVersion.NuGetVersionV2 + } + + "ModuleVersion=$moduleVersion" >> $env:GITHUB_ENV + "module-version=$moduleVersion" >> $env:GITHUB_OUTPUT - name: Build and package module shell: pwsh diff --git a/CHANGELOG.md b/CHANGELOG.md index aedc187..14c1b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added reusable Sampler tasks for packaging and deploying PowerShell Universal modules. - Added task-file alias exports compatible with `ModuleBuildTasks` in Sampler repositories. - Added WikiSource documentation and DscResource.DocGenerator build and publish workflows. +- Added post-deployment notification validation that fails the build when PowerShell Universal reports a deployment error. +- Added module-scoped compound tasks for pulling a packaged module from a PSU resource repository or packaging and pushing a complete offline repository, including post-deployment validation. ### Changed diff --git a/README.md b/README.md index 294516d..a936976 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,24 @@ The module provides tasks to: - publish a packed module directly to a PowerShell Universal server; - install a module from a PowerShell resource repository; - package a module and its dependencies as an offline automation repository; -- deploy an offline automation repository package. +- deploy an offline automation repository package; +- fail a workflow when PSU reports a deployment error notification. + +Consumer workflows can use the compound tasks +`publish_psu_pull_module_from_psresourcerepo` or +`publish_psu_push_repository` to group this module's deployment and validation +steps. Consumer build and package prerequisites remain in the consumer workflow. + +```yaml +BuildWorkflow: + deploy: + - pack + - publish_psu_pull_module_from_psresourcerepo + + deploy_repository: + - build + - publish_psu_push_repository +``` Configure the server under `UniversalServer` in `build.yaml`. Supply `UniversalServerAppToken` through the build environment or a local secrets file; diff --git a/build.yaml b/build.yaml index 2928cf3..ffd3e63 100644 --- a/build.yaml +++ b/build.yaml @@ -20,7 +20,7 @@ Encoding: UTF8 #SemVer: '99.0.0-preview1' # suffix: suffix.ps1 -# prefix: prefix.ps1 +prefix: prefix.ps1 VersionedOutputDirectory: true AliasesToExport: diff --git a/source/Private/Copy-UniversalAutomationRepositoryModule.ps1 b/source/Private/Copy-UniversalAutomationRepositoryModule.ps1 deleted file mode 100644 index 8afdc0f..0000000 --- a/source/Private/Copy-UniversalAutomationRepositoryModule.ps1 +++ /dev/null @@ -1,78 +0,0 @@ -function Copy-UniversalAutomationRepositoryModule -{ - <# - .SYNOPSIS - Copies a module and its dependencies into a PSU repository layout. - - .DESCRIPTION - Recursively copies a module and each RequiredModules dependency into - versioned folders below the automation repository Modules directory. - - .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-UniversalAutomationRepositoryModule -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 - ) - - if ($Visited.ContainsKey($Module.Name)) - { - return - } - - $Visited[$Module.Name] = $true - $moduleDestination = Join-Path -Path $ModulesDestinationPath -ChildPath $Module.Name - $moduleDestination = Join-Path -Path $moduleDestination -ChildPath $Module.Version.ToString() - - $null = New-Item -Path $moduleDestination -ItemType Directory -Force - Copy-Item -Path (Join-Path -Path $Module.ModuleBase -ChildPath '*') -Destination $moduleDestination -Recurse -Force - - $manifestPath = Join-Path -Path $Module.ModuleBase -ChildPath ('{0}.psd1' -f $Module.Name) - if (-not (Test-Path -Path $manifestPath)) - { - return - } - - $moduleInfo = Get-SamplerModuleInfo -ModuleManifestPath $manifestPath - foreach ($requiredModule in @($moduleInfo.RequiredModules)) - { - if (-not $requiredModule) - { - continue - } - - $moduleSpecification = [Microsoft.PowerShell.Commands.ModuleSpecification] $requiredModule - $resolvedModule = Get-Module -ListAvailable -FullyQualifiedName $moduleSpecification | - Sort-Object -Property Version -Descending | - Select-Object -First 1 - - if (-not $resolvedModule) - { - throw "Required module '$($moduleSpecification.Name)' for '$($Module.Name)' was not found on PSModulePath." - } - - Copy-UniversalAutomationRepositoryModule -Module $resolvedModule -ModulesDestinationPath $ModulesDestinationPath -Visited $Visited - } -} diff --git a/source/Private/Copy-UniversalRepositoryModule.ps1 b/source/Private/Copy-UniversalRepositoryModule.ps1 new file mode 100644 index 0000000..7df4d5c --- /dev/null +++ b/source/Private/Copy-UniversalRepositoryModule.ps1 @@ -0,0 +1,121 @@ +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 + } +} diff --git a/source/Public/Get-UniversalDeploymentError.ps1 b/source/Public/Get-UniversalDeploymentError.ps1 new file mode 100644 index 0000000..e7247b4 --- /dev/null +++ b/source/Public/Get-UniversalDeploymentError.ps1 @@ -0,0 +1,111 @@ +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 + } +} diff --git a/source/Public/New-UniversalAutomationRepositoryPackage.ps1 b/source/Public/New-UniversalAutomationRepositoryPackage.ps1 index 287eb91..68b806a 100644 --- a/source/Public/New-UniversalAutomationRepositoryPackage.ps1 +++ b/source/Public/New-UniversalAutomationRepositoryPackage.ps1 @@ -77,7 +77,7 @@ function New-UniversalAutomationRepositoryPackage } $null = New-Item -Path $modulesDestination -ItemType Directory -Force - Copy-UniversalAutomationRepositoryModule -Module $module -ModulesDestinationPath $modulesDestination -Visited @{ } + 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 diff --git a/source/Public/Task.Publish_PowerShellUniversal.ps1 b/source/Public/Task.Publish_PowerShellUniversal.ps1 deleted file mode 100644 index 65856c2..0000000 --- a/source/Public/Task.Publish_PowerShellUniversal.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -<# - .SYNOPSIS - Defines the exported alias for the PowerShell Universal InvokeBuild task file. - - .DESCRIPTION - Creates the Task.Publish_PowerShellUniversal alias used by InvokeBuild task - discovery to load the PowerShell Universal tasks shipped with this module. - - .EXAMPLE - Get-Alias -Name 'Task.Publish_PowerShellUniversal' -#> - -$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 diff --git a/source/Tasks/Publish.PowerShellUniversal.build.ps1 b/source/Tasks/Publish.PowerShellUniversal.build.ps1 index 44c5e64..e31d4fe 100644 --- a/source/Tasks/Publish.PowerShellUniversal.build.ps1 +++ b/source/Tasks/Publish.PowerShellUniversal.build.ps1 @@ -40,6 +40,20 @@ param [System.Boolean] $UniversalUnpinned = (property UniversalUnpinned $true), + [Parameter()] + [ValidateRange(0, 300)] + [System.Int32] + $UniversalDeploymentNotificationDelaySeconds = (property UniversalDeploymentNotificationDelaySeconds 2), + + [Parameter()] + [ValidateRange(1, 3600)] + [System.Int32] + $UniversalDeploymentNotificationLookbackSeconds = (property UniversalDeploymentNotificationLookbackSeconds 120), + + [Parameter()] + [System.String] + $UniversalDeploymentNotificationFilter = (property UniversalDeploymentNotificationFilter ''), + [Parameter()] [System.Collections.Hashtable] $BuildInfo = (property BuildInfo @{ }) @@ -76,6 +90,7 @@ task publish_packed_module_to_universal_server { $ModuleVersion ) + $script:UniversalDeploymentStartedAt = [System.DateTimeOffset]::UtcNow $null = Sampler.PowerShellUniversalTasks\Invoke-UniversalDeploymentUpload -Uri $endpoint -AppToken $configuration.AppToken -Path $packagePath Write-Build -Color Green -Text ("Published module '{0}' version '{1}' to PowerShell Universal." -f $ProjectName, $ModuleVersion) } @@ -120,6 +135,7 @@ task publish_module_from_psresource_repos_to_universal_server { RepositoryAutoRemove = $configuration.RepositoryAutoRemove } + $script:UniversalDeploymentStartedAt = [System.DateTimeOffset]::UtcNow Sampler.PowerShellUniversalTasks\Install-UniversalModuleFromRepository @installParameters Write-Build -Color Green -Text ("Installed module '{0}' version '{1}' from repository '{2}'." -f $ProjectName, $ModuleVersion, $configuration.RepositoryName) } @@ -183,6 +199,116 @@ task publish_universal_automation_repository_to_server { $configuration.Unpinned.ToString().ToLowerInvariant() ) + $script:UniversalDeploymentStartedAt = [System.DateTimeOffset]::UtcNow $deployment = Sampler.PowerShellUniversalTasks\Invoke-UniversalDeploymentUpload -Uri $endpoint -AppToken $configuration.AppToken -Path $packagePath Write-Build -Color Green -Text ("Deployed automation repository '{0}' version '{1}'." -f $deployment.name, $deployment.version) } + +# Synopsis: Fail the build when PowerShell Universal reports a deployment error notification. +task assert_universal_deployment_succeeded { + # Get the values for task variables, see https://github.com/gaelcolas/Sampler?tab=readme-ov-file#build-task-variables. + . Set-SamplerTaskVariable + $secretsPath = Join-Path -Path $BuildRoot -ChildPath 'secrets.local.ps1' + if (Test-Path -Path $secretsPath) + { + . $secretsPath + } + + $configurationParameters = @{ + BuildInfo = $BuildInfo + ServerUrl = $UniversalServerUrl + AppToken = $UniversalServerAppToken + RepositoryName = $UniversalPSResourceRepositoryName + RepositoryUrl = $UniversalPSResourceRepositoryUrl + RepositoryAutoRemove = $UniversalPSResourceRepositoryAutoRemove + Unpinned = $UniversalUnpinned + RepositoryAutoRemoveWasBound = 'UniversalPSResourceRepositoryAutoRemove' -in $TaskParameterNames + UnpinnedWasBound = 'UniversalUnpinned' -in $TaskParameterNames + } + $configuration = Sampler.PowerShellUniversalTasks\Resolve-UniversalServerConfiguration @configurationParameters + + $universalServer = $BuildInfo.UniversalServer + if ($UniversalDeploymentNotificationDelaySeconds -eq 2 -and + $null -ne $universalServer.UniversalDeploymentNotificationDelaySeconds) + { + $UniversalDeploymentNotificationDelaySeconds = [System.Convert]::ToInt32( + $universalServer.UniversalDeploymentNotificationDelaySeconds + ) + } + + if ($UniversalDeploymentNotificationLookbackSeconds -eq 120 -and + $null -ne $universalServer.UniversalDeploymentNotificationLookbackSeconds) + { + $UniversalDeploymentNotificationLookbackSeconds = [System.Convert]::ToInt32( + $universalServer.UniversalDeploymentNotificationLookbackSeconds + ) + } + + if ([System.String]::IsNullOrWhiteSpace($UniversalDeploymentNotificationFilter) -and + -not [System.String]::IsNullOrWhiteSpace($universalServer.UniversalDeploymentNotificationFilter)) + { + $UniversalDeploymentNotificationFilter = [System.String] $universalServer.UniversalDeploymentNotificationFilter + } + + if ($UniversalDeploymentNotificationDelaySeconds -lt 0 -or + $UniversalDeploymentNotificationDelaySeconds -gt 300) + { + throw 'UniversalDeploymentNotificationDelaySeconds must be between 0 and 300.' + } + + if ($UniversalDeploymentNotificationLookbackSeconds -lt 1 -or + $UniversalDeploymentNotificationLookbackSeconds -gt 3600) + { + throw 'UniversalDeploymentNotificationLookbackSeconds must be between 1 and 3600.' + } + + if ($UniversalDeploymentNotificationDelaySeconds -gt 0) + { + Write-Build -Color DarkGray -Text ("Waiting {0} second(s) for deployment notifications." -f $UniversalDeploymentNotificationDelaySeconds) + Start-Sleep -Seconds $UniversalDeploymentNotificationDelaySeconds + } + + $notificationSince = $script:UniversalDeploymentStartedAt + if (-not $notificationSince) + { + $notificationSince = [System.DateTimeOffset]::UtcNow.AddSeconds(-$UniversalDeploymentNotificationLookbackSeconds) + Write-Build -Color Yellow -Text ("Deployment start time was not recorded. Checking the last {0} second(s)." -f $UniversalDeploymentNotificationLookbackSeconds) + } + + $notificationFilter = $UniversalDeploymentNotificationFilter + $errorParameters = @{ + ServerUrl = $configuration.ServerUrl + AppToken = $configuration.AppToken + Since = $notificationSince + FilterText = $notificationFilter + } + $deploymentErrors = @(Sampler.PowerShellUniversalTasks\Get-UniversalDeploymentError @errorParameters) + + if ($deploymentErrors.Count -gt 0) + { + $errorSummary = $deploymentErrors | ForEach-Object -Process { + '[{0}] [{1}] {2}: {3}' -f $_.CreatedTime, $_.Level, $_.Title, $_.Description + } + + Write-Build -Color Red -Text 'PowerShell Universal deployment validation failed:' + foreach ($errorMessage in $errorSummary) + { + Write-Build -Color Red -Text (" {0}" -f $errorMessage) + } + + $failureMessage = 'PowerShell Universal deployment validation failed after {0}:{1}{2}' -f @( + $notificationSince + [System.Environment]::NewLine + ($errorSummary -join [System.Environment]::NewLine) + ) + throw $failureMessage + } + + Write-Build -Color Green -Text ("No PowerShell Universal deployment error notifications were found after {0}." -f $notificationSince) +} + +# Synopsis: Install the module from a PSU resource repository and validate deployment notifications. +task publish_psu_pull_module_from_psresourcerepo publish_module_from_psresource_repos_to_universal_server, assert_universal_deployment_succeeded + +# Synopsis: Package and deploy an offline PSU automation repository and validate deployment notifications. +task publish_psu_push_repository package_universal_automation_repository, publish_universal_automation_repository_to_server, assert_universal_deployment_succeeded diff --git a/source/WikiSource/Configuration.md b/source/WikiSource/Configuration.md index 1e50335..5441493 100644 --- a/source/WikiSource/Configuration.md +++ b/source/WikiSource/Configuration.md @@ -16,6 +16,9 @@ UniversalServer: UniversalRepositoryZipName: UniversalRepositoryAsModule: false UniversalUnpinned: true + UniversalDeploymentNotificationDelaySeconds: 2 + UniversalDeploymentNotificationLookbackSeconds: 120 + UniversalDeploymentNotificationFilter: ``` | Setting | Default | Description | @@ -29,6 +32,9 @@ UniversalServer: | `UniversalRepositoryZipName` | `..zip` | Optional explicit offline repository package name. | | `UniversalRepositoryAsModule` | `false` | Value sent to the deployment endpoint's `asModule` query parameter. | | `UniversalUnpinned` | `true` | Value sent to the deployment endpoint's `unpinned` query parameter. | +| `UniversalDeploymentNotificationDelaySeconds` | `2` | Seconds to wait before querying notifications after deployment. Valid range: 0-300. | +| `UniversalDeploymentNotificationLookbackSeconds` | `120` | Fallback lookback window when no deployment start time was recorded in the current workflow. Valid range: 1-3600. | +| `UniversalDeploymentNotificationFilter` | Empty | Optional text that must appear in a deployment error title or description. | ## Authentication @@ -75,9 +81,51 @@ PsuRepository/ `-- 2.0.0/ ``` -The zip contains the project module and recursively resolved `RequiredModules` -dependencies. `repository.psd1` identifies the project module that PowerShell -Universal should load from the `Modules` directory. +The zip contains the project module and transitively resolved `RequiredModules` +dependencies. Dependencies are processed through an iterative queue, matching +Sampler's packaging approach and avoiding nested recursive calls. The first +discovery order is retained; when the same module is required again, its +selection changes only if the newly resolved version is higher. +`repository.psd1` identifies the project module that PowerShell Universal +should load from the `Modules` directory. Keep `UniversalRepositoryAsModule` set to `false` for this full repository layout. + +## Post-deployment validation + +Add `assert_universal_deployment_succeeded` immediately after a deployment +task. The deployment tasks record their UTC start time, and the assertion task +queries: + +```text +GET /api/v1/notification/last +``` + +The workflow fails when a newer deployment, module, or configuration +notification: + +- has level `Error`; +- contains `failed` or `error`; or +- contains `Invalid configuration`. + +If the assertion task runs without a deployment task in the same InvokeBuild +workflow, it uses `UniversalDeploymentNotificationLookbackSeconds`. + +Use `UniversalDeploymentNotificationFilter` when multiple deployments can run +against the same PSU instance concurrently and notifications contain a stable +module or project identifier. + +For example, restrict deployment errors to notifications containing the module +name: + +```yaml +UniversalServer: + UniversalDeploymentNotificationFilter: MyModule +``` + +The filter is case-insensitive and applies to the combined notification title +and description. Leave it empty to consider every recent deployment error. +Only set a filter when PSU includes that value in relevant error notifications; +a filter intentionally excludes generic deployment errors that do not contain +the configured text. diff --git a/source/WikiSource/Getting-Started.md b/source/WikiSource/Getting-Started.md index 4b6b9e7..6c3dc16 100644 --- a/source/WikiSource/Getting-Started.md +++ b/source/WikiSource/Getting-Started.md @@ -71,33 +71,28 @@ Package and deploy the module directly: ```yaml BuildWorkflow: - pack: - - build - - package_module_nupkg - deploy: - pack - - publish_packed_module_to_universal_server + - publish_psu_pull_module_from_psresourcerepo ``` Package and deploy a complete offline automation repository: ```yaml BuildWorkflow: - pack: - - build - - package_module_nupkg - - package_universal_automation_repository - deploy_repository: - - pack - - publish_universal_automation_repository_to_server + - build + - publish_psu_push_repository ``` +The compound tasks intentionally contain only tasks exported by this module. +Keep consumer-specific workflows such as `build` and `pack` in the consumer +`build.yaml` so the task module remains reusable across repositories. + Run workflows through the Sampler entry point: ```powershell -./build.ps1 -Tasks pack +./build.ps1 -Tasks deploy ./build.ps1 -Tasks deploy_repository ``` diff --git a/source/WikiSource/Home.md b/source/WikiSource/Home.md index f0a9abe..c8f4822 100644 --- a/source/WikiSource/Home.md +++ b/source/WikiSource/Home.md @@ -22,6 +22,9 @@ for packaging and deploying PowerShell modules to PowerShell Universal. | `publish_module_from_psresource_repos_to_universal_server` | Register or reconcile a PowerShell resource repository and install the module from it. | | `package_universal_automation_repository` | Package the built module and its dependencies as an offline automation repository. | | `publish_universal_automation_repository_to_server` | Upload and activate an offline automation repository package. | +| `assert_universal_deployment_succeeded` | Query PSU notifications and fail the workflow when a deployment error is reported. | +| `publish_psu_pull_module_from_psresourcerepo` | Build, package, install from a PSU resource repository, and validate deployment. | +| `publish_psu_push_repository` | Build, package, push an offline PSU repository, and validate deployment. | ## Task discovery diff --git a/source/WikiSource/Task-Reference.md b/source/WikiSource/Task-Reference.md index 12def71..5567d27 100644 --- a/source/WikiSource/Task-Reference.md +++ b/source/WikiSource/Task-Reference.md @@ -3,12 +3,21 @@ The exported alias `Task.Publish_PowerShellUniversal` loads one InvokeBuild task file containing the tasks documented on this page. +Low-level tasks perform one packaging, publishing, or validation operation. +Compound tasks sequence only operations exported by this module. Build and +package workflows owned by the consumer repository remain explicit in the +consumer `build.yaml`. + ## Contents - [publish_packed_module_to_universal_server](#publish_packed_module_to_universal_server) - [publish_module_from_psresource_repos_to_universal_server](#publish_module_from_psresource_repos_to_universal_server) - [package_universal_automation_repository](#package_universal_automation_repository) - [publish_universal_automation_repository_to_server](#publish_universal_automation_repository_to_server) +- [assert_universal_deployment_succeeded](#assert_universal_deployment_succeeded) +- [publish_psu_pull_module_from_psresourcerepo](#publish_psu_pull_module_from_psresourcerepo) +- [publish_psu_push_repository](#publish_psu_push_repository) +- [Compound task dependency boundary](#compound-task-dependency-boundary) - [Common parameters](#common-parameters) ## publish_packed_module_to_universal_server @@ -61,7 +70,10 @@ BuildWorkflow: ## package_universal_automation_repository Creates an offline PowerShell Universal automation repository zip containing -the built module and all recursively resolved `RequiredModules`. +the built module and all transitively resolved `RequiredModules`. Module +dependencies are processed through an iterative queue rather than nested +recursive calls. First-discovery order is preserved, while a later occurrence +replaces the selected module only when it resolves to a higher version. ### Prerequisites @@ -113,6 +125,85 @@ The task sends `UniversalRepositoryAsModule` and `UniversalUnpinned` as deployment query parameters. For the complete repository layout generated by this module, use `UniversalRepositoryAsModule: false`. +## assert_universal_deployment_succeeded + +Queries recent PowerShell Universal notifications and throws when PSU reports a +deployment-related error after the deployment start time. + +### Typical workflow + +```yaml +BuildWorkflow: + deploy_repository: + - pack + - publish_universal_automation_repository_to_server + - assert_universal_deployment_succeeded +``` + +The deployment tasks record their start time in the current InvokeBuild +session. When no start time is available, the assertion checks the configured +lookback window. + +The task fails on newer deployment, module, or configuration notifications +whose level is `Error` or whose text contains `failed`, `error`, or +`Invalid configuration`. + +An optional notification filter can restrict matches to a module or project +identifier when deployments run concurrently. + +```yaml +UniversalServer: + UniversalDeploymentNotificationFilter: MyModule +``` + +When errors are found, the task writes each timestamp, level, title, and +description to the build log before throwing a terminating error containing +the same summary. + +## publish_psu_pull_module_from_psresourcerepo + +Provides a single workflow entry that runs: + +1. `publish_module_from_psresource_repos_to_universal_server` +1. `assert_universal_deployment_succeeded` + +```yaml +BuildWorkflow: + deploy: + - pack + - publish_psu_pull_module_from_psresourcerepo +``` + +Use this task when PSU should pull the project module and its packaged +dependencies from the configured PowerShell resource repository. The consumer +workflow remains responsible for building and packaging those artifacts. + +## publish_psu_push_repository + +Provides a single workflow entry that runs: + +1. `package_universal_automation_repository` +1. `publish_universal_automation_repository_to_server` +1. `assert_universal_deployment_succeeded` + +```yaml +BuildWorkflow: + deploy: + - build + - publish_psu_push_repository +``` + +Use this task when the build should assemble and push the complete offline +automation repository layout. The consumer workflow remains responsible for +producing the built module consumed by the repository packaging task. + +## Compound task dependency boundary + +The compound tasks do not reference consumer workflows such as `build`, `test`, +`pack`, or `publish`, and do not depend on unrelated external task modules. +This keeps their internal sequence stable when a consumer changes its workflow +names or packaging strategy. + ## Common parameters The task file exposes these values through Sampler properties and environment @@ -130,5 +221,8 @@ variables: | `UniversalRepositoryZipName` | Offline repository zip file name. | | `UniversalRepositoryAsModule` | Deployment endpoint `asModule` value. | | `UniversalUnpinned` | Deployment endpoint `unpinned` value. | +| `UniversalDeploymentNotificationDelaySeconds` | Delay before querying notifications. | +| `UniversalDeploymentNotificationLookbackSeconds` | Fallback lookback window when no deployment start time is recorded. | +| `UniversalDeploymentNotificationFilter` | Optional title or description filter for deployment errors. | See [Configuration](Configuration.md) for defaults and precedence. diff --git a/source/prefix.ps1 b/source/prefix.ps1 new file mode 100644 index 0000000..d5ca5e5 --- /dev/null +++ b/source/prefix.ps1 @@ -0,0 +1,9 @@ +<# + 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 diff --git a/tests/Unit/Private/Copy-UniversalAutomationRepositoryModule.Tests.ps1 b/tests/Unit/Private/Copy-UniversalAutomationRepositoryModule.Tests.ps1 deleted file mode 100644 index 4f6416a..0000000 --- a/tests/Unit/Private/Copy-UniversalAutomationRepositoryModule.Tests.ps1 +++ /dev/null @@ -1,43 +0,0 @@ -BeforeAll { - $script:moduleName = 'Sampler.PowerShellUniversalTasks' - - # If the module is not found, run the build task 'noop'. - if (-not (Get-Module -Name $script:moduleName -ListAvailable)) - { - # Redirect all streams to $null, except the error stream (stream 2) - & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null - } - - # Re-import the module using force to get any code changes between runs. - Import-Module -Name $script:moduleName -Force -ErrorAction 'Stop' - - $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:moduleName - $PSDefaultParameterValues['Mock:ModuleName'] = $script:moduleName - $PSDefaultParameterValues['Should:ModuleName'] = $script:moduleName -} - -AfterAll { - $PSDefaultParameterValues.Remove('Mock:ModuleName') - $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') - $PSDefaultParameterValues.Remove('Should:ModuleName') - - Remove-Module -Name $script:moduleName -} - -Describe 'Copy-UniversalAutomationRepositoryModule' { - BeforeAll { - Mock -CommandName New-Item - Mock -CommandName Copy-Item - Mock -CommandName Test-Path -MockWith { $false } - } - - It 'Should copy the module into its versioned repository directory' { - InModuleScope -ScriptBlock { - $module = New-Module -Name 'MyModule' -ScriptBlock { } - - Copy-UniversalAutomationRepositoryModule -Module $module -ModulesDestinationPath $Destination -Visited @{ } - } -Parameters @{ Destination = $TestDrive } - - Should -Invoke -CommandName Copy-Item -Exactly -Times 1 -Scope It - } -} diff --git a/tests/Unit/Private/Copy-UniversalRepositoryModule.Tests.ps1 b/tests/Unit/Private/Copy-UniversalRepositoryModule.Tests.ps1 new file mode 100644 index 0000000..058cde1 --- /dev/null +++ b/tests/Unit/Private/Copy-UniversalRepositoryModule.Tests.ps1 @@ -0,0 +1,163 @@ +BeforeAll { + $script:moduleName = 'Sampler.PowerShellUniversalTasks' + + # If the module is not found, run the build task 'noop'. + if (-not (Get-Module -Name $script:moduleName -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # Re-import the module using force to get any code changes between runs. + Import-Module -Name $script:moduleName -Force -ErrorAction 'Stop' + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:moduleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + Remove-Module -Name $script:moduleName +} + +Describe 'Copy-UniversalRepositoryModule' { + BeforeAll { + Mock -CommandName New-Item + Mock -CommandName Copy-Item + Mock -CommandName Test-Path -MockWith { $false } + } + + It 'Should copy the module into its versioned repository directory' { + InModuleScope -ScriptBlock { + $module = New-Module -Name 'MyModule' -ScriptBlock { } + + Copy-UniversalRepositoryModule -Module $module -ModulesDestinationPath $Destination -Visited @{ } + } -Parameters @{ Destination = $TestDrive } + + Should -Invoke -CommandName Copy-Item -Exactly -Times 1 -Scope It + } + + It 'Should process transitive dependencies with a queue and avoid cycles' { + Mock -CommandName Test-Path -MockWith { $true } + Mock -CommandName Get-SamplerModuleInfo -MockWith { + if ($ModuleManifestPath -match 'RootModule') + { + return [PSCustomObject]@{ + RequiredModules = @( + @{ + ModuleName = 'DependencyModule' + ModuleVersion = '1.0.0' + } + ) + } + } + + [PSCustomObject]@{ + RequiredModules = @('RootModule') + } + } + Mock -CommandName Get-Module -ParameterFilter { + $ListAvailable -and $FullyQualifiedName.Name -eq 'DependencyModule' + } -MockWith { + New-Module -Name 'DependencyModule' -ScriptBlock { } + } + + InModuleScope -ScriptBlock { + $module = New-Module -Name 'RootModule' -ScriptBlock { } + + Copy-UniversalRepositoryModule -Module $module -ModulesDestinationPath $Destination -Visited @{ } + } -Parameters @{ Destination = $TestDrive } + + Should -Invoke -CommandName Copy-Item -Exactly -Times 2 -Scope It + Should -Invoke -CommandName Get-Module -Exactly -Times 1 -Scope It + Should -Invoke -CommandName Get-SamplerModuleInfo -Exactly -Times 2 -Scope It + } + + It 'Should copy only the highest resolved version when a dependency is required more than once' { + $fixtureRoot = Join-Path -Path $TestDrive -ChildPath 'modules' + $rootModulePath = Join-Path -Path $fixtureRoot -ChildPath 'RootModule' + $branchModulePath = Join-Path -Path $fixtureRoot -ChildPath 'BranchModule' + $sharedV1Path = Join-Path -Path $fixtureRoot -ChildPath 'SharedModuleV1' + $sharedV2Path = Join-Path -Path $fixtureRoot -ChildPath 'SharedModuleV2' + + foreach ($moduleFixture in @( + @{ Name = 'RootModule'; Path = $rootModulePath; Version = '1.0.0' } + @{ Name = 'BranchModule'; Path = $branchModulePath; Version = '1.0.0' } + @{ Name = 'SharedModule'; Path = $sharedV1Path; Version = '1.0.0' } + @{ Name = 'SharedModule'; Path = $sharedV2Path; Version = '2.0.0' } + )) + { + $null = New-Item -Path $moduleFixture.Path -ItemType Directory -Force + $moduleScriptPath = Join-Path -Path $moduleFixture.Path -ChildPath ('{0}.psm1' -f $moduleFixture.Name) + Set-Content -Path $moduleScriptPath -Value '' + $manifestPath = Join-Path -Path $moduleFixture.Path -ChildPath ('{0}.psd1' -f $moduleFixture.Name) + $manifestParameters = @{ + Path = $manifestPath + RootModule = ('{0}.psm1' -f $moduleFixture.Name) + ModuleVersion = $moduleFixture.Version + } + New-ModuleManifest @manifestParameters + } + + $rootModule = Test-ModuleManifest -Path (Join-Path -Path $rootModulePath -ChildPath 'RootModule.psd1') + $branchModule = Test-ModuleManifest -Path (Join-Path -Path $branchModulePath -ChildPath 'BranchModule.psd1') + $sharedV1 = Test-ModuleManifest -Path (Join-Path -Path $sharedV1Path -ChildPath 'SharedModule.psd1') + $sharedV2 = Test-ModuleManifest -Path (Join-Path -Path $sharedV2Path -ChildPath 'SharedModule.psd1') + + Mock -CommandName Test-Path -MockWith { $true } + Mock -CommandName Get-SamplerModuleInfo -MockWith { + if ($ModuleManifestPath -match 'RootModule') + { + return [PSCustomObject]@{ + RequiredModules = @( + @{ ModuleName = 'BranchModule'; RequiredVersion = '1.0.0' } + @{ ModuleName = 'SharedModule'; RequiredVersion = '1.0.0' } + ) + } + } + + if ($ModuleManifestPath -match 'BranchModule') + { + return [PSCustomObject]@{ + RequiredModules = @( + @{ ModuleName = 'SharedModule'; RequiredVersion = '2.0.0' } + ) + } + } + + [PSCustomObject]@{ RequiredModules = @() } + } + Mock -CommandName Get-Module -MockWith { + if ($FullyQualifiedName.Name -eq 'BranchModule') + { + return $branchModule + } + + if ($FullyQualifiedName.RequiredVersion -eq [System.Version] '2.0.0') + { + return $sharedV2 + } + + $sharedV1 + } + + InModuleScope -ScriptBlock { + Copy-UniversalRepositoryModule -Module $RootModule -ModulesDestinationPath $Destination -Visited @{ } + } -Parameters @{ + RootModule = $rootModule + Destination = $TestDrive + } + + $expectedSharedDestination = Join-Path -Path $TestDrive -ChildPath 'SharedModule' + $expectedSharedDestination = Join-Path -Path $expectedSharedDestination -ChildPath '2.0.0' + + Should -Invoke -CommandName Copy-Item -Exactly -Times 3 -Scope It + Should -Invoke -CommandName Copy-Item -Exactly -Times 1 -Scope It -ParameterFilter { + $Destination -eq $expectedSharedDestination + } + } +} diff --git a/tests/Unit/Public/Get-UniversalDeploymentError.Tests.ps1 b/tests/Unit/Public/Get-UniversalDeploymentError.Tests.ps1 new file mode 100644 index 0000000..aa59387 --- /dev/null +++ b/tests/Unit/Public/Get-UniversalDeploymentError.Tests.ps1 @@ -0,0 +1,120 @@ +BeforeAll { + $script:moduleName = 'Sampler.PowerShellUniversalTasks' + + # If the module is not found, run the build task 'noop'. + if (-not (Get-Module -Name $script:moduleName -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # Re-import the module using force to get any code changes between runs. + Import-Module -Name $script:moduleName -Force -ErrorAction 'Stop' + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:moduleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + Remove-Module -Name $script:moduleName +} + +Describe 'Get-UniversalDeploymentError' { + It 'Should return deployment errors created after the specified time' { + $since = [System.DateTimeOffset]::UtcNow.AddMinutes(-1) + Mock -CommandName Invoke-RestMethod -MockWith { + [PSCustomObject]@{ + page = @( + [PSCustomObject]@{ + CreatedTime = [System.DateTimeOffset]::UtcNow.ToString('O') + Level = 'Error' + Title = 'Deployment error' + Description = 'Module deployment failed.' + } + [PSCustomObject]@{ + CreatedTime = [System.DateTimeOffset]::UtcNow.ToString('O') + Level = 'Information' + Title = 'Deployment completed' + Description = 'Module installed.' + } + ) + } + } + + $parameters = @{ + ServerUrl = 'https://psu.example.test' + AppToken = 'token' + Since = $since + } + $result = @(Sampler.PowerShellUniversalTasks\Get-UniversalDeploymentError @parameters) + + $result.Count | Should -Be 1 + $result[0].Title | Should -Be 'Deployment error' + Should -Invoke -CommandName Invoke-RestMethod -Exactly -Times 1 -Scope It -ParameterFilter { + $Uri -eq 'https://psu.example.test/api/v1/notification/last' -and + $Headers.Authorization -eq 'Bearer token' -and + $Method -eq 'Get' + } + } + + It 'Should ignore deployment errors created before the specified time' { + Mock -CommandName Invoke-RestMethod -MockWith { + [PSCustomObject]@{ + page = @( + [PSCustomObject]@{ + CreatedTime = [System.DateTimeOffset]::UtcNow.AddMinutes(-10).ToString('O') + Level = 'Error' + Title = 'Deployment error' + Description = 'Old deployment failed.' + } + ) + } + } + + $parameters = @{ + ServerUrl = 'https://psu.example.test' + AppToken = 'token' + Since = [System.DateTimeOffset]::UtcNow.AddMinutes(-1) + } + $result = @(Sampler.PowerShellUniversalTasks\Get-UniversalDeploymentError @parameters) + + $result.Count | Should -Be 0 + } + + It 'Should apply the optional notification text filter' { + Mock -CommandName Invoke-RestMethod -MockWith { + [PSCustomObject]@{ + page = @( + [PSCustomObject]@{ + CreatedTime = [System.DateTimeOffset]::UtcNow.ToString('O') + Level = 'Error' + Title = 'Deployment error' + Description = 'MyModule failed to load.' + } + [PSCustomObject]@{ + CreatedTime = [System.DateTimeOffset]::UtcNow.ToString('O') + Level = 'Error' + Title = 'Deployment error' + Description = 'OtherModule failed to load.' + } + ) + } + } + + $parameters = @{ + ServerUrl = 'https://psu.example.test' + AppToken = 'token' + Since = [System.DateTimeOffset]::UtcNow.AddMinutes(-1) + FilterText = 'MyModule' + } + $result = @(Sampler.PowerShellUniversalTasks\Get-UniversalDeploymentError @parameters) + + $result.Count | Should -Be 1 + $result[0].Description | Should -Match 'MyModule' + } +} diff --git a/tests/Unit/Public/New-UniversalAutomationRepositoryPackage.Tests.ps1 b/tests/Unit/Public/New-UniversalAutomationRepositoryPackage.Tests.ps1 index 87ca015..84e800a 100644 --- a/tests/Unit/Public/New-UniversalAutomationRepositoryPackage.Tests.ps1 +++ b/tests/Unit/Public/New-UniversalAutomationRepositoryPackage.Tests.ps1 @@ -32,7 +32,7 @@ Describe 'New-UniversalAutomationRepositoryPackage' { } Mock -CommandName Remove-Item Mock -CommandName New-Item - Mock -CommandName Copy-UniversalAutomationRepositoryModule + Mock -CommandName Copy-UniversalRepositoryModule Mock -CommandName New-UniversalAutomationRepositoryManifest Mock -CommandName Compress-Archive Mock -CommandName Get-Item -MockWith { @@ -51,7 +51,7 @@ Describe 'New-UniversalAutomationRepositoryPackage' { $result = Sampler.PowerShellUniversalTasks\New-UniversalAutomationRepositoryPackage @packageParameters $result.Name | Should -Be 'MyModule.1.2.3.zip' - Should -Invoke -CommandName Copy-UniversalAutomationRepositoryModule -Exactly -Times 1 -Scope It + Should -Invoke -CommandName Copy-UniversalRepositoryModule -Exactly -Times 1 -Scope It Should -Invoke -CommandName New-UniversalAutomationRepositoryManifest -Exactly -Times 1 -Scope It Should -Invoke -CommandName Compress-Archive -Exactly -Times 1 -Scope It } diff --git a/tests/Unit/TaskModule.Tests.ps1 b/tests/Unit/TaskModule.Tests.ps1 index 322563e..bab1f82 100644 --- a/tests/Unit/TaskModule.Tests.ps1 +++ b/tests/Unit/TaskModule.Tests.ps1 @@ -33,6 +33,12 @@ Describe 'Sampler.PowerShellUniversalTasks task exports' { $alias.ResolvedCommand.Path | Should -Match 'Tasks[\\/]Publish\.PowerShellUniversal\.build\.ps1$' } + It 'Should not export the task alias name as a function' { + $module = Get-Module -Name $script:moduleName + + $module.ExportedFunctions.ContainsKey('Task.Publish_PowerShellUniversal') | Should -BeFalse + } + It 'Should ship the task file in the Tasks directory' { $module = Get-Module -Name $script:moduleName $taskPath = Join-Path -Path $module.ModuleBase -ChildPath 'Tasks' @@ -40,4 +46,22 @@ Describe 'Sampler.PowerShellUniversalTasks task exports' { $taskFilePath | Should -Exist } + + It 'Should define the pull-module deployment meta task' { + $module = Get-Module -Name $script:moduleName + $taskPath = Join-Path -Path $module.ModuleBase -ChildPath 'Tasks' + $taskFilePath = Join-Path -Path $taskPath -ChildPath 'Publish.PowerShellUniversal.build.ps1' + $taskContent = Get-Content -Path $taskFilePath -Raw + + $taskContent | Should -Match 'task publish_psu_pull_module_from_psresourcerepo publish_module_from_psresource_repos_to_universal_server, assert_universal_deployment_succeeded' + } + + It 'Should define the push-repository deployment meta task' { + $module = Get-Module -Name $script:moduleName + $taskPath = Join-Path -Path $module.ModuleBase -ChildPath 'Tasks' + $taskFilePath = Join-Path -Path $taskPath -ChildPath 'Publish.PowerShellUniversal.build.ps1' + $taskContent = Get-Content -Path $taskFilePath -Raw + + $taskContent | Should -Match 'task publish_psu_push_repository package_universal_automation_repository, publish_universal_automation_repository_to_server, assert_universal_deployment_succeeded' + } }