diff --git a/CHANGELOG.md b/CHANGELOG.md index ee12451d..a0d57341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed MacOS-10.15 testing from build pipeline because it is no longer supported by Microsoft managed Azure DevOps images - Fixes [Issue #476](https://github.com/PlagueHO/CosmosDB/issues/476). - Added macOS-12 testing to build pipeline - Fixes [Issue #477](https://github.com/PlagueHO/CosmosDB/issues/477). +- Changed integration tests to deploy Cosmos DB using Bicep. +- Update `requirements.psd1` to install modules `Az.Accounts` 2.19.0 and `Az.Resources` 6.16.2. +- Renamed `New-CosmosDbAuthorizationToken` to `New-CosmosDbAuthorizationHeader` to better indicate + actual function return type. +- Refactored `Invoke-CosmosDbRequest` to support getting the EntraIdToken property from the context object + and using it for authentication if it is provided. If the Key property is provided, the EntraIdToken property + will take precendence and the key will be ignored. +- Updated CI pipeline to use `PublishCodeCoverageResults@2` task rather than `PublishCodeCoverageResults@1` + task to support the latest version of the task. + +### Changed + +- BREAKING CHANGE: Updated module to require `Az.Accounts` v2.19.0 or newer and `Az.Resources` + v6.16.2 or newer. +- Renamed `New-CosmosDbAuthorizationHeader` to `Get-CosmosDbAuthorizationHeaderFromContext` to better indicate + actual function behaviour. +- Renamed `Get-CosmosDbAuthorizationHeadersFromContext` to `Get-CosmosDbAuthorizationHeaderFromContextResourceToken` to better + indicate actual function behaviour and align naming convention. +- Refactored `Invoke-CosmosDbRequest` to clean up logic to generate the authorization header. +- Added new utillity function `Get-CosmosDbAuthorizationHeaderFromContextEntraId` to generate the authorization + header when an Entra ID Token is provided in the context. This function is used by `Invoke-CosmosDbRequest` to + generate the authorization header when an Entra ID Token is provided. + +### Added + +- Added support for setting an Entra Id OAuth2 Token in the `New-CosmosDbContext` - Fixes [Issue #479](https://github.com/PlagueHO/CosmosDB/issues/479). +- Added new `Get-CosmosDbEntraIdToken` function that uses `Get-AzAccessToken` to get an Entra Id Token + for use in Cosmos DB requests. This is used by `New-CosmosDbContext` to set the Entra Id Token in the + context object - Fixes [Issue #479](https://github.com/PlagueHO/CosmosDB/issues/479). ## [4.7.0] - 2023-01-29 @@ -666,7 +695,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.0.8.350] - 2018-04-05 -- Fixed `New-CosmosDbAuthorizationToken` function to support +- Fixed `New-CosmosDbAuthorizationHeader` function to support generating authorization tokens for case sensitive resource names - See [Issue #76](https://github.com/PlagueHO/CosmosDB/issues/76). Thanks [MWL88](https://github.com/MWL88). @@ -775,7 +804,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed bug in `New-CosmosDbConnection` connecting to Azure and improved tests. -- Changed `New-CosmosDbAuthorizationToken` to replaced `Connection` +- Changed `New-CosmosDbAuthorizationHeader` to replaced `Connection` parameter with `Key` and `KeyType` parameter. - Fixed bug in `Invoke-CosmosDbRequest` that can cause connection object to be changed. diff --git a/README.md b/README.md index 0fd862be..ff8e20f3 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,16 @@ - [Installation](#installation) - [Getting Started](#getting-started) - [Working with Contexts](#working-with-contexts) + - [Create a Context using an Entra ID Authorization Token](create-a-context-using-an-entra-id-authorization-token) + - [Configuring Role-Based Access Control (RBAC) with Entra ID](#configuring-role-based-access-control-rbac-with-entra-id) + - [Database Operations allowed by Role-Based Access Control](#database-operations-allowed-by-role-based-access-control) - [Create a Context specifying the Key Manually](#create-a-context-specifying-the-key-manually) + - [Use CosmosDB Module to Retrieve Key from Azure Management Portal](#use-cosmosdb-module-to-retrieve-key-from-azure-management-portal) + - [Create a Context from Resource Authorization Tokens](#create-a-context-from-resource-authorization-tokens) - [Create a Context for a Cosmos DB in Azure US Government Cloud](#create-a-context-for-a-cosmos-db-in-azure-us-government-cloud) - [Create a Context for a Cosmos DB in Azure China Cloud (Mooncake)](#create-a-context-for-a-cosmos-db-in-azure-china-cloud-mooncake) - [Create a Context for a Cosmos DB with a Custom Endpoint](#create-a-context-for-a-cosmos-db-with-a-custom-endpoint) - - [Use CosmosDB Module to Retrieve Key from Azure Management Portal](#use-cosmosdb-module-to-retrieve-key-from-azure-management-portal) - [Create a Context for a Cosmos DB Emulator](#create-a-context-for-a-cosmos-db-emulator) - - [Create a Context from Resource Authorization Tokens](#create-a-context-from-resource-authorization-tokens) - [Working with Accounts](#working-with-accounts) - [Working with Databases](#working-with-databases) - [Working with Offers](#working-with-offers) @@ -81,16 +84,15 @@ For more information on the Cosmos DB Rest APIs, see [this link](https://docs.mi This module requires the following: -- Windows PowerShell 5.x or PowerShell 6.x: - - **Az.Profile** and **Az.Resources** PowerShell modules - are required if using `New-CosmosDbContext -ResourceGroupName $resourceGroup` - or `*-CosmosDbAccount` functions. +- Windows PowerShell 5.x, PowerShell Core 6.x or PowerShell 7.x + +### PowerShell Module Dependencies -> Note: As of 3.0.0.0 of the CosmosDB module, support for **AzureRm** and -> **AzureRm.NetCore** PowerShell modules has been deprecated due to being -> superceeded by the **Az** modules. If it is a requirement that **AzureRm** -> or **AzureRm.NetCore** modules are used then you will need to remain on -> CosmosDB module 2.x. +- **Az.Account**: v2.19.0 or newer. +- **Az.Resources**: 6.16.2 or newer. + +These modules are required if using `New-CosmosDbContext -ResourceGroupName $resourceGroup` +or `*-CosmosDbAccount` functions. ## Recommended Knowledge @@ -129,8 +131,95 @@ the Azure management portal for you. ### Working with Contexts +#### Create a Context Using an Entra ID Authorization Token + +You can create a context object that can include use an _Entra ID Authorization Token_ +that will be used to authenticate requests to Cosmos DB. + +> Important: This is a recommended security practice to use when you've +> [configured role-based access control with Microsoft Entra ID](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac) +> on your Azure Cosmos DB account. It will help you keep your account secure +> by not exposing the primary or secondary keys in your code. + +To create a context object using an _Entra ID Authorization Token_ you will need +to set the `EntraIdToken` parameter to the token you have retrieved from Entra ID +for the identity that you have given appropriate permissions to the `account`, +`database` and/or `collection`. See [this page](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#concepts) for more infomration. + +```powershell +# Get an OAuth2 resource token from Entra ID for the Cosmos DB account. +# This will use the currently logged in user to authenticate to Entra ID to +# get the token. There are many other ways of doing this. +$entraIdOAuthToken = Get-CosmosDbEntraIdToken -Endpoint 'https://MyAzureCosmosDB.documents.azure.com' + +$newCosmosDbContextParams = @{ + Account = 'MyAzureCosmosDB' + EntraIdToken = $entraIdOAuthToken +} +$accountContext = New-CosmosDbContext @newCosmosDbContextParams +Get-CosmosDbCollection -Context $accountContext -Id MyNewCollection +``` + +An alternate method is to allow the New-CosmosDbContext cmdlet to retrieve the +Entra ID token for you. This will require you to have already logged into Azure +and will use the base URI detected for the account as the resource URI for the +token request. + +```powershell +$newCosmosDbContextParams = @{ + Account = 'MyAzureCosmosDB' + AutoGenerateEntraIdToken = $true +} +$accountContext = New-CosmosDbContext @newCosmosDbContextParams +Get-CosmosDbCollection -Context $accountContext -Id MyNewCollection +``` + +> Important: Using an Entra ID Authorization Token is only supported by setting it +> in a CosmosDB.Context object and passing that to the commands you want to execute. +> Not all commands support this method of authentication. If you need to use a command +> that doesn't support this method of authentication, you will need to use one of the +> other methods of authentication. See the [Database Operations allowed by Role-Based Access Control](#database-operations-allowed-by-role-based-access-control) +> section for more information. + +##### Configuring Role-Based Access Control (RBAC) with Entra ID + +There are several ways to configure a Cosmos DB Account with Role-Based Access Control, +including: + + - *Azure Bicep*: An example can be found in the [\tests\TestHelper\AzureDeploy\CosmosDb.bicep](\tests\TestHelper\AzureDeploy\CosmosDb.bicep) file. + - *Azure PowerShell*: The integration tests use this method. + - *AzCli*. + +> Important Note: One thing I found when adding a SQL Role Assignment to the Cosmos DB +> Account (or Database or Container) is that the principal ID must be the Object ID of +> the user, group or service principal that you want to assign the role to. You can't use +> the Application ID for this value. + +For more information on how to configure Role-Based Access Control with Entra ID, see the +[Configure role-based access control with Microsoft Entra ID for your Azure Cosmos DB account](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac) +page. + +##### Database Operations allowed by Role-Based Access Control + +Only a subset of all the operations that can be performed on a Cosmos DB account are +allowed by Role-Based Access Control. The following operations are allowed: +This permission model covers only database operations that involve reading and writing data. It does not cover any kind of management operations on management resources, including: + +- Create/Replace/Delete Database +- Create/Replace/Delete Container +- Read/Replace Container Throughput +- Create/Replace/Delete/Read Stored Procedures +- Create/Replace/Delete/Read Triggers +- Create/Replace/Delete/Read User Defined Functions + +For more information on this, please see the [Role-based access control (RBAC) with Azure Cosmos DB](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#permission-model) page. + #### Create a Context specifying the Key Manually +> Note: This method of authenticating to Cosmos DB is not recommended for +> production use. It is recommended to use the _Entra ID Authorization Token_ +> method described above. + First convert your key into a secure string: ```powershell @@ -144,8 +233,43 @@ create a context variable: $cosmosDbContext = New-CosmosDbContext -Account MyAzureCosmosDB -Database MyDatabase -Key $primaryKey ``` +#### Use CosmosDB Module to Retrieve Key from Azure Management Portal + +> Note: This method of authenticating to Cosmos DB is not recommended for +> production use. It is recommended to use the _Entra ID Authorization Token_ +> method described above. + +To create a context object so that the _CosmosDB PowerShell module_ +retrieves the primary or secondary key from the Azure Management +Portal, use the following command: + +```powershell +$cosmosDbContext = New-CosmosDbContext -Account MyAzureCosmosDB -Database MyDatabase -ResourceGroupName MyCosmosDbResourceGroup -MasterKeyType SecondaryMasterKey +``` + +_Note: if PowerShell is not connected to Azure then an interactive +Azure login will be initiated. If PowerShell is already connected to +an account that doesn't contain the Cosmos DB you wish to connect to then +you will first need to connect to the correct account using the +`Connect-AzAccount` cmdlet._ + +#### Create a Context from Resource Authorization Tokens + +> Note: This method of authenticating to Cosmos DB is better than using master key +> authentication, as it provides the ability to limit access to specific resources. +> However, it is recommended to use the _Entra ID Authorization Token_ method +> described above if possible. + +See the section [Using Resource Authorization Tokens](#using-resource-authorization-tokens) +for instructions on how to create a Context object containing one or more _Resource +Authorization Tokens_. + #### Create a Context for a Cosmos DB in Azure US Government Cloud +> Note: This method of authenticating to Cosmos DB is not recommended for +> production use. It is recommended to use the _Entra ID Authorization Token_ +> method described above. + Use the key secure string, Azure Cosmos DB account name and database to create a context variable and set the `Environment` parameter to `AzureUSGovernment`: @@ -156,6 +280,10 @@ $cosmosDbContext = New-CosmosDbContext -Account MyAzureCosmosDB -Database MyData #### Create a Context for a Cosmos DB in Azure China Cloud (Mooncake) +> Note: This method of authenticating to Cosmos DB is not recommended for +> production use. It is recommended to use the _Entra ID Authorization Token_ +> method described above. + Use the key secure string, Azure Cosmos DB account name and database to create a context variable and set the `Environment` parameter to `AzureChinaCloud`: @@ -173,22 +301,6 @@ Cosmos DB custom endpoint hostname: $cosmosDbContext = New-CosmosDbContext -Account MyAzureCosmosDB -Database MyDatabase -Key $primaryKey -EndpointHostname documents.eassov.com ``` -#### Use CosmosDB Module to Retrieve Key from Azure Management Portal - -To create a context object so that the _CosmosDB PowerShell module_ -retrieves the primary or secondary key from the Azure Management -Portal, use the following command: - -```powershell -$cosmosDbContext = New-CosmosDbContext -Account MyAzureCosmosDB -Database MyDatabase -ResourceGroupName MyCosmosDbResourceGroup -MasterKeyType SecondaryMasterKey -``` - -_Note: if PowerShell is not connected to Azure then an interactive -Azure login will be initiated. If PowerShell is already connected to -an account that doesn't contain the Cosmos DB you wish to connect to then -you will first need to connect to the correct account using the -`Connect-AzAccount` cmdlet._ - #### Create a Context for a Cosmos DB Emulator Microsoft provides a [Cosmos DB emulator](https://docs.microsoft.com/azure/cosmos-db/local-emulator) that @@ -208,12 +320,6 @@ $primaryKey = ConvertTo-SecureString -String 'GFJqJesi2Rq910E0G7P4WoZkzowzbj23Sm $cosmosDbContext = New-CosmosDbContext -Emulator -Database MyDatabase -Uri https://cosmosdbemulator.contoso.com:9081 -Key $primaryKey ``` -#### Create a Context from Resource Authorization Tokens - -See the section [Using Resource Authorization Tokens](#using-resource-authorization-tokens) -for instructions on how to create a Context object containing one or more _Resource -Authorization Tokens_. - ### Working with Accounts You can create, retrieve, update and remove Azure Cosmos DB accounts using diff --git a/RequiredModules.psd1 b/RequiredModules.psd1 index 54b53e40..d1579e4c 100644 --- a/RequiredModules.psd1 +++ b/RequiredModules.psd1 @@ -17,6 +17,7 @@ Sampler = 'latest' 'Sampler.GitHubTasks' = 'latest' MarkdownLinkCheck = 'latest' - 'Az.Accounts' = '2.2.8' - 'Az.Resources' = '1.3.1' + 'Az.Accounts' = '2.19.0' + 'Az.Resources' = '6.16.2' + 'Az.CosmosDB' = '1.14.2' # Required by integration tests } diff --git a/Resolve-Dependency.ps1 b/Resolve-Dependency.ps1 index 1a32d07a..17cc98ec 100644 --- a/Resolve-Dependency.ps1 +++ b/Resolve-Dependency.ps1 @@ -1,289 +1,1060 @@ +<# + .DESCRIPTION + Bootstrap script for PSDepend. + + .PARAMETER DependencyFile + Specifies the configuration file for the this script. The default value is + 'RequiredModules.psd1' relative to this script's path. + + .PARAMETER PSDependTarget + Path for PSDepend to be bootstrapped and save other dependencies. + Can also be CurrentUser or AllUsers if you wish to install the modules in + such scope. The default value is 'output/RequiredModules' relative to + this script's path. + + .PARAMETER Proxy + Specifies the URI to use for Proxy when attempting to bootstrap + PackageProvider and PowerShellGet. + + .PARAMETER ProxyCredential + Specifies the credential to contact the Proxy when provided. + + .PARAMETER Scope + Specifies the scope to bootstrap the PackageProvider and PSGet if not available. + THe default value is 'CurrentUser'. + + .PARAMETER Gallery + Specifies the gallery to use when bootstrapping PackageProvider, PSGet and + when calling PSDepend (can be overridden in Dependency files). The default + value is 'PSGallery'. + + .PARAMETER GalleryCredential + Specifies the credentials to use with the Gallery specified above. + + .PARAMETER AllowOldPowerShellGetModule + Allow you to use a locally installed version of PowerShellGet older than + 1.6.0 (not recommended). Default it will install the latest PowerShellGet + if an older version than 2.0 is detected. + + .PARAMETER MinimumPSDependVersion + Allow you to specify a minimum version fo PSDepend, if you're after specific + features. + + .PARAMETER AllowPrerelease + Not yet written. + + .PARAMETER WithYAML + Not yet written. + + .PARAMETER UseModuleFast + Specifies to use ModuleFast instead of PowerShellGet to resolve dependencies + faster. + + .PARAMETER ModuleFastBleedingEdge + Specifies to use ModuleFast code that is in the ModuleFast's main branch + in its GitHub repository. The parameter UseModuleFast must also be set to + true. + + .PARAMETER UsePSResourceGet + Specifies to use the new PSResourceGet module instead of the (now legacy) PowerShellGet module. + + .PARAMETER PSResourceGetVersion + String specifying the module version for PSResourceGet if the `UsePSResourceGet` switch is utilized. + + .NOTES + Load defaults for parameters values from Resolve-Dependency.psd1 if not + provided as parameter. +#> [CmdletBinding()] param ( - [Parameter()] [System.String] $DependencyFile = 'RequiredModules.psd1', [Parameter()] [System.String] - # Path for PSDepend to be bootstrapped and save other dependencies. - # Can also be CurrentUser or AllUsers if you wish to install the modules in such scope - # Default to $PWD.Path/output/modules - $PSDependTarget = (Join-Path $PSScriptRoot './output/RequiredModules'), + $PSDependTarget = (Join-Path -Path $PSScriptRoot -ChildPath 'output/RequiredModules'), [Parameter()] - [uri] - # URI to use for Proxy when attempting to Bootstrap PackageProvider & PowerShellGet + [System.Uri] $Proxy, [Parameter()] - # Credential to contact the Proxy when provided - [PSCredential]$ProxyCredential, + [System.Management.Automation.PSCredential] + $ProxyCredential, [Parameter()] [ValidateSet('CurrentUser', 'AllUsers')] [System.String] - # Scope to bootstrap the PackageProvider and PSGet if not available $Scope = 'CurrentUser', [Parameter()] [System.String] - # Gallery to use when bootstrapping PackageProvider, PSGet and when calling PSDepend (can be overridden in Dependency files) $Gallery = 'PSGallery', [Parameter()] - [PSCredential] - # Credentials to use with the Gallery specified above + [System.Management.Automation.PSCredential] $GalleryCredential, - [Parameter()] - [switch] - # Allow you to use a locally installed version of PowerShellGet older than 1.6.0 (not recommended, default to $False) + [System.Management.Automation.SwitchParameter] $AllowOldPowerShellGetModule, [Parameter()] [System.String] - # Allow you to specify a minimum version fo PSDepend, if you're after specific features. $MinimumPSDependVersion, [Parameter()] - [Switch] + [System.Management.Automation.SwitchParameter] $AllowPrerelease, [Parameter()] - [Switch] - $WithYAML + [System.Management.Automation.SwitchParameter] + $WithYAML, + + [Parameter()] + [System.Collections.Hashtable] + $RegisterGallery, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UseModuleFast, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $ModuleFastBleedingEdge, + + [Parameter()] + [System.String] + $ModuleFastVersion, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePSResourceGet, + + [Parameter()] + [System.String] + $PSResourceGetVersion, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePowerShellGetCompatibilityModule, + + [Parameter()] + [System.String] + $UsePowerShellGetCompatibilityModuleVersion ) -# Load Defaults for parameters values from Resolve-Dependency.psd1 if not provided as parameter try { - Write-Verbose -Message "Importing Bootstrap default parameters from '$PSScriptRoot/Resolve-Dependency.psd1'." - $ResolveDependencyDefaults = Import-PowerShellDataFile -Path (Join-Path $PSScriptRoot '.\Resolve-Dependency.psd1' -Resolve -ErrorAction Stop) - $ParameterToDefault = $MyInvocation.MyCommand.ParameterSets.Where{ $_.Name -eq $PSCmdlet.ParameterSetName }.Parameters.Keys - if ($ParameterToDefault.Count -eq 0) + if ($PSVersionTable.PSVersion.Major -le 5) { - $ParameterToDefault = $MyInvocation.MyCommand.Parameters.Keys + if (-not (Get-Command -Name 'Import-PowerShellDataFile' -ErrorAction 'SilentlyContinue')) + { + Import-Module -Name Microsoft.PowerShell.Utility -RequiredVersion '3.1.0.0' + } } - # Set the parameters available in the Parameter Set, or it's not possible to choose yet, so all parameters are an option - foreach ($ParamName in $ParameterToDefault) + + Write-Verbose -Message 'Importing Bootstrap default parameters from ''$PSScriptRoot/Resolve-Dependency.psd1''.' + + $resolveDependencyConfigPath = Join-Path -Path $PSScriptRoot -ChildPath '.\Resolve-Dependency.psd1' -Resolve -ErrorAction 'Stop' + + $resolveDependencyDefaults = Import-PowerShellDataFile -Path $resolveDependencyConfigPath + + $parameterToDefault = $MyInvocation.MyCommand.ParameterSets.Where{ $_.Name -eq $PSCmdlet.ParameterSetName }.Parameters.Keys + + if ($parameterToDefault.Count -eq 0) { - if (-Not $PSBoundParameters.Keys.Contains($ParamName) -and $ResolveDependencyDefaults.ContainsKey($ParamName)) + $parameterToDefault = $MyInvocation.MyCommand.Parameters.Keys + } + + # Set the parameters available in the Parameter Set, or it's not possible to choose yet, so all parameters are an option. + foreach ($parameterName in $parameterToDefault) + { + if (-not $PSBoundParameters.Keys.Contains($parameterName) -and $resolveDependencyDefaults.ContainsKey($parameterName)) { - Write-Verbose -Message "Setting $ParamName with $($ResolveDependencyDefaults[$ParamName])" + Write-Verbose -Message "Setting parameter '$parameterName' to value '$($resolveDependencyDefaults[$parameterName])'." + try { - $variableValue = $ResolveDependencyDefaults[$ParamName] + $variableValue = $resolveDependencyDefaults[$parameterName] + if ($variableValue -is [System.String]) { $variableValue = $ExecutionContext.InvokeCommand.ExpandString($variableValue) } - $PSBoundParameters.Add($ParamName, $variableValue) - Set-Variable -Name $ParamName -value $variableValue -Force -ErrorAction SilentlyContinue + + $PSBoundParameters.Add($parameterName, $variableValue) + + Set-Variable -Name $parameterName -Value $variableValue -Force -ErrorAction 'SilentlyContinue' } catch { - Write-Verbose -Message "Error adding default for $ParamName : $($_.Exception.Message)" + Write-Verbose -Message "Error adding default for $parameterName : $($_.Exception.Message)." } } } } catch { - Write-Warning -Message "Error attempting to import Bootstrap's default parameters from $(Join-Path $PSScriptRoot '.\Resolve-Dependency.psd1'): $($_.Exception.Message)." + Write-Warning -Message "Error attempting to import Bootstrap's default parameters from '$resolveDependencyConfigPath': $($_.Exception.Message)." } -Write-Progress -Activity "Bootstrap:" -PercentComplete 0 -CurrentOperation "NuGet Bootstrap" - -if (!(Get-PackageProvider -Name NuGet -ForceBootstrap -ErrorAction SilentlyContinue)) +# Handle when both ModuleFast and PSResourceGet is configured or/and passed as parameter. +if ($UseModuleFast -and $UsePSResourceGet) { - $providerBootstrapParams = @{ - Name = 'nuget' - force = $true - ForceBootstrap = $true - ErrorAction = 'Stop' + Write-Information -MessageData 'Both ModuleFast and PSResourceGet is configured or/and passed as parameter.' -InformationAction 'Continue' + + if ($PSVersionTable.PSVersion -ge '7.2') + { + $UsePSResourceGet = $false + + Write-Information -MessageData 'PowerShell 7.2 or higher being used, prefer ModuleFast over PSResourceGet.' -InformationAction 'Continue' } + else + { + $UseModuleFast = $false - switch ($PSBoundParameters.Keys) + Write-Information -MessageData 'Windows PowerShell or PowerShell <=7.1 is being used, prefer PSResourceGet since ModuleFast is not supported on this version of PowerShell.' -InformationAction 'Continue' + } +} + +# Only bootstrap ModuleFast if it is not already imported. +if ($UseModuleFast -and -not (Get-Module -Name 'ModuleFast')) +{ + try { - 'Proxy' + $moduleFastBootstrapScriptBlockParameters = @{} + + if ($ModuleFastBleedingEdge) { - $providerBootstrapParams.Add('Proxy', $Proxy) + Write-Information -MessageData 'ModuleFast is configured to use Bleeding Edge (directly from ModuleFast''s main branch).' -InformationAction 'Continue' + + $moduleFastBootstrapScriptBlockParameters.UseMain = $true } - 'ProxyCredential' + elseif($ModuleFastVersion) { - $providerBootstrapParams.Add('ProxyCredential', $ProxyCredential) + if ($ModuleFastVersion -notmatch 'v') + { + $ModuleFastVersion = 'v{0}' -f $ModuleFastVersion + } + + Write-Information -MessageData ('ModuleFast is configured to use version {0}.' -f $ModuleFastVersion) -InformationAction 'Continue' + + $moduleFastBootstrapScriptBlockParameters.Release = $ModuleFastVersion } - 'Scope' + else { - $providerBootstrapParams.Add('Scope', $Scope) + Write-Information -MessageData 'ModuleFast is configured to use latest released version.' -InformationAction 'Continue' } + + $moduleFastBootstrapUri = 'bit.ly/modulefast' # cSpell: disable-line + + Write-Debug -Message ('Using bootstrap script at {0}' -f $moduleFastBootstrapUri) + + $invokeWebRequestParameters = @{ + Uri = $moduleFastBootstrapUri + ErrorAction = 'Stop' + } + + $moduleFastBootstrapScript = Invoke-WebRequest @invokeWebRequestParameters + + $moduleFastBootstrapScriptBlock = [ScriptBlock]::Create($moduleFastBootstrapScript) + + & $moduleFastBootstrapScriptBlock @moduleFastBootstrapScriptBlockParameters + } + catch + { + Write-Warning -Message ('ModuleFast could not be bootstrapped. Reverting to PSResourceGet. Error: {0}' -f $_.Exception.Message) + + $UseModuleFast = $false + $UsePSResourceGet = $true } +} + +if ($UsePSResourceGet) +{ + $psResourceGetModuleName = 'Microsoft.PowerShell.PSResourceGet' - if ($AllowPrerelease) + # If PSResourceGet was used prior it will be locked and we can't replace it. + if ((Test-Path -Path "$PSDependTarget/$psResourceGetModuleName" -PathType 'Container') -and (Get-Module -Name $psResourceGetModuleName)) { - $providerBootstrapParams.Add('AllowPrerelease', $true) + Write-Information -MessageData ('{0} is already bootstrapped and imported into the session. If there is a need to refresh the module, open a new session and resolve dependencies again.' -f $psResourceGetModuleName) -InformationAction 'Continue' } + else + { + Write-Debug -Message ('{0} do not exist, saving the module to RequiredModules.' -f $psResourceGetModuleName) - Write-Information "Bootstrap: Installing NuGet Package Provider from the web (Make sure Microsoft addresses/ranges are allowed)" - $null = Install-PackageProvider @providerBootstrapParams - $latestNuGetVersion = (Get-PackageProvider -Name NuGet -ListAvailable | Select-Object -First 1).Version.ToString() - Write-Information "Bootstrap: Importing NuGet Package Provider version $latestNuGetVersion to current session." - $Null = Import-PackageProvider -Name NuGet -RequiredVersion $latestNuGetVersion -Force -} + $psResourceGetDownloaded = $false -Write-Progress -Activity "Bootstrap:" -PercentComplete 10 -CurrentOperation "Ensuring Gallery $Gallery is trusted" + try + { + if (-not $PSResourceGetVersion) + { + # Default to latest version if no version is passed in parameter or specified in configuration. + $psResourceGetUri = "https://www.powershellgallery.com/api/v2/package/$psResourceGetModuleName" + } + else + { + $psResourceGetUri = "https://www.powershellgallery.com/api/v2/package/$psResourceGetModuleName/$PSResourceGetVersion" + } -# Fail if the given PSGallery is not Registered -$Policy = (Get-PSRepository $Gallery -ErrorAction Stop).InstallationPolicy -Set-PSRepository -Name $Gallery -InstallationPolicy Trusted -ErrorAction Ignore -try + $invokeWebRequestParameters = @{ + # TODO: Should support proxy parameters passed to the script. + Uri = $psResourceGetUri + OutFile = "$PSDependTarget/$psResourceGetModuleName.nupkg" # cSpell: ignore nupkg + ErrorAction = 'Stop' + } + + $previousProgressPreference = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + + # Bootstrapping Microsoft.PowerShell.PSResourceGet. + Invoke-WebRequest @invokeWebRequestParameters + + $ProgressPreference = $previousProgressPreference + + $psResourceGetDownloaded = $true + } + catch + { + Write-Warning -Message ('{0} could not be bootstrapped. Reverting to PowerShellGet. Error: {1}' -f $psResourceGetModuleName, $_.Exception.Message) + } + + $UsePSResourceGet = $false + + if ($psResourceGetDownloaded) + { + # On Windows PowerShell the command Expand-Archive do not like .nupkg as a zip archive extension. + $zipFileName = ((Split-Path -Path $invokeWebRequestParameters.OutFile -Leaf) -replace 'nupkg', 'zip') + + $renameItemParameters = @{ + Path = $invokeWebRequestParameters.OutFile + NewName = $zipFileName + Force = $true + } + + Rename-Item @renameItemParameters + + $psResourceGetZipArchivePath = Join-Path -Path (Split-Path -Path $invokeWebRequestParameters.OutFile -Parent) -ChildPath $zipFileName + + $expandArchiveParameters = @{ + Path = $psResourceGetZipArchivePath + DestinationPath = "$PSDependTarget/$psResourceGetModuleName" + Force = $true + } + + Expand-Archive @expandArchiveParameters + + Remove-Item -Path $psResourceGetZipArchivePath + + Import-Module -Name $expandArchiveParameters.DestinationPath -Force + + # Successfully bootstrapped PSResourceGet, so let's use it. + $UsePSResourceGet = $true + } + } + + if ($UsePSResourceGet) + { + $psResourceGetModule = Get-Module -Name $psResourceGetModuleName + + $psResourceGetModuleVersion = $psResourceGetModule.Version.ToString() + + if ($psResourceGetModule.PrivateData.PSData.Prerelease) + { + $psResourceGetModuleVersion += '-{0}' -f $psResourceGetModule.PrivateData.PSData.Prerelease + } + + Write-Information -MessageData ('Using {0} v{1}.' -f $psResourceGetModuleName, $psResourceGetModuleVersion) -InformationAction 'Continue' + + if ($UsePowerShellGetCompatibilityModule) + { + $savePowerShellGetParameters = @{ + Name = 'PowerShellGet' + Path = $PSDependTarget + Repository = 'PSGallery' + TrustRepository = $true + } + + if ($UsePowerShellGetCompatibilityModuleVersion) + { + $savePowerShellGetParameters.Version = $UsePowerShellGetCompatibilityModuleVersion + + # Check if the version is a prerelease. + if ($UsePowerShellGetCompatibilityModuleVersion -match '\d+\.\d+\.\d+-.*') + { + $savePowerShellGetParameters.Prerelease = $true + } + } + + Save-PSResource @savePowerShellGetParameters + + Import-Module -Name "$PSDependTarget/PowerShellGet" + } + } +} + +# Check if legacy PowerShellGet and PSDepend must be bootstrapped. +if (-not ($UseModuleFast -or $UsePSResourceGet)) { - Write-Progress -Activity "Bootstrap:" -PercentComplete 25 -CurrentOperation "Checking PowerShellGet" - # Ensure the module is loaded and retrieve the version you have - $PowerShellGetVersion = (Import-Module PowerShellGet -PassThru -ErrorAction SilentlyContinue).Version + if ($PSVersionTable.PSVersion.Major -le 5) + { + <# + Making sure the imported PackageManagement module is not from PS7 module + path. The VSCode PS extension is changing the $env:PSModulePath and + prioritize the PS7 path. This is an issue with PowerShellGet because + it loads an old version if available (or fail to load latest). + #> + Get-Module -ListAvailable PackageManagement | + Where-Object -Property 'ModuleBase' -NotMatch 'powershell.7' | + Select-Object -First 1 | + Import-Module -Force + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 0 -CurrentOperation 'NuGet Bootstrap' + + $importModuleParameters = @{ + Name = 'PowerShellGet' + MinimumVersion = '2.0' + MaximumVersion = '2.8.999' + ErrorAction = 'SilentlyContinue' + PassThru = $true + } + + if ($AllowOldPowerShellGetModule) + { + $importModuleParameters.Remove('MinimumVersion') + } + + $powerShellGetModule = Import-Module @importModuleParameters - Write-Verbose "Bootstrap: The PowerShellGet version is $PowerShellGetVersion" - # Versions below 1.6.0 are considered old, unreliable & not recommended - if (!$PowerShellGetVersion -or ($PowerShellGetVersion -lt [System.version]'1.6.0' -and !$AllowOldPowerShellGetModule)) + # Install the package provider if it is not available. + $nuGetProvider = Get-PackageProvider -Name 'NuGet' -ListAvailable -ErrorAction 'SilentlyContinue' | + Select-Object -First 1 + + if (-not $powerShellGetModule -and -not $nuGetProvider) { - Write-Progress -Activity "Bootstrap:" -PercentComplete 40 -CurrentOperation "Installing newer version of PowerShellGet" - $InstallPSGetParam = @{ - Name = 'PowerShellGet' - Force = $True - SkipPublisherCheck = $true - AllowClobber = $true - Scope = $Scope - Repository = $Gallery + $providerBootstrapParameters = @{ + Name = 'NuGet' + Force = $true + ForceBootstrap = $true + ErrorAction = 'Stop' + Scope = $Scope } switch ($PSBoundParameters.Keys) { 'Proxy' { - $InstallPSGetParam.Add('Proxy', $Proxy) + $providerBootstrapParameters.Add('Proxy', $Proxy) } + 'ProxyCredential' { - $InstallPSGetParam.Add('ProxyCredential', $ProxyCredential) + $providerBootstrapParameters.Add('ProxyCredential', $ProxyCredential) } - 'GalleryCredential' + + 'AllowPrerelease' { - $InstallPSGetParam.Add('Credential', $GalleryCredential) + $providerBootstrapParameters.Add('AllowPrerelease', $AllowPrerelease) } } - Install-Module @InstallPSGetParam - Remove-Module PowerShellGet -force -ErrorAction SilentlyContinue - Import-Module PowerShellGet -Force - $NewLoadedVersion = (Get-Module PowerShellGet).Version.ToString() - Write-Information "Bootstrap: PowerShellGet version loaded is $NewLoadedVersion" - Write-Progress -Activity "Bootstrap:" -PercentComplete 60 -CurrentOperation "Installing newer version of PowerShellGet" + Write-Information -MessageData 'Bootstrap: Installing NuGet Package Provider from the web (Make sure Microsoft addresses/ranges are allowed).' + + $null = Install-PackageProvider @providerBootstrapParameters + + $nuGetProvider = Get-PackageProvider -Name 'NuGet' -ListAvailable | Select-Object -First 1 + + $nuGetProviderVersion = $nuGetProvider.Version.ToString() + + Write-Information -MessageData "Bootstrap: Importing NuGet Package Provider version $nuGetProviderVersion to current session." + + $Null = Import-PackageProvider -Name 'NuGet' -RequiredVersion $nuGetProviderVersion -Force } - # Try to import the PSDepend module from the available modules - try + if ($RegisterGallery) { - $ImportPSDependParam = @{ - Name = 'PSDepend' - ErrorAction = 'Stop' - Force = $true + if ($RegisterGallery.ContainsKey('Name') -and -not [System.String]::IsNullOrEmpty($RegisterGallery.Name)) + { + $Gallery = $RegisterGallery.Name } + else + { + $RegisterGallery.Name = $Gallery + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 7 -CurrentOperation "Verifying private package repository '$Gallery'" -Completed - if ($MinimumPSDependVersion) + $previousRegisteredRepository = Get-PSRepository -Name $Gallery -ErrorAction 'SilentlyContinue' + + if ($previousRegisteredRepository.SourceLocation -ne $RegisterGallery.SourceLocation) { - $ImportPSDependParam.add('MinimumVersion', $MinimumPSDependVersion) + if ($previousRegisteredRepository) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 9 -CurrentOperation "Re-registrering private package repository '$Gallery'" -Completed + + Unregister-PSRepository -Name $Gallery + + $unregisteredPreviousRepository = $true + } + else + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 9 -CurrentOperation "Registering private package repository '$Gallery'" -Completed + } + + Register-PSRepository @RegisterGallery } - $null = Import-Module @ImportPSDependParam } - catch + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 10 -CurrentOperation "Ensuring Gallery $Gallery is trusted" + + # Fail if the given PSGallery is not registered. + $previousGalleryInstallationPolicy = (Get-PSRepository -Name $Gallery -ErrorAction 'Stop').Trusted + + $updatedGalleryInstallationPolicy = $false + + if ($previousGalleryInstallationPolicy -ne $true) { - # PSDepend module not found, installing or saving it - if ($PSDependTarget -in 'CurrentUser', 'AllUsers') + $updatedGalleryInstallationPolicy = $true + + # Only change policy if the repository is not trusted + Set-PSRepository -Name $Gallery -InstallationPolicy 'Trusted' -ErrorAction 'Ignore' + } +} + +try +{ + # Check if legacy PowerShellGet and PSDepend must be used. + if (-not ($UseModuleFast -or $UsePSResourceGet)) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 25 -CurrentOperation 'Checking PowerShellGet' + + # Ensure the module is loaded and retrieve the version you have. + $powerShellGetVersion = (Import-Module -Name 'PowerShellGet' -PassThru -ErrorAction 'SilentlyContinue').Version + + Write-Verbose -Message "Bootstrap: The PowerShellGet version is $powerShellGetVersion" + + # Versions below 2.0 are considered old, unreliable & not recommended + if (-not $powerShellGetVersion -or ($powerShellGetVersion -lt [System.Version] '2.0' -and -not $AllowOldPowerShellGetModule)) { - Write-Debug "PSDepend module not found. Attempting to install from Gallery $Gallery" - Write-Warning "Installing PSDepend in $PSDependTarget Scope" - $InstallPSDependParam = @{ - Name = 'PSDepend' - Repository = $Gallery - Force = $true - Scope = $PSDependTarget - SkipPublisherCheck = $true - AllowClobber = $true + Write-Progress -Activity 'Bootstrap:' -PercentComplete 40 -CurrentOperation 'Fetching newer version of PowerShellGet' + + # PowerShellGet module not found, installing or saving it. + if ($PSDependTarget -in 'CurrentUser', 'AllUsers') + { + Write-Debug -Message "PowerShellGet module not found. Attempting to install from Gallery $Gallery." + + Write-Warning -Message "Installing PowerShellGet in $PSDependTarget Scope." + + $installPowerShellGetParameters = @{ + Name = 'PowerShellGet' + Force = $true + SkipPublisherCheck = $true + AllowClobber = $true + Scope = $Scope + Repository = $Gallery + MaximumVersion = '2.8.999' + } + + switch ($PSBoundParameters.Keys) + { + 'Proxy' + { + $installPowerShellGetParameters.Add('Proxy', $Proxy) + } + + 'ProxyCredential' + { + $installPowerShellGetParameters.Add('ProxyCredential', $ProxyCredential) + } + + 'GalleryCredential' + { + $installPowerShellGetParameters.Add('Credential', $GalleryCredential) + } + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 60 -CurrentOperation 'Installing newer version of PowerShellGet' + + Install-Module @installPowerShellGetParameters } + else + { + Write-Debug -Message "PowerShellGet module not found. Attempting to Save from Gallery $Gallery to $PSDependTarget" + + $saveModuleParameters = @{ + Name = 'PowerShellGet' + Repository = $Gallery + Path = $PSDependTarget + Force = $true + MaximumVersion = '2.8.999' + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 60 -CurrentOperation "Saving PowerShellGet from $Gallery to $Scope" + + Save-Module @saveModuleParameters + } + + Write-Debug -Message 'Removing previous versions of PowerShellGet and PackageManagement from session' - if ($MinimumPSDependVersion) + Get-Module -Name 'PowerShellGet' -All | Remove-Module -Force -ErrorAction 'SilentlyContinue' + Get-Module -Name 'PackageManagement' -All | Remove-Module -Force + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 65 -CurrentOperation 'Loading latest version of PowerShellGet' + + Write-Debug -Message 'Importing latest PowerShellGet and PackageManagement versions into session' + + if ($AllowOldPowerShellGetModule) { - $InstallPSDependParam.add('MinimumVersion', $MinimumPSDependVersion) + $powerShellGetModule = Import-Module -Name 'PowerShellGet' -Force -PassThru + } + else + { + Import-Module -Name 'PackageManagement' -MinimumVersion '1.4.8.1' -Force + + $powerShellGetModule = Import-Module -Name 'PowerShellGet' -MinimumVersion '2.2.5' -Force -PassThru } - Write-Progress -Activity "Bootstrap:" -PercentComplete 75 -CurrentOperation "Installing PSDepend from $Gallery" - Install-Module @InstallPSDependParam + $powerShellGetVersion = $powerShellGetModule.Version.ToString() + + Write-Information -MessageData "Bootstrap: PowerShellGet version loaded is $powerShellGetVersion" } - else + + # Try to import the PSDepend module from the available modules. + $getModuleParameters = @{ + Name = 'PSDepend' + ListAvailable = $true + } + + $psDependModule = Get-Module @getModuleParameters + + if ($PSBoundParameters.ContainsKey('MinimumPSDependVersion')) { - Write-Debug "PSDepend module not found. Attempting to Save from Gallery $Gallery to $PSDependTarget" - $SaveModuleParam = @{ - Name = 'PSDepend' - Repository = $Gallery - Path = $PSDependTarget + try + { + $psDependModule = $psDependModule | Where-Object -FilterScript { $_.Version -ge $MinimumPSDependVersion } + } + catch + { + throw ('There was a problem finding the minimum version of PSDepend. Error: {0}' -f $_) + } + } + + if (-not $psDependModule) + { + Write-Debug -Message 'PSDepend module not found.' + + # PSDepend module not found, installing or saving it. + if ($PSDependTarget -in 'CurrentUser', 'AllUsers') + { + Write-Debug -Message "Attempting to install from Gallery '$Gallery'." + + Write-Warning -Message "Installing PSDepend in $PSDependTarget Scope." + + $installPSDependParameters = @{ + Name = 'PSDepend' + Repository = $Gallery + Force = $true + Scope = $PSDependTarget + SkipPublisherCheck = $true + AllowClobber = $true + } + + if ($MinimumPSDependVersion) + { + $installPSDependParameters.Add('MinimumVersion', $MinimumPSDependVersion) + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 75 -CurrentOperation "Installing PSDepend from $Gallery" + + Install-Module @installPSDependParameters } + else + { + Write-Debug -Message "Attempting to Save from Gallery $Gallery to $PSDependTarget" + + $saveModuleParameters = @{ + Name = 'PSDepend' + Repository = $Gallery + Path = $PSDependTarget + Force = $true + } - if ($MinimumPSDependVersion) + if ($MinimumPSDependVersion) + { + $saveModuleParameters.add('MinimumVersion', $MinimumPSDependVersion) + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 75 -CurrentOperation "Saving PSDepend from $Gallery to $PSDependTarget" + + Save-Module @saveModuleParameters + } + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 80 -CurrentOperation 'Importing PSDepend' + + $importModulePSDependParameters = @{ + Name = 'PSDepend' + ErrorAction = 'Stop' + Force = $true + } + + if ($PSBoundParameters.ContainsKey('MinimumPSDependVersion')) + { + $importModulePSDependParameters.Add('MinimumVersion', $MinimumPSDependVersion) + } + + # We should have successfully bootstrapped PSDepend. Fail if not available. + $null = Import-Module @importModulePSDependParameters + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 81 -CurrentOperation 'Invoke PSDepend' + + if ($WithYAML) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 82 -CurrentOperation 'Verifying PowerShell module PowerShell-Yaml' + + if (-not (Get-Module -ListAvailable -Name 'PowerShell-Yaml')) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 85 -CurrentOperation 'Installing PowerShell module PowerShell-Yaml' + + Write-Verbose -Message "PowerShell-Yaml module not found. Attempting to Save from Gallery '$Gallery' to '$PSDependTarget'." + + $SaveModuleParam = @{ + Name = 'PowerShell-Yaml' + Repository = $Gallery + Path = $PSDependTarget + Force = $true + } + + Save-Module @SaveModuleParam + } + else { - $SaveModuleParam.add('MinimumVersion', $MinimumPSDependVersion) + Write-Verbose -Message 'PowerShell-Yaml is already available' } - Write-Progress -Activity "Bootstrap:" -PercentComplete 75 -CurrentOperation "Saving & Importing PSDepend from $Gallery to $Scope" - Save-Module @SaveModuleParam + Write-Progress -Activity 'Bootstrap:' -PercentComplete 88 -CurrentOperation 'Importing PowerShell module PowerShell-Yaml' } } - finally - { - Write-Progress -Activity "Bootstrap:" -PercentComplete 100 -CurrentOperation "Loading PSDepend" - # We should have successfully bootstrapped PSDepend. Fail if not available - Import-Module PSDepend -ErrorAction Stop - } - if ($WithYAML) + if (Test-Path -Path $DependencyFile) { - if (-Not (Get-Module -ListAvailable -Name 'PowerShell-Yaml')) + if ($UseModuleFast -or $UsePSResourceGet) { - Write-Verbose "PowerShell-Yaml module not found. Attempting to Save from Gallery $Gallery to $PSDependTarget" - $SaveModuleParam = @{ - Name = 'PowerShell-Yaml' - Repository = $Gallery - Path = $PSDependTarget + $requiredModules = Import-PowerShellDataFile -Path $DependencyFile + + $requiredModules = $requiredModules.GetEnumerator() | + Where-Object -FilterScript { $_.Name -ne 'PSDependOptions' } + + if ($UseModuleFast) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 90 -CurrentOperation 'Invoking ModuleFast' + + Write-Progress -Activity 'ModuleFast:' -PercentComplete 0 -CurrentOperation 'Restoring Build Dependencies' + + $modulesToSave = @( + 'PSDepend' # Always include PSDepend for backward compatibility. + ) + + if ($WithYAML) + { + $modulesToSave += 'PowerShell-Yaml' + } + + if ($UsePowerShellGetCompatibilityModule) + { + Write-Debug -Message 'PowerShellGet compatibility module is configured to be used.' + + # This is needed to ensure that the PowerShellGet compatibility module works. + $psResourceGetModuleName = 'Microsoft.PowerShell.PSResourceGet' + + if ($PSResourceGetVersion) + { + $modulesToSave += ('{0}:[{1}]' -f $psResourceGetModuleName, $PSResourceGetVersion) + } + else + { + $modulesToSave += $psResourceGetModuleName + } + + $powerShellGetCompatibilityModuleName = 'PowerShellGet' + + if ($UsePowerShellGetCompatibilityModuleVersion) + { + $modulesToSave += ('{0}:[{1}]' -f $powerShellGetCompatibilityModuleName, $UsePowerShellGetCompatibilityModuleVersion) + } + else + { + $modulesToSave += $powerShellGetCompatibilityModuleName + } + } + + foreach ($requiredModule in $requiredModules) + { + # If the RequiredModules.psd1 entry is an Hashtable then special handling is needed. + if ($requiredModule.Value -is [System.Collections.Hashtable]) + { + if (-not $requiredModule.Value.Version) + { + $requiredModuleVersion = 'latest' + } + else + { + $requiredModuleVersion = $requiredModule.Value.Version + } + + if ($requiredModuleVersion -eq 'latest') + { + $moduleNameSuffix = '' + + if ($requiredModule.Value.Parameters.AllowPrerelease -eq $true) + { + <# + Adding '!' to the module name indicate to ModuleFast + that is should also evaluate pre-releases. + #> + $moduleNameSuffix = '!' + } + + $modulesToSave += ('{0}{1}' -f $requiredModule.Name, $moduleNameSuffix) + } + else + { + $modulesToSave += ('{0}:[{1}]' -f $requiredModule.Name, $requiredModuleVersion) + } + } + else + { + if ($requiredModule.Value -eq 'latest') + { + $modulesToSave += $requiredModule.Name + } + else + { + # Handle different nuget version operators already present. + if ($requiredModule.Value -match '[!|:|[|(|,|>|<|=]') + { + $modulesToSave += ('{0}{1}' -f $requiredModule.Name, $requiredModule.Value) + } + else + { + # Assuming the version is a fixed version. + $modulesToSave += ('{0}:[{1}]' -f $requiredModule.Name, $requiredModule.Value) + } + } + } + } + + Write-Debug -Message ("Required modules to retrieve plan for:`n{0}" -f ($modulesToSave | Out-String)) + + $installModuleFastParameters = @{ + Destination = $PSDependTarget + DestinationOnly = $true + NoPSModulePathUpdate = $true + NoProfileUpdate = $true + Update = $true + Confirm = $false + } + + $moduleFastPlan = Install-ModuleFast -Specification $modulesToSave -Plan @installModuleFastParameters + + Write-Debug -Message ("Missing modules that need to be saved:`n{0}" -f ($moduleFastPlan | Out-String)) + + if ($moduleFastPlan) + { + # Clear all modules in plan from the current session so they can be fetched again. + $moduleFastPlan.Name | Get-Module | Remove-Module -Force + + $moduleFastPlan | Install-ModuleFast @installModuleFastParameters + } + else + { + Write-Verbose -Message 'All required modules were already up to date' + } + + Write-Progress -Activity 'ModuleFast:' -PercentComplete 100 -CurrentOperation 'Dependencies restored' -Completed } - Save-Module @SaveModuleParam - Import-Module "PowerShell-Yaml" -ErrorAction Stop + if ($UsePSResourceGet) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 90 -CurrentOperation 'Invoking PSResourceGet' + + $modulesToSave = @( + @{ + Name = 'PSDepend' # Always include PSDepend for backward compatibility. + } + ) + + if ($WithYAML) + { + $modulesToSave += @{ + Name = 'PowerShell-Yaml' + } + } + + # Prepare hashtable that can be concatenated to the Save-PSResource parameters. + foreach ($requiredModule in $requiredModules) + { + # If the RequiredModules.psd1 entry is an Hashtable then special handling is needed. + if ($requiredModule.Value -is [System.Collections.Hashtable]) + { + $saveModuleHashtable = @{ + Name = $requiredModule.Name + } + + if ($requiredModule.Value.Version -and $requiredModule.Value.Version -ne 'latest') + { + $saveModuleHashtable.Version = $requiredModule.Value.Version + } + + if ($requiredModule.Value.Parameters.AllowPrerelease -eq $true) + { + $saveModuleHashtable.Prerelease = $true + } + + $modulesToSave += $saveModuleHashtable + } + else + { + if ($requiredModule.Value -eq 'latest') + { + $modulesToSave += @{ + Name = $requiredModule.Name + } + } + else + { + $modulesToSave += @{ + Name = $requiredModule.Name + Version = $requiredModule.Value + } + } + } + } + + $percentagePerModule = [System.Math]::Floor(100 / $modulesToSave.Length) + + $progressPercentage = 0 + + Write-Progress -Activity 'PSResourceGet:' -PercentComplete $progressPercentage -CurrentOperation 'Restoring Build Dependencies' + + foreach ($currentModule in $modulesToSave) + { + Write-Progress -Activity 'PSResourceGet:' -PercentComplete $progressPercentage -CurrentOperation 'Restoring Build Dependencies' -Status ('Saving module {0}' -f $savePSResourceParameters.Name) + + $savePSResourceParameters = @{ + Path = $PSDependTarget + TrustRepository = $true + Confirm = $false + } + + # Concatenate the module parameters to the Save-PSResource parameters. + $savePSResourceParameters += $currentModule + + # Modules that Sampler depend on that cannot be refreshed without a new session. + $skipModule = @('PowerShell-Yaml') + + if ($savePSResourceParameters.Name -in $skipModule -and (Get-Module -Name $savePSResourceParameters.Name)) + { + Write-Progress -Activity 'PSResourceGet:' -PercentComplete $progressPercentage -CurrentOperation 'Restoring Build Dependencies' -Status ('Skipping module {0}' -f $savePSResourceParameters.Name) + + Write-Information -MessageData ('Skipping the module {0} since it cannot be refresh while loaded into the session. To refresh the module open a new session and resolve dependencies again.' -f $savePSResourceParameters.Name) -InformationAction 'Continue' + } + else + { + # Clear all module from the current session so any new version fetched will be re-imported. + Get-Module -Name $savePSResourceParameters.Name | Remove-Module -Force + + Save-PSResource @savePSResourceParameters -ErrorVariable 'savePSResourceError' + + if ($savePSResourceError) + { + Write-Warning -Message 'Save-PSResource could not save (replace) one or more dependencies. This can be due to the module is loaded into the session (and referencing assemblies). Close the current session and open a new session and try again.' + } + } + + $progressPercentage += $percentagePerModule + } + + Write-Progress -Activity 'PSResourceGet:' -PercentComplete 100 -CurrentOperation 'Dependencies restored' -Completed + } } else { - Write-Verbose "PowerShell-Yaml is already available" + Write-Progress -Activity 'Bootstrap:' -PercentComplete 90 -CurrentOperation 'Invoking PSDepend' + + Write-Progress -Activity 'PSDepend:' -PercentComplete 0 -CurrentOperation 'Restoring Build Dependencies' + + $psDependParameters = @{ + Force = $true + Path = $DependencyFile + } + + # TODO: Handle when the Dependency file is in YAML, and -WithYAML is specified. + Invoke-PSDepend @psDependParameters + + Write-Progress -Activity 'PSDepend:' -PercentComplete 100 -CurrentOperation 'Dependencies restored' -Completed } } - - Write-Progress -Activity "PSDepend:" -PercentComplete 0 -CurrentOperation "Restoring Build Dependencies" - if (Test-Path $DependencyFile) + else { - $PSDependParams = @{ - Force = $true - Path = $DependencyFile - } - - # TODO: Handle when the Dependency file is in YAML, and -WithYAML is specified - Invoke-PSDepend @PSDependParams + Write-Warning -Message "The dependency file '$DependencyFile' could not be found." } - Write-Progress -Activity "PSDepend:" -PercentComplete 100 -CurrentOperation "Dependencies restored" -Completed + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 100 -CurrentOperation 'Bootstrap complete' -Completed } finally { - # Reverting the Installation Policy for the given gallery - Set-PSRepository -Name $Gallery -InstallationPolicy $Policy - Write-Verbose "Project Bootstrapped, returning to Invoke-Build" + if ($RegisterGallery) + { + Write-Verbose -Message "Removing private package repository '$Gallery'." + Unregister-PSRepository -Name $Gallery + } + + if ($unregisteredPreviousRepository) + { + Write-Verbose -Message "Reverting private package repository '$Gallery' to previous location URI:s." + + $registerPSRepositoryParameters = @{ + Name = $previousRegisteredRepository.Name + InstallationPolicy = $previousRegisteredRepository.InstallationPolicy + } + + if ($previousRegisteredRepository.SourceLocation) + { + $registerPSRepositoryParameters.SourceLocation = $previousRegisteredRepository.SourceLocation + } + + if ($previousRegisteredRepository.PublishLocation) + { + $registerPSRepositoryParameters.PublishLocation = $previousRegisteredRepository.PublishLocation + } + + if ($previousRegisteredRepository.ScriptSourceLocation) + { + $registerPSRepositoryParameters.ScriptSourceLocation = $previousRegisteredRepository.ScriptSourceLocation + } + + if ($previousRegisteredRepository.ScriptPublishLocation) + { + $registerPSRepositoryParameters.ScriptPublishLocation = $previousRegisteredRepository.ScriptPublishLocation + } + + Register-PSRepository @registerPSRepositoryParameters + } + + if ($updatedGalleryInstallationPolicy -eq $true -and $previousGalleryInstallationPolicy -ne $true) + { + # Only try to revert installation policy if the repository exist + if ((Get-PSRepository -Name $Gallery -ErrorAction 'SilentlyContinue')) + { + # Reverting the Installation Policy for the given gallery if it was not already trusted + Set-PSRepository -Name $Gallery -InstallationPolicy 'Untrusted' + } + } + + Write-Verbose -Message 'Project Bootstrapped, returning to Invoke-Build.' } diff --git a/Resolve-Dependency.psd1 b/Resolve-Dependency.psd1 index 29e16dde..07945f81 100644 --- a/Resolve-Dependency.psd1 +++ b/Resolve-Dependency.psd1 @@ -1,10 +1,15 @@ -@{ # Defaults Parameter value to be loaded by the Resolve-Dependency command (unless set in Bound Parameters) - #PSDependTarget = './output/modules' - #Proxy = '' - #ProxyCredential = '$MyCredentialVariable' #TODO: find a way to support credentials in build (resolve variable) +@{ Gallery = 'PSGallery' - #AllowOldPowerShellGetModule = $true - #MinimumPSDependVersion = '0.3.0' AllowPrerelease = $false - WithYAML = $true # Will also bootstrap PowerShell-Yaml to read other config files + WithYAML = $true + + #UseModuleFast = $true + #ModuleFastVersion = '0.1.2' + #ModuleFastBleedingEdge = $true + + UsePSResourceGet = $true + #PSResourceGetVersion = '1.0.1' + + UsePowerShellGetCompatibilityModule = $true + UsePowerShellGetCompatibilityModuleVersion = '3.0.23-beta23' } diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 82ae0410..c5478029 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -6,6 +6,9 @@ trigger: paths: include: - source/* + - tests/* + - docs/* + - RequiredModules.psd1 tags: include: - "v*" @@ -42,7 +45,7 @@ stages: displayName: 'Build & Package Module' inputs: filePath: './build.ps1' - arguments: '-ResolveDependency -tasks pack' + arguments: '-ResolveDependency -Tasks pack -Verbose' pwsh: true env: ModuleVersion: $(NuGetVersionV2) @@ -95,11 +98,10 @@ stages: testResultsFiles: '$(buildFolderName)/$(testResultFolderName)/NUnit*.xml' testRunTitle: 'Unit (PowerShell 5.1 on Windows Server 2019)' - - task: PublishCodeCoverageResults@1 + - task: PublishCodeCoverageResults@2 displayName: 'Publish Code Coverage' condition: succeededOrFailed() inputs: - codeCoverageTool: 'JaCoCo' summaryFileLocation: '$(Build.SourcesDirectory)/$(buildFolderName)/$(testResultFolderName)/CodeCov*.xml' pathToSources: '$(Build.SourcesDirectory)/$(sourceFolderName)/' @@ -126,6 +128,7 @@ stages: azureApplicationPassword: $(azureApplicationPassword) azureSubscriptionId: $(azureSubscriptionId) azureTenantId: $(azureTenantId) + azureAppicationObjectId: $(azureAppicationObjectId) inputs: filePath: './build.ps1' arguments: "-Tasks test -PesterScript 'tests/Integration' -CodeCoverageThreshold 0" @@ -174,11 +177,10 @@ stages: testResultsFiles: '$(buildFolderName)/$(testResultFolderName)/NUnit*.xml' testRunTitle: 'Unit (PowerShell 5.1 on Windows Server 2022)' - - task: PublishCodeCoverageResults@1 + - task: PublishCodeCoverageResults@2 displayName: 'Publish Code Coverage' condition: succeededOrFailed() inputs: - codeCoverageTool: 'JaCoCo' summaryFileLocation: '$(Build.SourcesDirectory)/$(buildFolderName)/$(testResultFolderName)/CodeCov*.xml' pathToSources: '$(Build.SourcesDirectory)/$(sourceFolderName)/' @@ -205,6 +207,7 @@ stages: azureApplicationPassword: $(azureApplicationPassword) azureSubscriptionId: $(azureSubscriptionId) azureTenantId: $(azureTenantId) + azureAppicationObjectId: $(azureAppicationObjectId) inputs: filePath: './build.ps1' arguments: "-Tasks test -PesterScript 'tests/Integration' -CodeCoverageThreshold 0" @@ -253,11 +256,10 @@ stages: testResultsFiles: '$(buildFolderName)/$(testResultFolderName)/NUnit*.xml' testRunTitle: 'Unit (Powershell 7 on Ubuntu 20.04)' - - task: PublishCodeCoverageResults@1 + - task: PublishCodeCoverageResults@2 displayName: 'Publish Code Coverage' condition: succeededOrFailed() inputs: - codeCoverageTool: 'JaCoCo' summaryFileLocation: '$(Build.SourcesDirectory)/$(buildFolderName)/$(testResultFolderName)/CodeCov*.xml' pathToSources: '$(Build.SourcesDirectory)/$(sourceFolderName)/' @@ -284,6 +286,7 @@ stages: azureApplicationPassword: $(azureApplicationPassword) azureSubscriptionId: $(azureSubscriptionId) azureTenantId: $(azureTenantId) + azureAppicationObjectId: $(azureAppicationObjectId) inputs: filePath: './build.ps1' arguments: "-Tasks test -PesterScript 'tests/Integration' -CodeCoverageThreshold 0" @@ -332,11 +335,10 @@ stages: testResultsFiles: '$(buildFolderName)/$(testResultFolderName)/NUnit*.xml' testRunTitle: 'Unit (Powershell 7 on Ubuntu 22.04)' - - task: PublishCodeCoverageResults@1 + - task: PublishCodeCoverageResults@2 displayName: 'Publish Code Coverage' condition: succeededOrFailed() inputs: - codeCoverageTool: 'JaCoCo' summaryFileLocation: '$(Build.SourcesDirectory)/$(buildFolderName)/$(testResultFolderName)/CodeCov*.xml' pathToSources: '$(Build.SourcesDirectory)/$(sourceFolderName)/' @@ -363,6 +365,7 @@ stages: azureApplicationPassword: $(azureApplicationPassword) azureSubscriptionId: $(azureSubscriptionId) azureTenantId: $(azureTenantId) + azureAppicationObjectId: $(azureAppicationObjectId) inputs: filePath: './build.ps1' arguments: "-Tasks test -PesterScript 'tests/Integration' -CodeCoverageThreshold 0" @@ -415,11 +418,10 @@ stages: testResultsFiles: '$(buildFolderName)/$(testResultFolderName)/NUnit*.xml' testRunTitle: 'Unit (Powershell Core 6 on MacOS 11)' - - task: PublishCodeCoverageResults@1 + - task: PublishCodeCoverageResults@2 displayName: 'Publish Code Coverage' condition: succeededOrFailed() inputs: - codeCoverageTool: 'JaCoCo' summaryFileLocation: '$(Build.SourcesDirectory)/$(buildFolderName)/$(testResultFolderName)/CodeCov*.xml' pathToSources: '$(Build.SourcesDirectory)/$(sourceFolderName)/' @@ -451,6 +453,7 @@ stages: azureApplicationPassword: $(azureApplicationPassword) azureSubscriptionId: $(azureSubscriptionId) azureTenantId: $(azureTenantId) + azureAppicationObjectId: $(azureAppicationObjectId) inputs: filePath: './build.ps1' arguments: "-Tasks test -PesterScript 'tests/Integration' -CodeCoverageThreshold 0" @@ -503,11 +506,10 @@ stages: testResultsFiles: '$(buildFolderName)/$(testResultFolderName)/NUnit*.xml' testRunTitle: 'Unit (Powershell Core 6 on MacOS 12)' - - task: PublishCodeCoverageResults@1 + - task: PublishCodeCoverageResults@2 displayName: 'Publish Code Coverage' condition: succeededOrFailed() inputs: - codeCoverageTool: 'JaCoCo' summaryFileLocation: '$(Build.SourcesDirectory)/$(buildFolderName)/$(testResultFolderName)/CodeCov*.xml' pathToSources: '$(Build.SourcesDirectory)/$(sourceFolderName)/' @@ -539,6 +541,7 @@ stages: azureApplicationPassword: $(azureApplicationPassword) azureSubscriptionId: $(azureSubscriptionId) azureTenantId: $(azureTenantId) + azureAppicationObjectId: $(azureAppicationObjectId) inputs: filePath: './build.ps1' arguments: "-Tasks test -PesterScript 'tests/Integration' -CodeCoverageThreshold 0" diff --git a/build.ps1 b/build.ps1 index bbe1a8eb..f4a0faec 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,379 +1,538 @@ <# -.DESCRIPTION - Bootstrap and build script for PowerShell module pipeline + .DESCRIPTION + Bootstrap and build script for PowerShell module CI/CD pipeline. + + .PARAMETER Tasks + The task or tasks to run. The default value is '.' (runs the default task). + + .PARAMETER CodeCoverageThreshold + The code coverage target threshold to uphold. Set to 0 to disable. + The default value is '' (empty string). + + .PARAMETER BuildConfig + Not yet written. + + .PARAMETER OutputDirectory + Specifies the folder to build the artefact into. The default value is 'output'. + + .PARAMETER BuiltModuleSubdirectory + Subdirectory name to build the module (under $OutputDirectory). The default + value is '' (empty string). + + .PARAMETER RequiredModulesDirectory + Can be a path (relative to $PSScriptRoot or absolute) to tell Resolve-Dependency + and PSDepend where to save the required modules. It is also possible to use + 'CurrentUser' och 'AllUsers' to install missing dependencies. You can override + the value for PSDepend in the Build.psd1 build manifest. The default value is + 'output/RequiredModules'. + + .PARAMETER PesterScript + One or more paths that will override the Pester configuration in build + configuration file when running the build task Invoke_Pester_Tests. + + If running Pester 5 test, use the alias PesterPath to be future-proof. + + .PARAMETER PesterTag + Filter which tags to run when invoking Pester tests. This is used in the + Invoke-Pester.pester.build.ps1 tasks. + + .PARAMETER PesterExcludeTag + Filter which tags to exclude when invoking Pester tests. This is used in + the Invoke-Pester.pester.build.ps1 tasks. + + .PARAMETER DscTestTag + Filter which tags to run when invoking DSC Resource tests. This is used + in the DscResource.Test.build.ps1 tasks. + + .PARAMETER DscTestExcludeTag + Filter which tags to exclude when invoking DSC Resource tests. This is + used in the DscResource.Test.build.ps1 tasks. + + .PARAMETER ResolveDependency + Not yet written. + + .PARAMETER BuildInfo + The build info object from ModuleBuilder. Defaults to an empty hashtable. + + .PARAMETER AutoRestore + Not yet written. + + .PARAMETER UseModuleFast + Specifies to use ModuleFast instead of PowerShellGet to resolve dependencies + faster. + + .PARAMETER UsePSResourceGet + Specifies to use PSResourceGet instead of PowerShellGet to resolve dependencies + faster. This can also be configured in Resolve-Dependency.psd1. + + .PARAMETER UsePowerShellGetCompatibilityModule + Specifies to use the compatibility module PowerShellGet. This parameter + only works then the method of downloading dependencies is PSResourceGet. + This can also be configured in Resolve-Dependency.psd1. #> [CmdletBinding()] param ( [Parameter(Position = 0)] - [string[]]$Tasks = '.', + [System.String[]] + $Tasks = '.', [Parameter()] - [String] + [System.String] $CodeCoverageThreshold = '', [Parameter()] - [validateScript( + [System.String] + [ValidateScript( { Test-Path -Path $_ } )] $BuildConfig, [Parameter()] - # A Specific folder to build the artefact into. + [System.String] $OutputDirectory = 'output', [Parameter()] - # Subdirectory name to build the module (under $OutputDirectory) + [System.String] $BuiltModuleSubdirectory = '', - # Can be a path (relative to $PSScriptRoot or absolute) to tell Resolve-Dependency & PSDepend where to save the required modules, - # or use CurrentUser, AllUsers to target where to install missing dependencies - # You can override the value for PSDepend in the Build.psd1 build manifest - # This defaults to $OutputDirectory/modules (by default: ./output/modules) [Parameter()] + [System.String] $RequiredModulesDirectory = $(Join-Path 'output' 'RequiredModules'), [Parameter()] - [object[]] + # This alias is to prepare for the rename of this parameter to PesterPath when Pester 4 support is removed + [Alias('PesterPath')] + [System.Object[]] $PesterScript, - # Filter which tags to run when invoking Pester tests - # This is used in the Invoke-Pester.pester.build.ps1 tasks [Parameter()] - [string[]] + [System.String[]] $PesterTag, - # Filter which tags to exclude when invoking Pester tests - # This is used in the Invoke-Pester.pester.build.ps1 tasks [Parameter()] - [string[]] + [System.String[]] $PesterExcludeTag, - # Filter which tags to run when invoking DSC Resource tests - # This is used in the DscResource.Test.build.ps1 tasks [Parameter()] - [string[]] + [System.String[]] $DscTestTag, - # Filter which tags to exclude when invoking DSC Resource tests - # This is used in the DscResource.Test.build.ps1 tasks [Parameter()] - [string[]] + [System.String[]] $DscTestExcludeTag, [Parameter()] [Alias('bootstrap')] - [switch]$ResolveDependency, + [System.Management.Automation.SwitchParameter] + $ResolveDependency, [Parameter(DontShow)] [AllowNull()] + [System.Collections.Hashtable] $BuildInfo, [Parameter()] - [switch] - $AutoRestore + [System.Management.Automation.SwitchParameter] + $AutoRestore, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UseModuleFast, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePSResourceGet, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePowerShellGetCompatibilityModule ) -# The BEGIN block (at the end of this file) handles the Bootstrap of the Environment before Invoke-Build can run the tasks -# if the -ResolveDependency (aka Bootstrap) is specified, the modules are already available, and can be auto loaded +<# + The BEGIN block (at the end of this file) handles the Bootstrap of the Environment + before Invoke-Build can run the tasks if the parameter ResolveDependency (or + parameter alias Bootstrap) is specified. +#> process { - if ($MyInvocation.ScriptName -notLike '*Invoke-Build.ps1') { - # Only run the process block through InvokeBuild (Look at the Begin block at the bottom of this script) + # Only run the process block through InvokeBuild (look at the Begin block at the bottom of this script). return } - # Execute the Build Process from the .build.ps1 path. - Push-Location -Path $PSScriptRoot -StackName BeforeBuild + # Execute the Build process from the .build.ps1 path. + Push-Location -Path $PSScriptRoot -StackName 'BeforeBuild' try { - Write-Host -ForeGroundColor magenta "[build] Parsing defined tasks" + Write-Host -Object "[build] Parsing defined tasks" -ForeGroundColor Magenta - # Load Default BuildInfo if not provided as parameter - if (!$PSBoundParameters.ContainsKey('BuildInfo')) + # Load the default BuildInfo if the parameter BuildInfo is not set. + if (-not $PSBoundParameters.ContainsKey('BuildInfo')) { try { - if (Test-Path $BuildConfig) + if (Test-Path -Path $BuildConfig) { - $ConfigFile = (Get-Item -Path $BuildConfig) - Write-Host "[build] Loading Configuration from $ConfigFile" - $BuildInfo = switch -Regex ($ConfigFile.Extension) + $configFile = Get-Item -Path $BuildConfig + + Write-Host -Object "[build] Loading Configuration from $configFile" + + $BuildInfo = switch -Regex ($configFile.Extension) { # Native Support for PSD1 '\.psd1' { + if (-not (Get-Command -Name Import-PowerShellDataFile -ErrorAction SilentlyContinue)) + { + Import-Module -Name Microsoft.PowerShell.Utility -RequiredVersion 3.1.0.0 + } + Import-PowerShellDataFile -Path $BuildConfig } + # Support for yaml when module PowerShell-Yaml is available '\.[yaml|yml]' { - Import-Module -ErrorAction Stop -Name 'powershell-yaml' - ConvertFrom-Yaml -Yaml (Get-Content -Raw $ConfigFile) + Import-Module -Name 'powershell-yaml' -ErrorAction Stop + + ConvertFrom-Yaml -Yaml (Get-Content -Raw $configFile) } - # Native Support for JSON and JSONC (by Removing comments) + + # Support for JSON and JSONC (by Removing comments) when module PowerShell-Yaml is available '\.[json|jsonc]' { - $JSONC = (Get-Content -Raw -Path $ConfigFile) - $JSON = $JSONC -replace '(?m)\s*//.*?$' -replace '(?ms)/\*.*?\*/' - # This should probably be converted to hashtable for splatting - $JSON | ConvertFrom-Json + $jsonFile = Get-Content -Raw -Path $configFile + + $jsonContent = $jsonFile -replace '(?m)\s*//.*?$' -replace '(?ms)/\*.*?\*/' + + # Yaml is superset of JSON. + ConvertFrom-Yaml -Yaml $jsonContent } + + # Unknown extension, return empty hashtable. default { - Write-Error "Extension '$_' not supported. using @{}" + Write-Error -Message "Extension '$_' not supported. using @{}" + @{ } } } } else { - Write-Host -Object "Configuration file $BuildConfig not found" -ForegroundColor Red + Write-Host -Object "Configuration file '$($BuildConfig.FullName)' not found" -ForegroundColor Red + + # No config file was found, return empty hashtable. $BuildInfo = @{ } } } catch { - Write-Host -Object "Error loading Config $ConfigFile.`r`n Are you missing dependencies?" -ForegroundColor Yellow - Write-Host -Object "Make sure you run './build.ps1 -ResolveDependency -tasks noop' to restore the Required modules the first time" -ForegroundColor Yellow + $logMessage = "Error loading Config '$($BuildConfig.FullName)'.`r`nAre you missing dependencies?`r`nMake sure you run './build.ps1 -ResolveDependency -tasks noop' before running build to restore the required modules." + + Write-Host -Object $logMessage -ForegroundColor Yellow + $BuildInfo = @{ } - Write-Error $_.Exception.Message + + Write-Error -Message $_.Exception.Message } } - # If the Invoke-Build Task Header is specified in the Build Info, set it + # If the Invoke-Build Task Header is specified in the Build Info, set it. if ($BuildInfo.TaskHeader) { - Set-BuildHeader ([scriptblock]::Create($BuildInfo.TaskHeader)) + Set-BuildHeader -Script ([scriptblock]::Create($BuildInfo.TaskHeader)) + } + + <# + Add BuildModuleOutput to PSModule Path environment variable. + Moved here (not in begin block) because build file can contains BuiltSubModuleDirectory value. + #> + if ($BuiltModuleSubdirectory) + { + if (-not (Split-Path -IsAbsolute -Path $BuiltModuleSubdirectory)) + { + $BuildModuleOutput = Join-Path -Path $OutputDirectory -ChildPath $BuiltModuleSubdirectory + } + else + { + $BuildModuleOutput = $BuiltModuleSubdirectory + } + } # test if BuiltModuleSubDirectory set in build config file + elseif ($BuildInfo.ContainsKey('BuiltModuleSubDirectory')) + { + $BuildModuleOutput = Join-Path -Path $OutputDirectory -ChildPath $BuildInfo['BuiltModuleSubdirectory'] + } + else + { + $BuildModuleOutput = $OutputDirectory } - # Import Tasks from modules via their exported aliases when defined in BUild Manifest - # https://github.com/nightroman/Invoke-Build/tree/master/Tasks/Import#example-2-import-from-a-module-with-tasks - if ($BuildInfo.containsKey('ModuleBuildTasks')) + # Pre-pending $BuildModuleOutput folder to PSModulePath to resolve built module from this folder. + if ($powerShellModulePaths -notcontains $BuildModuleOutput) { - foreach ($Module in $BuildInfo['ModuleBuildTasks'].Keys) + Write-Host -Object "[build] Pre-pending '$BuildModuleOutput' folder to PSModulePath" -ForegroundColor Green + + $env:PSModulePath = $BuildModuleOutput + [System.IO.Path]::PathSeparator + $env:PSModulePath + } + + <# + Import Tasks from modules via their exported aliases when defined in Build Manifest. + https://github.com/nightroman/Invoke-Build/tree/master/Tasks/Import#example-2-import-from-a-module-with-tasks + #> + if ($BuildInfo.ContainsKey('ModuleBuildTasks')) + { + foreach ($module in $BuildInfo['ModuleBuildTasks'].Keys) { try { - Write-Host -ForegroundColor DarkGray -Verbose "Importing tasks from module $Module" - $LoadedModule = Import-Module $Module -PassThru -ErrorAction Stop - foreach ($TaskToExport in $BuildInfo['ModuleBuildTasks'].($Module)) + Write-Host -Object "Importing tasks from module $module" -ForegroundColor DarkGray + + $loadedModule = Import-Module -Name $module -PassThru -ErrorAction Stop + + foreach ($TaskToExport in $BuildInfo['ModuleBuildTasks'].($module)) { - $LoadedModule.ExportedAliases.GetEnumerator().Where{ - # using -like to support wildcard - Write-Host -ForegroundColor DarkGray "`t Loading $($_.Key)..." + $loadedModule.ExportedAliases.GetEnumerator().Where{ + Write-Host -Object "`t Loading $($_.Key)..." -ForegroundColor DarkGray + + # Using -like to support wildcard. $_.Key -like $TaskToExport }.ForEach{ - # Dot sourcing the Tasks via their exported aliases + # Dot-sourcing the Tasks via their exported aliases. . (Get-Alias $_.Key) } } } catch { - Write-Host -ForegroundColor Red -Object "Could not load tasks for module $Module." - Write-Error $_ + Write-Host -Object "Could not load tasks for module $module." -ForegroundColor Red + + Write-Error -Message $_ } } } - # Loading Build Tasks defined in the .build/ folder (will override the ones imported above if same task name) - Get-ChildItem -Path ".build/" -Recurse -Include *.ps1 -ErrorAction Ignore | ForEach-Object { - "Importing file $($_.BaseName)" | Write-Verbose - . $_.FullName - } + # Loading Build Tasks defined in the .build/ folder (will override the ones imported above if same task name). + Get-ChildItem -Path '.build/' -Recurse -Include '*.ps1' -ErrorAction Ignore | + ForEach-Object { + "Importing file $($_.BaseName)" | Write-Verbose - # Synopsis: Empty task, useful to test the bootstrap process + . $_.FullName + } + + # Synopsis: Empty task, useful to test the bootstrap process. task noop { } - # Define default task sequence ("."), can be overridden in the $BuildInfo + # Define default task sequence ("."), can be overridden in the $BuildInfo. task . { - Write-Build Yellow "No sequence currently defined for the default task" + Write-Build -Object 'No sequence currently defined for the default task' -ForegroundColor Yellow } - # Load Invoke-Build task sequences/workflows from $BuildInfo - Write-Host -ForegroundColor DarkGray "Adding Workflow from configuration:" - foreach ($Workflow in $BuildInfo.BuildWorkflow.keys) + Write-Host -Object 'Adding Workflow from configuration:' -ForegroundColor DarkGray + + # Load Invoke-Build task sequences/workflows from $BuildInfo. + foreach ($workflow in $BuildInfo.BuildWorkflow.keys) { - Write-Verbose "Creating Build Workflow '$Workflow' with tasks $($BuildInfo.BuildWorkflow.($Workflow) -join ', ')" - $WorkflowItem = $BuildInfo.BuildWorkflow.($Workflow) - if ($WorkflowItem.Trim() -match '^\{(?[\w\W]*)\}$') + Write-Verbose -Message "Creating Build Workflow '$Workflow' with tasks $($BuildInfo.BuildWorkflow.($Workflow) -join ', ')." + + $workflowItem = $BuildInfo.BuildWorkflow.($workflow) + + if ($workflowItem.Trim() -match '^\{(?[\w\W]*)\}$') { - $WorkflowItem = [ScriptBlock]::Create($Matches['sb']) + $workflowItem = [ScriptBlock]::Create($Matches['sb']) } - Write-Host -ForegroundColor DarkGray " +-> $Workflow" - task $Workflow $WorkflowItem + + Write-Host -Object " +-> $workflow" -ForegroundColor DarkGray + + task $workflow $workflowItem } - Write-Host -ForeGroundColor magenta "[build] Executing requested workflow: $($Tasks -join ', ')" + Write-Host -Object "[build] Executing requested workflow: $($Tasks -join ', ')" -ForeGroundColor Magenta } finally { - Pop-Location -StackName BeforeBuild + Pop-Location -StackName 'BeforeBuild' } } -Begin +begin { - # Find build config if not specified - if (-not $BuildConfig) { - $config = Get-ChildItem -Path "$PSScriptRoot\*" -Include 'build.y*ml', 'build.psd1', 'build.json*' -ErrorAction:Ignore - if (-not $config -or ($config -is [array] -and $config.Length -le 0)) { - throw "No build configuration found. Specify path via -BuildConfig" + # Find build config if not specified. + if (-not $BuildConfig) + { + $config = Get-ChildItem -Path "$PSScriptRoot\*" -Include 'build.y*ml', 'build.psd1', 'build.json*' -ErrorAction Ignore + + if (-not $config -or ($config -is [System.Array] -and $config.Length -le 0)) + { + throw 'No build configuration found. Specify path via parameter BuildConfig.' } - elseif ($config -is [array]) { - if ($config.Length -gt 1) { - throw "More than one build configuration found. Specify which one to use via -BuildConfig" + elseif ($config -is [System.Array]) + { + if ($config.Length -gt 1) + { + throw 'More than one build configuration found. Specify which path to use via parameter BuildConfig.' } + $BuildConfig = $config[0] } - else { + else + { $BuildConfig = $config } } + # Bootstrapping the environment before using Invoke-Build as task runner - if ($MyInvocation.ScriptName -notLike '*Invoke-Build.ps1') + if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { - Write-Host -foregroundColor Green "[pre-build] Starting Build Init" - Push-Location $PSScriptRoot -StackName BuildModule + Write-Host -Object "[pre-build] Starting Build Init" -ForegroundColor Green + + Push-Location $PSScriptRoot -StackName 'BuildModule' } if ($RequiredModulesDirectory -in @('CurrentUser', 'AllUsers')) { - # Installing modules instead of saving them - Write-Host -foregroundColor Green "[pre-build] Required Modules will be installed for $RequiredModulesDirectory, not saved." - # Tell Resolve-Dependency to use provided scope as the -PSDependTarget if not overridden in Build.psd1 + # Installing modules instead of saving them. + Write-Host -Object "[pre-build] Required Modules will be installed to the PowerShell module path that is used for $RequiredModulesDirectory." -ForegroundColor Green + + <# + The variable $PSDependTarget will be used below when building the splatting + variable before calling Resolve-Dependency.ps1, unless overridden in the + file Resolve-Dependency.psd1. + #> $PSDependTarget = $RequiredModulesDirectory } else { - if (-Not (Split-Path -IsAbsolute -Path $OutputDirectory)) + if (-not (Split-Path -IsAbsolute -Path $OutputDirectory)) { $OutputDirectory = Join-Path -Path $PSScriptRoot -ChildPath $OutputDirectory } - # Resolving the absolute path to save the required modules to - if (-Not (Split-Path -IsAbsolute -Path $RequiredModulesDirectory)) + # Resolving the absolute path to save the required modules to. + if (-not (Split-Path -IsAbsolute -Path $RequiredModulesDirectory)) { $RequiredModulesDirectory = Join-Path -Path $PSScriptRoot -ChildPath $RequiredModulesDirectory } - # Create the output/modules folder if not exists, or resolve the Absolute path otherwise - if (Resolve-Path $RequiredModulesDirectory -ErrorAction SilentlyContinue) + # Create the output/modules folder if not exists, or resolve the Absolute path otherwise. + if (Resolve-Path -Path $RequiredModulesDirectory -ErrorAction SilentlyContinue) { - Write-Debug "[pre-build] Required Modules path already exist at $RequiredModulesDirectory" - $RequiredModulesPath = Convert-Path $RequiredModulesDirectory + Write-Debug -Message "[pre-build] Required Modules path already exist at $RequiredModulesDirectory" + + $requiredModulesPath = Convert-Path -Path $RequiredModulesDirectory } else { - Write-Host -foregroundColor Green "[pre-build] Creating required modules directory $RequiredModulesDirectory." - $RequiredModulesPath = (New-Item -ItemType Directory -Force -Path $RequiredModulesDirectory).FullName - } + Write-Host -Object "[pre-build] Creating required modules directory $RequiredModulesDirectory." -ForegroundColor Green - # Prepending $RequiredModulesPath folder to PSModulePath to resolve from this folder FIRST - if ($RequiredModulesDirectory -notIn @('CurrentUser', 'AllUsers') -and - (($Env:PSModulePath -split [io.path]::PathSeparator) -notContains $RequiredModulesDirectory)) - { - Write-Host -foregroundColor Green "[pre-build] Prepending '$RequiredModulesDirectory' folder to PSModulePath" - $Env:PSModulePath = $RequiredModulesDirectory + [io.path]::PathSeparator + $Env:PSModulePath + $requiredModulesPath = (New-Item -ItemType Directory -Force -Path $RequiredModulesDirectory).FullName } - # Checking if the user should -ResolveDependency - if ((!(Get-Module -ListAvailable powershell-yaml) -or !(Get-Module -ListAvailable InvokeBuild) -or !(Get-Module -ListAvailable PSDepend)) -and !$ResolveDependency) + $powerShellModulePaths = $env:PSModulePath -split [System.IO.Path]::PathSeparator + + # Pre-pending $requiredModulesPath folder to PSModulePath to resolve from this folder FIRST. + if ($RequiredModulesDirectory -notin @('CurrentUser', 'AllUsers') -and + ($powerShellModulePaths -notcontains $RequiredModulesDirectory)) { - if ($AutoRestore -or !$PSBoundParameters.ContainsKey('Tasks') -or $Tasks -contains 'build') - { - Write-Host -ForegroundColor Yellow "[pre-build] Dependency missing, running './build.ps1 -ResolveDependency -Tasks noop' for you `r`n" - $ResolveDependency = $true - } - else - { - Write-Warning "Some required Modules are missing, make sure you first run with the '-ResolveDependency' parameter." - Write-Warning "Running 'build.ps1 -ResolveDependency -Tasks noop' will pull required modules without running the build task." - } + Write-Host -Object "[pre-build] Pre-pending '$RequiredModulesDirectory' folder to PSModulePath" -ForegroundColor Green + + $env:PSModulePath = $RequiredModulesDirectory + [System.IO.Path]::PathSeparator + $env:PSModulePath } - if ($BuiltModuleSubdirectory) + $powerShellYamlModule = Get-Module -Name 'powershell-yaml' -ListAvailable + $invokeBuildModule = Get-Module -Name 'InvokeBuild' -ListAvailable + $psDependModule = Get-Module -Name 'PSDepend' -ListAvailable + + # Checking if the user should -ResolveDependency. + if (-not ($powerShellYamlModule -and $invokeBuildModule -and $psDependModule) -and -not $ResolveDependency) { - if (-Not (Split-Path -IsAbsolute $BuiltModuleSubdirectory)) + if ($AutoRestore -or -not $PSBoundParameters.ContainsKey('Tasks') -or $Tasks -contains 'build') { - $BuildModuleOutput = Join-Path $OutputDirectory $BuiltModuleSubdirectory + Write-Host -Object "[pre-build] Dependency missing, running './build.ps1 -ResolveDependency -Tasks noop' for you `r`n" -ForegroundColor Yellow + + $ResolveDependency = $true } else { - $BuildModuleOutput = $BuiltModuleSubdirectory + Write-Warning -Message "Some required Modules are missing, make sure you first run with the '-ResolveDependency' parameter. Running 'build.ps1 -ResolveDependency -Tasks noop' will pull required modules without running the build task." } } - else - { - $BuildModuleOutput = $OutputDirectory - } - - # Prepending $BuildModuleOutput folder to PSModulePath to resolve built module from this folder - if (($Env:PSModulePath -split [io.path]::PathSeparator) -notContains $BuildModuleOutput) - { - Write-Host -foregroundColor Green "[pre-build] Prepending '$BuildModuleOutput' folder to PSModulePath" - $Env:PSModulePath = $BuildModuleOutput + [io.path]::PathSeparator + $Env:PSModulePath - } - # Tell Resolve-Dependency to use $RequiredModulesPath as -PSDependTarget if not overridden in Build.psd1 - $PSDependTarget = $RequiredModulesPath + <# + The variable $PSDependTarget will be used below when building the splatting + variable before calling Resolve-Dependency.ps1, unless overridden in the + file Resolve-Dependency.psd1. + #> + $PSDependTarget = $requiredModulesPath } if ($ResolveDependency) { - Write-Host -Object "[pre-build] Resolving dependencies." -foregroundColor Green - $ResolveDependencyParams = @{ } + Write-Host -Object "[pre-build] Resolving dependencies using preferred method." -ForegroundColor Green - # If BuildConfig is a Yaml file, bootstrap powershell-yaml via ResolveDependency + $resolveDependencyParams = @{ } + + # If BuildConfig is a Yaml file, bootstrap powershell-yaml via ResolveDependency. if ($BuildConfig -match '\.[yaml|yml]$') { - $ResolveDependencyParams.add('WithYaml', $True) + $resolveDependencyParams.Add('WithYaml', $true) } - $ResolveDependencyAvailableParams = (Get-Command -Name '.\Resolve-Dependency.ps1').parameters.keys - foreach ($CmdParameter in $ResolveDependencyAvailableParams) - { + $resolveDependencyAvailableParams = (Get-Command -Name '.\Resolve-Dependency.ps1').Parameters.Keys + foreach ($cmdParameter in $resolveDependencyAvailableParams) + { # The parameter has been explicitly used for calling the .build.ps1 - if ($MyInvocation.BoundParameters.ContainsKey($CmdParameter)) + if ($MyInvocation.BoundParameters.ContainsKey($cmdParameter)) { - $ParamValue = $MyInvocation.BoundParameters.ContainsKey($CmdParameter) - Write-Debug " adding $CmdParameter :: $ParamValue [from user-provided parameters to Build.ps1]" - $ResolveDependencyParams.Add($CmdParameter, $ParamValue) + $paramValue = $MyInvocation.BoundParameters.Item($cmdParameter) + + Write-Debug " adding $cmdParameter :: $paramValue [from user-provided parameters to Build.ps1]" + + $resolveDependencyParams.Add($cmdParameter, $paramValue) } # Use defaults parameter value from Build.ps1, if any else { - if ($ParamValue = Get-Variable -Name $CmdParameter -ValueOnly -ErrorAction Ignore) + $paramValue = Get-Variable -Name $cmdParameter -ValueOnly -ErrorAction Ignore + + if ($paramValue) { - Write-Debug " adding $CmdParameter :: $ParamValue [from default Build.ps1 variable]" - $ResolveDependencyParams.add($CmdParameter, $ParamValue) + Write-Debug " adding $cmdParameter :: $paramValue [from default Build.ps1 variable]" + + $resolveDependencyParams.Add($cmdParameter, $paramValue) } } } - Write-Host -foregroundColor Green "[pre-build] Starting bootstrap process." - .\Resolve-Dependency.ps1 @ResolveDependencyParams + Write-Host -Object "[pre-build] Starting bootstrap process." -ForegroundColor Green + + .\Resolve-Dependency.ps1 @resolveDependencyParams } - if ($MyInvocation.ScriptName -notLike '*Invoke-Build.ps1') + if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { - Write-Verbose "Bootstrap completed. Handing back to InvokeBuild." + Write-Verbose -Message "Bootstrap completed. Handing back to InvokeBuild." + if ($PSBoundParameters.ContainsKey('ResolveDependency')) { - Write-Verbose "Dependency already resolved. Removing task" + Write-Verbose -Message "Dependency already resolved. Removing task." + $null = $PSBoundParameters.Remove('ResolveDependency') } - Write-Host -foregroundColor Green "[build] Starting build with InvokeBuild." + + Write-Host -Object "[build] Starting build with InvokeBuild." -ForegroundColor Green + Invoke-Build @PSBoundParameters -Task $Tasks -File $MyInvocation.MyCommand.Path - Pop-Location -StackName BuildModule + + Pop-Location -StackName 'BuildModule' + return } } diff --git a/build.yaml b/build.yaml index 12413c90..0b37cf89 100644 --- a/build.yaml +++ b/build.yaml @@ -1,20 +1,6 @@ --- #################################################### -# ModuleBuilder Configuration # -#################################################### -CopyPaths: - - classes - - formats - - en-US - - types -prefix: prefix.ps1 -suffix: suffix.ps1 -Encoding: UTF8 -VersionedOutputDirectory: true - - -#################################################### -# Sampler Pipeline Configuration # +# Pipeline Build Task Configuration (Invoke-Build) # #################################################### BuildWorkflow: Compile_Classes: | @@ -98,25 +84,18 @@ BuildWorkflow: - Publish_Release_To_GitHub - Publish_Module_To_gallery - #################################################### -# PESTER Configuration # +# ModuleBuilder Configuration # #################################################### - -Pester: - OutputFormat: NUnitXML - ExcludeFromCodeCoverage: - Script: - - tests/Unit - - tests/Integration - ExcludeTag: - Tag: - CodeCoverageThreshold: 70 - -Resolve-Dependency: - Gallery: 'PSGallery' - AllowPrerelease: false - Verbose: false +CopyPaths: + - classes + - formats + - en-US + - types +prefix: prefix.ps1 +suffix: suffix.ps1 +Encoding: UTF8 +VersionedOutputDirectory: true ModuleBuildTasks: Sampler: @@ -135,6 +114,30 @@ TaskHeader: | Write-Build DarkGray " $($Task.InvocationInfo.ScriptName):$($Task.InvocationInfo.ScriptLineNumber)" "" +#################################################### +# PESTER Configuration # +#################################################### +Pester: + OutputFormat: NUnitXML + ExcludeFromCodeCoverage: + Script: + - tests/Unit + - tests/Integration + ExcludeTag: + Tag: + CodeCoverageThreshold: 70 + +#################################################### +# PSDepend Configuration # +#################################################### +Resolve-Dependency: + Gallery: 'PSGallery' + AllowPrerelease: false + Verbose: false + +#################################################### +# GitHub Configuration # +#################################################### GitHubConfig: GitHubFilesToAdd: - 'CHANGELOG.md' diff --git a/docs/CosmosDB.md b/docs/CosmosDB.md index cdea2b5b..ad4dbdb4 100644 --- a/docs/CosmosDB.md +++ b/docs/CosmosDB.md @@ -71,6 +71,11 @@ Return documents from a Cosmos DB database collection as a JSON string. Return the resource path for a document object. +### [Get-CosmosDbEntraIdToken](Get-CosmosDbEntraIdToken.md) + +Generates a secure string token for use with Azure Cosmos DB by +calling the Entra ID service using the Get-AzAccessToken cmdlet. + ### [Get-CosmosDbOffer](Get-CosmosDbOffer.md) Return the offers in a Cosmos DB account. @@ -150,7 +155,7 @@ in Azure. Create a new attachment for a document in a Cosmos DB database. -### [New-CosmosDbAuthorizationToken](New-CosmosDbAuthorizationToken.md) +### [Get-CosmosDbAuthorizationHeaderFromContext](Get-CosmosDbAuthorizationHeaderFromContext.md) Create a new Authorization Token to be used with in a Rest API request to Cosmos DB. diff --git a/docs/New-CosmosDbAuthorizationToken.md b/docs/Get-CosmosDbAuthorizationHeaderFromContext.md similarity index 75% rename from docs/New-CosmosDbAuthorizationToken.md rename to docs/Get-CosmosDbAuthorizationHeaderFromContext.md index d4fca6be..12646f49 100644 --- a/docs/New-CosmosDbAuthorizationToken.md +++ b/docs/Get-CosmosDbAuthorizationHeaderFromContext.md @@ -5,7 +5,7 @@ online version: schema: 2.0.0 --- -# New-CosmosDbAuthorizationToken +# Get-CosmosDbAuthorizationHeaderFromContext ## SYNOPSIS @@ -15,17 +15,19 @@ Rest API request to Cosmos DB. ## SYNTAX ```powershell -New-CosmosDbAuthorizationToken [-Key] [[-KeyType] ] [[-Method] ] +Get-CosmosDbAuthorizationHeaderFromContext [-Key] [[-KeyType] ] [[-Method] ] [[-ResourceType] ] [[-ResourceId] ] [-Date] [[-TokenVersion] ] [] ``` ## DESCRIPTION -This cmdlet is used to create an Authorization Token to -pass in the header of a Rest API request to an Azure Cosmos DB. -The Authorization token that is generated must match the -other parameters in the header of the request that is passed. +This cmdlet is used to create an HTTP request header containing +a master key Authorization Token and the date of the request +to pass in a Rest API request to an Azure Cosmos DB. +The Authorization token that is generated will match the +other parameters in the header of the request that is passed +and can not be used with other requests. ## EXAMPLES @@ -33,11 +35,12 @@ other parameters in the header of the request that is passed. ```powershell PS C:\> $dttoken = ConvertTo-CosmosDbTokenDateString -Date (Get-Date) -PS C:\> $token = New-CosmosDbAuthorizationToken -Key $Key -KeyType master -Method Get -ResourceType 'dbs' -ResourceId 'dbs/mydatabase' -Date ($dttoken) +PS C:\> $header = Get-CosmosDbAuthorizationHeaderFromContext -Key $Key -KeyType master -Method Get -ResourceType 'dbs' -ResourceId 'dbs/mydatabase' -Date ($dttoken) ``` -Generate a Cosmos DB authorization token using a master key $Key -for issuing a 'Get' request on the dbs (database) 'mydatabase'. +Generate a collection of headers required for Cosmos DB token authorization +using a master key $Key for issuing a 'Get' request on the dbs (database) +'mydatabase'. ## PARAMETERS @@ -112,7 +115,8 @@ Accept wildcard characters: False This is the resource Id of the Cosmos DB being accessed. This is in the format 'dbs/{database}' and must match the the value in the path of the URI that the request is made -to. +to. This value is case sensitive and must match the case +of the required resource stored in Cosmos DB account. ```yaml Type: String diff --git a/docs/Get-CosmosDbEntraIdToken.md b/docs/Get-CosmosDbEntraIdToken.md new file mode 100644 index 00000000..c70fdcfa --- /dev/null +++ b/docs/Get-CosmosDbEntraIdToken.md @@ -0,0 +1,71 @@ +--- +external help file: CosmosDB-help.xml +Module Name: CosmosDB +online version: +schema: 2.0.0 +--- + +# Get-CosmosDbEntraIdToken + +## SYNOPSIS + +Generates a secure string token for use with Azure Cosmos DB by +calling the Entra ID service using the Get-AzAccessToken cmdlet. + +## SYNTAX + +```powershell +Get-CosmosDbEntraIdToken [[-Endpoint] ] [-ProgressAction ] [] +``` + +## DESCRIPTION + +Generates a secure string token for use with Azure Cosmos DB by +calling the Entra ID service using the Get-AzAccessToken cmdlet. +This requires that a user or service principal has been authenticated +using the Connect-AzAccount cmdlet. If the user is not authenticated, +the cmdlet will return null but display an error message. + +## EXAMPLES + +### Example 1 +```powershell +PS C:\> Get-CosmosDbEntraIdToken +``` + +This will return a secure string token that can be used with Azure Cosmos DB. +The token will use the default resource URI of https://cosmos.azure.com. + +## PARAMETERS + +### -Endpoint + +This parameter allows the resource URI of the token to be specified. The default +value is https://cosmos.azure.com. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 0 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +## OUTPUTS + +### System.Security.SecureString + +## NOTES + +## RELATED LINKS diff --git a/docs/New-CosmosDbContext.md b/docs/New-CosmosDbContext.md index bd43cee4..4804dc4d 100644 --- a/docs/New-CosmosDbContext.md +++ b/docs/New-CosmosDbContext.md @@ -47,6 +47,22 @@ New-CosmosDbContext -Account [-Database ] -ResourceGroupName ] ``` +### EntraIdToken + +```powershell +New-CosmosDbContext -Account [-Database ] -EntraIdToken + [-BackoffPolicy ] [-Environment ] + [-WhatIf] [-Confirm] [] +``` + +### EntraIdTokenAutogen + +```powershell +New-CosmosDbContext -Account [-Database ] -AutoGenerateEntraIdToken + [-BackoffPolicy ] [-Environment ] + [-WhatIf] [-Confirm] [] +``` + ### Token ```powershell @@ -176,6 +192,27 @@ PS C:\> $cosmosDbContext = New-CosmosDbContext -ConnectionString ($connectionStr Creates a CosmosDB context specifying the connection string connecting to the Cosmos DB account. +### Example 10 + +```powershell +PS C:\> $entraIdToken = Get-CosmosDbEntraIdToken -Endpoint 'https://MyAzureCosmosDB.documents.azure.com/' +PS C:\> $cosmosDbContext = New-CosmosDbContext -Account 'MyAzureCosmosDB' -EntraIdToken $entraIdToken +``` +Creates a CosmosDB context specifying the an Entra ID OAuth2 token generated +by the Entra ID service for the endpoint 'https://MyAzureCosmosDB.documents.azure.com/'. The +identity it represents should have the appropriate RBAC permissions to access the Cosmos DB +account. + +### Example 11 + +```powershell +PS C:\> $cosmosDbContext = New-CosmosDbContext -Account 'MyAzureCosmosDB' -AutoGenerateEntraIdToken +``` +Creates a CosmosDB context with an Entra ID OAuth2 token generated automatically. This will use the +Get-CosmosDbEntraIdToken function to generate the token using the Base URI for the context as the +resource URL. This function requires that the Az.Account module is installed and that the user or +principle is logged in and has the appropriate RBAC permissions to access the Cosmos DB account. + ## PARAMETERS ### -Account @@ -184,7 +221,7 @@ The account name of the Cosmos DB to access. ```yaml Type: String -Parameter Sets: Account, CustomAzureAccount, CustomAccount, AzureAccount, Token +Parameter Sets: Account, CustomAzureAccount, CustomAccount, AzureAccount, EntraIdToken, EntraIdTokenAutogen, Token Aliases: Required: True @@ -303,7 +340,7 @@ The supported values are: ```yaml Type: Environment -Parameter Sets: Account, AzureAccount, Token, ConnectionString +Parameter Sets: Account, AzureAccount, ConnectionString, EntraIdToken, EntraIdTokenAutogen, Token Aliases: Accepted values: AzureChinaCloud, AzureCloud, AzureUSGovernment @@ -424,6 +461,41 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -EntraIdToken + +This contains an Entra ID OAuth2 token that will be used to +authenticate to the account, database or collection. + +```yaml +Type: SecureString +Parameter Sets: EntraIdToken +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -AutoGenerateEntraIdToken + +This switch causes this function to request an OAuth2 token +using the Base URI for the context as the resource URL from +Entra ID using the Az.Account Get-AzAccessToken function. + +```yaml +Type: SecureString +Parameter Sets: EntraIdTokenAutogen +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Token This is an array of Token objects. These can be generated by diff --git a/source/CosmosDB.psd1 b/source/CosmosDB.psd1 index ecc3d5d6..d6a9c8ce 100644 --- a/source/CosmosDB.psd1 +++ b/source/CosmosDB.psd1 @@ -28,8 +28,8 @@ # Modules that must be imported into the global environment prior to importing this module RequiredModules = @( - @{ ModuleName = 'Az.Accounts'; GUID = '17a2feff-488b-47f9-8729-e2cec094624c'; ModuleVersion = '1.0.0'; }, - @{ ModuleName = 'Az.Resources'; GUID = '48bb344d-4c24-441e-8ea0-589947784700'; ModuleVersion = '1.0.0'; } + @{ ModuleName = 'Az.Accounts'; GUID = '17a2feff-488b-47f9-8729-e2cec094624c'; ModuleVersion = '2.19.0'; }, + @{ ModuleName = 'Az.Resources'; GUID = '48bb344d-4c24-441e-8ea0-589947784700'; ModuleVersion = '6.16.2'; } ) # Type files (.ps1xml) to be loaded when importing this module diff --git a/source/Private/utils/New-CosmosDbAuthorizationToken.ps1 b/source/Private/utils/Get-CosmosDbAuthorizationHeaderFromContext.ps1 similarity index 81% rename from source/Private/utils/New-CosmosDbAuthorizationToken.ps1 rename to source/Private/utils/Get-CosmosDbAuthorizationHeaderFromContext.ps1 index 807380f4..f915b9dd 100644 --- a/source/Private/utils/New-CosmosDbAuthorizationToken.ps1 +++ b/source/Private/utils/Get-CosmosDbAuthorizationHeaderFromContext.ps1 @@ -1,8 +1,8 @@ -function New-CosmosDbAuthorizationToken +function Get-CosmosDbAuthorizationHeaderFromContext { [CmdletBinding()] - [OutputType([System.String])] + [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = $true)] @@ -46,10 +46,10 @@ function New-CosmosDbAuthorizationToken $dateString = ConvertTo-CosmosDbTokenDateString -Date $Date $payLoad = @( $Method.ToLowerInvariant() + "`n" + ` - $ResourceType.ToLowerInvariant() + "`n" + ` - $ResourceId + "`n" + ` - $dateString.ToLowerInvariant() + "`n" + ` - "" + "`n" + $ResourceType.ToLowerInvariant() + "`n" + ` + $ResourceId + "`n" + ` + $dateString.ToLowerInvariant() + "`n" + ` + "" + "`n" ) $body = [System.Text.Encoding]::UTF8.GetBytes($payLoad) @@ -58,5 +58,9 @@ function New-CosmosDbAuthorizationToken Add-Type -AssemblyName 'System.Web' $token = [System.Web.HttpUtility]::UrlEncode(('type={0}&ver={1}&sig={2}' -f $KeyType, $TokenVersion, $signature)) - return $token + $headers = @{ + 'authorization' = $token + 'x-ms-date' = $dateString + } + return $headers } diff --git a/source/Private/utils/Get-CosmosDbAuthorizationHeaderFromContextEntraId.ps1 b/source/Private/utils/Get-CosmosDbAuthorizationHeaderFromContextEntraId.ps1 new file mode 100644 index 00000000..d2790fc4 --- /dev/null +++ b/source/Private/utils/Get-CosmosDbAuthorizationHeaderFromContextEntraId.ps1 @@ -0,0 +1,30 @@ +function Get-CosmosDbAuthorizationHeaderFromContextEntraId +{ + + [CmdletBinding()] + [OutputType([System.Collections.Hashtable])] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [CosmosDB.Context] + $Context, + + [Parameter(Mandatory = $true)] + [System.DateTime] + $Date + ) + + Write-Verbose -Message $($LocalizedData.CreateAuthorizationTokenEntraId) + + if (-not [System.String]::IsNullOrEmpty($Context.EntraIdToken)) + { + $decryptedEntraIdToken = Convert-CosmosDbSecureStringToString -SecureString $Context.EntraIdToken + $token = [System.Web.HttpUtility]::UrlEncode(('type=aad&ver=1.0&sig={0}' -f $decryptedEntraIdToken)) + $headers = @{ + 'authorization' = $token + 'x-ms-date' = ConvertTo-CosmosDbTokenDateString -Date $Date + } + return $headers + } +} diff --git a/source/Private/utils/Get-CosmosDbAuthorizationHeadersFromContext.ps1 b/source/Private/utils/Get-CosmosDbAuthorizationHeaderFromContextResourceToken.ps1 similarity index 97% rename from source/Private/utils/Get-CosmosDbAuthorizationHeadersFromContext.ps1 rename to source/Private/utils/Get-CosmosDbAuthorizationHeaderFromContextResourceToken.ps1 index a14a7a82..52309536 100644 --- a/source/Private/utils/Get-CosmosDbAuthorizationHeadersFromContext.ps1 +++ b/source/Private/utils/Get-CosmosDbAuthorizationHeaderFromContextResourceToken.ps1 @@ -1,4 +1,4 @@ -function Get-CosmosDbAuthorizationHeadersFromContext +function Get-CosmosDbAuthorizationHeaderFromContextResourceToken { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] diff --git a/source/Private/utils/Invoke-CosmosDbRequest.ps1 b/source/Private/utils/Invoke-CosmosDbRequest.ps1 index 62aa7040..f2839fd8 100644 --- a/source/Private/utils/Invoke-CosmosDbRequest.ps1 +++ b/source/Private/utils/Invoke-CosmosDbRequest.ps1 @@ -54,7 +54,7 @@ function Invoke-CosmosDbRequest $ApiVersion = '2018-09-17', [Parameter()] - [Hashtable] + [System.Collections.Hashtable] $Headers = @{ }, [Parameter()] @@ -111,8 +111,14 @@ function Invoke-CosmosDbRequest default { # Request for an object that is within a database + if ([System.String]::IsNullOrEmpty($Database)) + { + New-CosmosDbInvalidOperationException -Message ($LocalizedData.ErrorMalformedContextDatabaseEmpty) + } + $resourceLink = ('dbs/{0}' -f $Database) + if ($PSBoundParameters.ContainsKey('ResourcePath')) { $resourceLink = ('{0}/{1}' -f $resourceLink, $ResourcePath) @@ -137,50 +143,59 @@ function Invoke-CosmosDbRequest } } + if ([System.String]::IsNullOrEmpty($Context.BaseUri)) + { + New-CosmosDbInvalidOperationException -Message ($LocalizedData.ErrorMalformedContextBaseUriEmpty) + } + # Generate the URI from the base connection URI and the resource link $baseUri = $Context.BaseUri.ToString() $uri = [uri]::New(('{0}{1}' -f $baseUri, $resourceLink)) - # Try to build the authorization headers from the Context - $authorizationHeaders = Get-CosmosDbAuthorizationHeadersFromContext ` + # Try to build the authorization headers from the Context Resource token if there are any + $authorizationHeaders = Get-CosmosDbAuthorizationHeaderFromContextResourceToken ` -Context $Context ` -ResourceLink $resourceLink if ($null -eq $authorizationHeaders) { <# - A token in the context that matched the resource link could not - be found. So use the master key to generate the authorization headers - from the token. + A token in the context that matched the resource link could not be found + So try to use the Entra Id token in the context. #> - if (-not ($PSBoundParameters.ContainsKey('Key'))) + if ([System.String]::IsNullOrEmpty($Context.EntraIdToken)) { - if (-not [System.String]::IsNullOrEmpty($Context.Key)) + <# + Neither a resource token in the context or an EntraIdToken was found + So try to use the Key in the context to generate the authorization headers. + #> + if ([System.String]::IsNullOrEmpty($Context.Key)) { - $Key = $Context.Key + New-CosmosDbInvalidOperationException -Message ($LocalizedData.ErrorAuthorizationKeyEmpty) } - } - if ([System.String]::IsNullOrEmpty($Key)) - { - New-CosmosDbInvalidOperationException -Message ($LocalizedData.ErrorAuthorizationKeyEmpty) - } - - # Generate the date used for the authorization token - $date = Get-Date - - $authorizationHeaders = @{ - 'authorization' = New-CosmosDbAuthorizationToken ` - -Key $Key ` - -KeyType $KeyType ` + $authorizationHeaders = Get-CosmosDbAuthorizationHeaderFromContext ` + -Key $Context.Key ` + -KeyType $Context.KeyType ` -Method $Method ` -ResourceType $ResourceType ` -ResourceId $resourceId ` - -Date $date - 'x-ms-date' = ConvertTo-CosmosDbTokenDateString -Date $date + -Date (Get-Date) + } + else + { + $authorizationHeaders = Get-CosmosDbAuthorizationHeaderFromContextEntraId ` + -Context $Context ` + -Date(Get-Date) } } + if ($null -eq $authorizationHeaders) + { + # This generally shouldn't occur, but we need to check just in case + New-CosmosDbInvalidOperationException -Message ($LocalizedData.ErrorAuthorizationHeadersEmpty) + } + $Headers += $authorizationHeaders $Headers.Add('x-ms-version', $ApiVersion) diff --git a/source/Public/utils/Get-CosmosDbEntraIdToken.ps1 b/source/Public/utils/Get-CosmosDbEntraIdToken.ps1 new file mode 100644 index 00000000..86e81685 --- /dev/null +++ b/source/Public/utils/Get-CosmosDbEntraIdToken.ps1 @@ -0,0 +1,21 @@ +function Get-CosmosDbEntraIdToken { + [System.Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidUsingConvertToSecureStringWithPlainText', '')] + [CmdletBinding()] + [OutputType([System.Security.SecureString])] + param + ( + [Parameter()] + [ValidateNotNullOrEmpty()] + [System.String] + $Endpoint = 'https://cosmos.azure.com' + ) + + # Remove any trailing slash as Cosmos DB RBAC does not expect the resource URL to have a trailing slash + $Endpoint = $Endpoint.TrimEnd('/') + + $token = (Get-AzAccessToken -ResourceUrl $Endpoint).Token + if ([System.String]::IsNullOrEmpty($token)) { + return $null + } + return (ConvertTo-SecureString -String $token -AsPlainText -Force) +} diff --git a/source/Public/utils/New-CosmosDbContext.ps1 b/source/Public/utils/New-CosmosDbContext.ps1 index cf1e25d0..c9b03c88 100644 --- a/source/Public/utils/New-CosmosDbContext.ps1 +++ b/source/Public/utils/New-CosmosDbContext.ps1 @@ -9,10 +9,12 @@ function New-CosmosDbContext param ( [Parameter(Mandatory = $true, ParameterSetName = 'Account')] - [Parameter(Mandatory = $true, ParameterSetName = 'Token')] [Parameter(Mandatory = $true, ParameterSetName = 'AzureAccount')] [Parameter(Mandatory = $true, ParameterSetName = 'CustomAccount')] [Parameter(Mandatory = $true, ParameterSetName = 'CustomAzureAccount')] + [Parameter(Mandatory = $true, ParameterSetName = 'EntraIdToken')] + [Parameter(Mandatory = $true, ParameterSetName = 'EntraIdTokenAutogen')] + [Parameter(Mandatory = $true, ParameterSetName = 'Token')] [ValidateScript({ Assert-CosmosDbAccountNameValid -Name $_ })] [System.String] $Account, @@ -72,15 +74,27 @@ function New-CosmosDbContext [CosmosDB.ContextToken[]] $Token, + [Parameter(Mandatory = $true, ParameterSetName = 'EntraIdToken')] + [ValidateNotNullOrEmpty()] + [System.Security.SecureString] + $EntraIdToken, + + [Parameter(Mandatory = $true, ParameterSetName = 'EntraIdTokenAutogen')] + [ValidateNotNullOrEmpty()] + [Switch] + $AutoGenerateEntraIdToken, + [Parameter()] [ValidateNotNullOrEmpty()] [CosmosDB.BackoffPolicy] $BackoffPolicy, [Parameter(ParameterSetName = 'Account')] - [Parameter(ParameterSetName = 'Token')] [Parameter(ParameterSetName = 'AzureAccount')] [Parameter(ParameterSetName = 'ConnectionString')] + [Parameter(ParameterSetName = 'EntraIdToken')] + [Parameter(ParameterSetName = 'EntraIdTokenAutogen')] + [Parameter(ParameterSetName = 'Token')] [CosmosDB.Environment] $Environment = [CosmosDB.Environment]::AzureCloud, @@ -92,44 +106,9 @@ function New-CosmosDbContext switch ($PSCmdlet.ParameterSetName) { - 'Emulator' + 'Account' { - $Account = 'emulator' - - if (-not ($PSBoundParameters.ContainsKey('Key'))) - { - # This is a publically known fixed master key (see https://docs.microsoft.com/en-us/azure/cosmos-db/local-emulator#authenticating-requests) - $Key = ConvertTo-SecureString ` - -String 'C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==' ` - -AsPlainText ` - -Force - } - - if (-not ($PSBoundParameters.ContainsKey('Uri'))) - { - $Uri = 'https://localhost:8081' - } - - if ($Uri -notmatch '^https?:\/\/') - { - $Uri = 'https://{0}' -f $Uri - } - - if ($Uri -notmatch ':\d*$') - { - if ($PSBoundParameters.ContainsKey('Port')) - { - Write-Warning -Message $LocalizedData.DeprecateContextPortWarning - } - else - { - $Port = 8081 - } - - $Uri = '{0}:{1}' -f $Uri, $Port - } - - $BaseUri = [System.Uri]::new($Uri) + $BaseUri = Get-CosmosDbUri -Account $Account -Environment $Environment } 'AzureAccount' @@ -170,9 +149,13 @@ function New-CosmosDbContext $BaseUri = Get-CosmosDbUri -Account $Account -BaseHostname $EndpointHostname } - 'Account' + 'ConnectionString' { - $BaseUri = Get-CosmosDbUri -Account $Account -Environment $Environment + $decryptedConnectionString = $ConnectionString | Convert-CosmosDbSecureStringToString + $connectionStringParts = $decryptedConnectionString -replace ';', [System.Environment]::NewLine | ConvertFrom-StringData + $BaseUri = [System.Uri]::new($connectionStringParts.AccountEndpoint) + $Account = $BaseUri.Host.Split('.')[0] + $Key = $connectionStringParts.AccountKey | ConvertTo-SecureString -AsPlainText -Force } 'CustomAccount' @@ -180,18 +163,60 @@ function New-CosmosDbContext $BaseUri = Get-CosmosDbUri -Account $Account -BaseHostname $EndpointHostname } - 'Token' + 'Emulator' + { + $Account = 'emulator' + + if (-not ($PSBoundParameters.ContainsKey('Key'))) + { + # This is a publically known fixed master key (see https://docs.microsoft.com/en-us/azure/cosmos-db/local-emulator#authenticating-requests) + $Key = ConvertTo-SecureString ` + -String 'C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==' ` + -AsPlainText ` + -Force + } + + if (-not ($PSBoundParameters.ContainsKey('Uri'))) + { + $Uri = 'https://localhost:8081' + } + + if ($Uri -notmatch '^https?:\/\/') + { + $Uri = 'https://{0}' -f $Uri + } + + if ($Uri -notmatch ':\d*$') + { + if ($PSBoundParameters.ContainsKey('Port')) + { + Write-Warning -Message $LocalizedData.DeprecateContextPortWarning + } + else + { + $Port = 8081 + } + + $Uri = '{0}:{1}' -f $Uri, $Port + } + + $BaseUri = [System.Uri]::new($Uri) + } + + 'EntraIdToken' { $BaseUri = Get-CosmosDbUri -Account $Account -Environment $Environment } - 'ConnectionString' + 'EntraIdTokenAutogen' { - $decryptedConnectionString = $ConnectionString | Convert-CosmosDbSecureStringToString - $connectionStringParts = $decryptedConnectionString -replace ';', [System.Environment]::NewLine | ConvertFrom-StringData - $BaseUri = [System.Uri]::new($connectionStringParts.AccountEndpoint) - $Account = $BaseUri.Host.Split('.')[0] - $Key = $connectionStringParts.AccountKey | ConvertTo-SecureString -AsPlainText -Force + $BaseUri = Get-CosmosDbUri -Account $Account -Environment $Environment + $EntraIdToken = Get-CosmosDbEntraIdToken -Endpoint $BaseUri + } + + 'Token' + { + $BaseUri = Get-CosmosDbUri -Account $Account -Environment $Environment } } @@ -204,6 +229,7 @@ function New-CosmosDbContext KeyType = $KeyType BaseUri = $BaseUri Token = $Token + EntraIdToken = $EntraIdToken BackoffPolicy = $BackoffPolicy Environment = $Environment } diff --git a/source/classes/CosmosDB/CosmosDB.cs b/source/classes/CosmosDB/CosmosDB.cs index 608035b1..05bbb699 100644 --- a/source/classes/CosmosDB/CosmosDB.cs +++ b/source/classes/CosmosDB/CosmosDB.cs @@ -7,7 +7,6 @@ public enum Environment { AzureUSGovernment } - public class ContextToken { public System.String Resource { get; set; } @@ -31,6 +30,7 @@ public class Context public System.String KeyType { get; set; } public System.String BaseUri { get; set; } public CosmosDB.ContextToken[] Token { get; set; } + public System.Security.SecureString EntraIdToken { get; set; } public CosmosDB.BackoffPolicy BackoffPolicy { get; set; } public CosmosDB.Environment Environment { get; set; } = Environment.AzureCloud; } diff --git a/source/en-US/CosmosDB.strings.psd1 b/source/en-US/CosmosDB.strings.psd1 index 92e96299..01609cb2 100644 --- a/source/en-US/CosmosDB.strings.psd1 +++ b/source/en-US/CosmosDB.strings.psd1 @@ -10,6 +10,7 @@ ConvertFrom-StringData -StringData @' NoMatchingUnexpiredResourceTokenInContext = At least one matching context token with resource '{0}' was found, but all are expired. NoResourceTokensInContext = Context does not contain any resource tokens. CreateAuthorizationToken = Creating authorization token: Method = '{0}', ResourceType = '{1}', ResourceId = '{2}', Date = '{3}'. + CreateAuthorizationTokenEntraId = Creating authorization token using Entra ID token. GettingAzureCosmosDBAccount = Getting Azure Cosmos DB account '{0}' in resource group '{1}'. GettingAzureCosmosDBAccountConnectionString = Getting '{2}' connection string for Azure Cosmos DB account '{0}' in resource group '{1}'. GettingAzureCosmosDBAccountMasterKey = Getting '{2}' for Azure Cosmos DB account '{0}' in resource group '{1}'. @@ -63,4 +64,7 @@ ConvertFrom-StringData -StringData @' ErrorConvertingDocumentJsonToObject = An error occured converting the document information returned from Cosmsos DB into an object. This might be caused by the document including keys with same name but differing in case. Include the -ReturnJson parameter to return these as JSON instead. ErrorTooManyRequests = The server returned a '429 Too Many Requests' error. This is likely due to the client making too many requests to the server. Please retry your request. ErrorTooManyRequestsWithNoRetryAfter = The server returned a '429 Too Many Requests' error, but the did not include an 'x-ms-retry-after-ms' header in the response. A retry delay of 0ms will be used. + ErrorAuthorizationHeadersEmpty = Authorization headers could not be created for the request. This is usually caused by the context not containing the necessary authorization information. + ErrorMalformedContextBaseUriEmpty = The context base URI is empty or malformed. Please ensure the context is correctly configured. + ErrorMalformedContextDatabaseEmpty = The context database is empty or malformed or not passed in to the function. Please ensure the context is correctly configured or a database is specified. '@ diff --git a/source/prefix.ps1 b/source/prefix.ps1 index f75824ec..633de692 100644 --- a/source/prefix.ps1 +++ b/source/prefix.ps1 @@ -2,16 +2,16 @@ .EXTERNALHELP CosmosDB-help.xml #> #Requires -Version 5.1 -#Requires -Modules @{ ModuleName = 'Az.Accounts'; ModuleVersion = '1.0.0'; Guid = '17a2feff-488b-47f9-8729-e2cec094624c' } -#Requires -Modules @{ ModuleName = 'Az.Resources'; ModuleVersion = '1.0.0'; Guid = '48bb344d-4c24-441e-8ea0-589947784700' } +#Requires -Modules @{ ModuleName = 'Az.Accounts'; ModuleVersion = '2.19.0'; Guid = '17a2feff-488b-47f9-8729-e2cec094624c' } +#Requires -Modules @{ ModuleName = 'Az.Resources'; ModuleVersion = '6.16.2'; Guid = '48bb344d-4c24-441e-8ea0-589947784700' } $script:moduleRoot = Split-Path ` -Path $MyInvocation.MyCommand.Path ` -Parent # Import dependent Az modules -Import-Module -Name Az.Accounts -MinimumVersion 1.0.0 -Scope Global -Import-Module -Name Az.Resources -MinimumVersion 1.0.0 -Scope Global +Import-Module -Name Az.Accounts -MinimumVersion 2.19.0 -Scope Global +Import-Module -Name Az.Resources -MinimumVersion 6.16.2 -Scope Global #region LocalizedData $culture = $PSUICulture diff --git a/tests/Integration/CosmosDB.integration.Tests.ps1 b/tests/Integration/CosmosDB.integration.Tests.ps1 index dcc06723..356ab8ec 100644 --- a/tests/Integration/CosmosDB.integration.Tests.ps1 +++ b/tests/Integration/CosmosDB.integration.Tests.ps1 @@ -19,10 +19,12 @@ if ([System.String]::IsNullOrEmpty($env:azureSubscriptionId) -or ` [System.String]::IsNullOrEmpty($env:azureApplicationId) -or ` [System.String]::IsNullOrEmpty($env:azureApplicationPassword) -or ` [System.String]::IsNullOrEmpty($env:azureTenantId) -or ` + [System.String]::IsNullOrEmpty($env:azureAppicationObjectId) -or ` $env:azureSubscriptionId -eq '$(azureSubscriptionId)' -or ` $env:azureApplicationId -eq '$(azureApplicationId)' -or ` $env:azureApplicationPassword -eq '$(azureApplicationPassword)' -or ` - $env:azureTenantId -eq '$(azureTenantId)' + $env:azureTenantId -eq '$(azureTenantId)' -or ` + $env:azureAppicationObjectId -eq '$(azureAppicationObjectId)' ) { Write-Warning -Message 'Integration tests can not be run because one or more Azure connection environment variables are not set.' @@ -136,6 +138,8 @@ function tax(income) { } '@ $script:testDefaultTimeToLive = 3600 +$script:cosmosDbRoleDefinitionIdReader = '00000000-0000-0000-0000-000000000001' +$script:cosmosDbRoleDefinitionIdContributor = '00000000-0000-0000-0000-000000000002' # Connect to Azure $secureStringAzureApplicationPassword = ConvertTo-SecureString ` @@ -150,6 +154,11 @@ Connect-AzureServicePrincipal ` -TenantId $env:azureTenantId ` -Verbose +# Get the Entra ID token for the logged in Service Principal to use +# for testing Cosmos DB secured with RBAC. +$script:entraIdTokenForSP = Get-AzureEntraIdToken -Verbose +$script:entraIdTokenForSPSecureString = ConvertTo-SecureString -String $script:entraIdTokenForSP -AsPlainText -Force + # Create resource group $null = New-AzureTestCosmosDbResourceGroup ` -ResourceGroupName $script:testResourceGroupName ` @@ -875,6 +884,107 @@ Describe 'Cosmos DB Module' -Tag 'Integration' { } } + Context 'When testing RBAC access using an Entra ID token' { + Context 'When assigning an RBAC contributor role to the account for the principal' { + It 'Should not throw an exception' { + New-AzCosmosDBSqlRoleAssignment ` + -AccountName $script:testAccountName ` + -ResourceGroupName $script:testResourceGroupName ` + -RoleDefinitionId $script:cosmosDbRoleDefinitionIdContributor ` + -Scope "/" ` + -PrincipalId $env:azureAppicationObjectId + } + } + + Context 'When retrieving the RBAC contributor role from the account for the principal' { + It 'Should not throw an exception' { + $script:Result = Get-AzCosmosDBSqlRoleAssignment ` + -AccountName $script:testAccountName ` + -ResourceGroupName $script:testResourceGroupName + + Write-Verbose -Message ($script:Result | Out-String) + } + + It 'Should return at least one SQL Role Assignement' { + $script:Result | Should -Not -BeNullOrEmpty + } + } + + # RBAC access testing using a Entra ID token generated via the test harness + Context 'When creating a new context from Azure using an Entra ID Token for the Service Principal' { + It 'Should not throw an exception' { + $script:testEntraIdContext = New-CosmosDbContext ` + -Account $script:testAccountName ` + -Database $script:testDatabase ` + -EntraIdToken $script:entraIdTokenForSPSecureString + } + } + + Context 'When adding a document to a collection using an Entra ID Token' { + It 'Should not throw an exception' { + $script:result = New-CosmosDbDocument ` + -Context $script:testEntraIdContext ` + -CollectionId $script:testCollection ` + -DocumentBody $script:testDocumentBody ` + -Verbose + } + + It 'Should return expected object' { + Test-GenericResult -GenericResult $script:result + $script:result.Id | Should -Be $script:testDocumentId + $script:result.Content | Should -Be 'Some string' + $script:result.More | Should -Be 'Some other string' + } + } + + Context 'When removing a document from a collection using an Entra ID Token' { + It 'Should not throw an exception' { + $script:result = Remove-CosmosDbDocument ` + -Context $script:testEntraIdContext ` + -CollectionId $script:testCollection ` + -Id $script:testDocumentId ` + -Verbose + } + } + + # RBAC access testing using a Entra ID token generated via the test harness + Context 'When creating a new context from Azure using an automatically generated Entra ID Token for the Service Principal' { + It 'Should not throw an exception' { + $script:testEntraIdContext = New-CosmosDbContext ` + -Account $script:testAccountName ` + -Database $script:testDatabase ` + -AutoGenerateEntraIdToken + } + } + + Context 'When adding a document to a collection using an Entra ID Token' { + It 'Should not throw an exception' { + $script:result = New-CosmosDbDocument ` + -Context $script:testEntraIdContext ` + -CollectionId $script:testCollection ` + -DocumentBody $script:testDocumentBody ` + -Verbose + } + + It 'Should return expected object' { + Test-GenericResult -GenericResult $script:result + $script:result.Id | Should -Be $script:testDocumentId + $script:result.Content | Should -Be 'Some string' + $script:result.More | Should -Be 'Some other string' + } + } + + Context 'When removing a document from a collection using an Entra ID Token' { + It 'Should not throw an exception' { + $script:result = Remove-CosmosDbDocument ` + -Context $script:testEntraIdContext ` + -CollectionId $script:testCollection ` + -Id $script:testDocumentId ` + -Verbose + } + } + } + Context 'When adding a document to a collection' { It 'Should not throw an exception' { $script:result = New-CosmosDbDocument ` diff --git a/tests/TestHelper/AzureDeploy/AzureDeploy.bicep b/tests/TestHelper/AzureDeploy/AzureDeploy.bicep new file mode 100644 index 00000000..04db513f --- /dev/null +++ b/tests/TestHelper/AzureDeploy/AzureDeploy.bicep @@ -0,0 +1,31 @@ +targetScope = 'subscription' + +param resourceGroupName string +param accountName string +param location string = 'East US' +param principalId string +@allowed([ + '00000000-0000-0000-0000-000000000001'// Built-in role 'Azure Cosmos DB Built-in Data Reader' + '00000000-0000-0000-0000-000000000002' // Built-in role 'Azure Cosmos DB Built-in Data Contributor' +]) +param roleDefinitionId string = '00000000-0000-0000-0000-000000000002' + +resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: resourceGroupName + location: location +} + + +module cosmosDb './CosmosDb.bicep' = { + name: '${resourceGroupName}-cosmosDb' + scope: rg + params: { + accountName: accountName + location: location + principalId: principalId + roleDefinitionId: roleDefinitionId + } +} + +output cosmosDbEndpoint string = cosmosDb.outputs.endpoint +output roleAssignmentId string = cosmosDb.outputs.roleAssignmentId diff --git a/tests/TestHelper/AzureDeploy/AzureDeploy.json b/tests/TestHelper/AzureDeploy/AzureDeploy.json deleted file mode 100644 index e72d24f9..00000000 --- a/tests/TestHelper/AzureDeploy/AzureDeploy.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "AccountName": { - "defaultValue": "", - "type": "string" - } - }, - "variables": {}, - "resources": [ - { - "type": "Microsoft.DocumentDB/databaseAccounts", - "kind": "GlobalDocumentDB", - "name": "[parameters('accountName')]", - "apiVersion": "2015-04-08", - "location": "East US", - "tags": { - "defaultExperience": "DocumentDB" - }, - "scale": null, - "properties": { - "databaseAccountOfferType": "Standard", - "consistencyPolicy": { - "defaultConsistencyLevel": "BoundedStaleness", - "maxIntervalInSeconds": 50, - "maxStalenessPrefix": 50 - }, - "name": "[parameters('accountName')]" - }, - "dependsOn": [] - } - ] -} diff --git a/tests/TestHelper/AzureDeploy/CosmosDb.bicep b/tests/TestHelper/AzureDeploy/CosmosDb.bicep new file mode 100644 index 00000000..989366bb --- /dev/null +++ b/tests/TestHelper/AzureDeploy/CosmosDb.bicep @@ -0,0 +1,46 @@ +param accountName string +param location string = 'East US' +param principalId string +@allowed([ + '00000000-0000-0000-0000-000000000001'// Built-in role 'Azure Cosmos DB Built-in Data Reader' + '00000000-0000-0000-0000-000000000002' // Built-in role 'Azure Cosmos DB Built-in Data Contributor' +]) +param roleDefinitionId string = '00000000-0000-0000-0000-000000000002' + +resource account 'Microsoft.DocumentDB/databaseAccounts@2021-10-15' = { + kind: 'GlobalDocumentDB' + name: accountName + location: location + tags: { + defaultExperience: 'DocumentDB' + } + properties: { + databaseAccountOfferType: 'Standard' + consistencyPolicy: { + defaultConsistencyLevel: 'BoundedStaleness' + maxIntervalInSeconds: 50 + maxStalenessPrefix: 50 + } + locations: [ + { + locationName: location + failoverPriority: 0 + } + ] + } +} + +var roleAssignmentId = guid(roleDefinitionId, principalId, account.id) + +resource sqlRoleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2024-02-15-preview' = { + name: roleAssignmentId + parent: account + properties: { + principalId: principalId + roleDefinitionId: '/${subscription().id}/resourceGroups/${resourceGroup().name}/providers/Microsoft.DocumentDB/databaseAccounts/${account.name}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002' + scope: account.id + } +} + +output endpoint string = account.properties.documentEndpoint +output roleAssignmentId string = roleAssignmentId diff --git a/tests/TestHelper/TestHelper.psm1 b/tests/TestHelper/TestHelper.psm1 index 8a3eb302..67ca0d59 100644 --- a/tests/TestHelper/TestHelper.psm1 +++ b/tests/TestHelper/TestHelper.psm1 @@ -101,9 +101,63 @@ function Connect-AzureServicePrincipal } } +<# + .SYNOPSIS + Get the Entra ID OAuth2 Token for the account authenticated to Azure. + + .DESCRIPTION + This is used to test Entra ID authentication when RBAC is enabled as per + https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac + + .PARAMETER ResourceUrl + The resource URL for which the token is requested. Defaults to 'https://cosmos.azure.com'. + + .OUTPUTS + System.String +#> +function Get-AzureEntraIdToken +{ + [CmdletBinding()] + param ( + [Parameter()] + [System.String] + $ResourceUrl = 'https://cosmos.azure.com' + ) + + # Get the access token for the specific audience + $entraIdOAuthToken = Get-AzAccessToken -ResourceUrl $ResourceUrl + + return $entraIdOAuthToken.Token +} + <# .SYNOPSIS Create a new Azure Cosmos DB Account for use with testing. + + .DESCRIPTION + The New-AzureTestCosmosDbAccount function deploys a new Azure Cosmos DB account using an ARM template. + It is primarily intended for use in testing scenarios. + + .PARAMETER ObjectId + The Object ID of the Azure AD principal identity that will be assigned the SQL role assigment. + + .PARAMETER AccountName + The name of the Azure Cosmos DB account to create. + + .PARAMETER ResourceGroupName + The name of the resource group where the Azure Cosmos DB account will be created. + + .PARAMETER Location + The Azure region where the Azure Cosmos DB account will be created. Defaults to 'East US'. + + .EXAMPLE + New-AzureTestCosmosDbAccount -ObjectId '12345678-1234-1234-1234-123456789012' -AccountName 'testCosmosDb' -ResourceGroupName 'testResourceGroup' + + This will create a new Azure Cosmos DB account named 'testCosmosDb' in the 'testResourceGroup' resource group. + + .NOTES + The function uses the New-AzDeployment cmdlet to deploy the ARM template. + It requires the Az.Resources module and an authenticated Azure session. #> function New-AzureTestCosmosDbAccount { @@ -114,11 +168,19 @@ function New-AzureTestCosmosDbAccount ( [Parameter(Mandatory = $true)] [System.String] - $Name, + $ObjectId, [Parameter(Mandatory = $true)] [System.String] - $ResourceGroupName + $AccountName, + + [Parameter(Mandatory = $true)] + [System.String] + $ResourceGroupName, + + [Parameter()] + [System.String] + $Location = 'East US' ) try @@ -127,20 +189,23 @@ function New-AzureTestCosmosDbAccount # Build hashtable of deployment parameters $azureDeployFolder = Join-Path -Path $PSScriptRoot -ChildPath 'AzureDeploy' - $deployName = ('Deploy_{0}' -f $AccountName) + $deployName = ('Deploy_{0}_{1}' -f $AccountName, (Get-Date -Format FileDateTimeUniversal)) $deploymentParameters = @{ Name = $deployName - ResourceGroupName = $ResourceGroupName - TemplateFile = Join-Path -Path $azureDeployFolder -ChildPath 'AzureDeploy.json' + Location = $Location + TemplateFile = Join-Path -Path $azureDeployFolder -ChildPath 'AzureDeploy.Bicep' TemplateParameterObject = @{ - AccountName = $Name + resourceGroupName = $ResourceGroupName + accountName = $AccountName + principalId = $ObjectId + location = $Location } } if ($PSCmdlet.ShouldProcess('Azure', ("Create an Azure Cosmos DB test account '{0}' in resource group '{1}'" -f $Name, $ResourceGroupName))) { # Deploy ARM template - New-AzResourceGroupDeployment ` + New-AzDeployment ` @deploymentParameters } } @@ -349,6 +414,7 @@ Export-ModuleMember -Function ` Get-AzureServicePrincipal, ` Connect-AzureServicePrincipal, ` New-AzureTestCosmosDbAccount, ` + Get-AzureEntraIdToken, ` Remove-AzureTestCosmosDbAccount, ` New-AzureTestCosmosDbResourceGroup, ` Remove-AzureTestCosmosDbResourceGroup, diff --git a/tests/Unit/CosmosDB.utils.Tests.ps1 b/tests/Unit/CosmosDB.utils.Tests.ps1 index b11cc31d..571d4eb7 100644 --- a/tests/Unit/CosmosDB.utils.Tests.ps1 +++ b/tests/Unit/CosmosDB.utils.Tests.ps1 @@ -40,15 +40,23 @@ InModuleScope $ProjectName { KeyType = 'master' BaseUri = ('https://{0}.documents.azure.com/' -f $script:testAccount) } - $script:testToken = 'type-resource&ver=1.0&sig=5mDuQBYA0kb70WDJoTUzSBMTG3owkC0/cEN4fqa18/s=' - $script:testTokenSecureString = ConvertTo-SecureString -String $script:testToken -AsPlainText -Force - $script:testTokenResource = ('dbs/{0}/colls/{1}' -f $script:testDatabase, $script:testCollection) - $script:testTokenExpiry = 7200 + $script:testAuthorizationHeaderResourceToken = 'type=resource&ver=1.0&sig=5mDuQBYA0kb70WDJoTUzSBMTG3owkC0/cEN4fqa18/s=' + $script:testAuthorizationHeaderResourceTokenSecureString = ConvertTo-SecureString -String $script:testAuthorizationHeaderResourceToken -AsPlainText -Force + $script:testAuthorizationContextResource = ('dbs/{0}/colls/{1}' -f $script:testDatabase, $script:testCollection) + $script:testAuthorizationContextResourceExpiry = 7200 + $script:testTokenExpirationDate = (Get-Date).AddSeconds($script:testAuthorizationContextResourceExpiry) + $script:testTokenExpirationUniversalDate = $script:testTokenExpirationDate.ToUniversalTime().ToString("r", [System.Globalization.CultureInfo]::InvariantCulture) $script:testContextToken = [CosmosDB.ContextToken] @{ - Resource = $script:testTokenResource + Resource = $script:testAuthorizationContextResource TimeStamp = $script:testDate - Expires = $script:testDate.AddSeconds($script:testTokenExpiry) - Token = $script:testTokenSecureString + Expires = $script:testTokenExpirationDate + Token = $script:testAuthorizationHeaderResourceTokenSecureString + } + $script:testContextTokenExpired = [CosmosDB.ContextToken] @{ + Resource = $script:testAuthorizationContextResource + TimeStamp = $script:testDate + Expires = Get-Date + Token = $script:testAuthorizationHeaderResourceTokenSecureString } $script:testResourceContext = [CosmosDb.Context] @{ Account = $script:testAccount @@ -56,6 +64,22 @@ InModuleScope $ProjectName { BaseUri = ('https://{0}.documents.azure.com/' -f $script:testAccount) Token = $script:testContextToken } + $script:testResourceContextExpired = [CosmosDb.Context] @{ + Account = $script:testAccount + Database = $script:testDatabase + BaseUri = ('https://{0}.documents.azure.com/' -f $script:testAccount) + Token = $script:testContextTokenExpired + } + # Not a real token, just a test token + $script:testEntraIdToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ikwx...t7XeB7XeDn1xsdXS3FbgGDPsYeB-0utkCJndu3ixHuDK_gTKWoQ' + $script:testEntraIdTokenSecureString = ConvertTo-SecureString -String $script:testEntraIdToken -AsPlainText -Force + $script:testAuthorizationHeaderEntraIdToken = "type=aad&ver=1.0&sig=$testEntraIdToken" + $script:testEntraIdContext = [CosmosDB.Context] @{ + Account = $script:testAccount + EntraIdToken = $script:testEntraIdTokenSecureString + BaseUri = ('https://{0}.documents.azure.com/' -f $script:testAccount) + } + $script:testJson = @' { "_rid": "2MFbAA==", @@ -192,10 +216,10 @@ console.log("done"); It 'Should not throw exception' { $newCosmosDbContextTokenParameters = @{ - Resource = $script:testTokenResource + Resource = $script:testAuthorizationContextResource TimeStamp = $script:testDate - TokenExpiry = $script:testTokenExpiry - Token = $script:testTokenSecureString + TokenExpiry = $script:testAuthorizationContextResourceExpiry + Token = $script:testAuthorizationHeaderResourceTokenSecureString Verbose = $true } @@ -203,10 +227,10 @@ console.log("done"); } It 'Should return expected result' { - $script:result.Resource | Should -Be $script:testTokenResource + $script:result.Resource | Should -Be $script:testAuthorizationContextResource $script:result.TimeStamp | Should -Be $script:testDate - $script:result.Expires | Should -Be $script:testDate.AddSeconds($script:testTokenExpiry) - $script:result.Token | Should -Be $script:testTokenSecureString + $script:result.Expires | Should -Be $script:testDate.AddSeconds($script:testAuthorizationContextResourceExpiry) + $script:result.Token | Should -Be $script:testAuthorizationHeaderResourceTokenSecureString } } } @@ -795,16 +819,81 @@ console.log("done"); $script:result.Account | Should -Be $script:testAccount $script:result.Database | Should -Be $script:testDatabase $script:result.BaseUri | Should -Be ('https://{0}.documents.azure.com/' -f $script:testAccount) - $script:result.Token[0].Resource | Should -Be $script:testTokenResource + $script:result.Token[0].Resource | Should -Be $script:testAuthorizationContextResource $script:result.Token[0].TimeStamp | Should -Be $script:testDate - $script:result.Token[0].Token | Convert-CosmosDbSecureStringToString | Should -Be $script:testToken + $script:result.Token[0].Token | Convert-CosmosDbSecureStringToString | Should -Be $script:testAuthorizationHeaderResourceToken + $script:result.Environment | Should -BeExactly 'AzureCloud' + } + } + + Context 'When called with EntraIdToken parameters' { + $script:result = $null + + It 'Should not throw exception' { + $newCosmosDbContextParameters = @{ + Account = $script:testAccount + Database = $script:testDatabase + EntraIdToken = $script:testEntraIdTokenSecureString + Verbose = $true + } + + { $script:result = New-CosmosDbContext @newCosmosDbContextParameters } | Should -Not -Throw + } + + It 'Should return expected result' { + $script:result.Account | Should -Be $script:testAccount + $script:result.Database | Should -Be $script:testDatabase + $script:result.BaseUri | Should -Be ('https://{0}.documents.azure.com/' -f $script:testAccount) + $script:result.Token | Should -BeNullOrEmpty + $script:result.EntraIdToken | Convert-CosmosDbSecureStringToString | Should -Be $script:testEntraIdToken $script:result.Environment | Should -BeExactly 'AzureCloud' } } + Context 'When called with EntraIdTokenAutoGen parameters' { + $script:result = $null + + Mock Get-AzAccessToken -MockWith { + return @{ + Token = $script:testEntraIdToken + } + } + + It 'Should not throw exception' { + $newCosmosDbContextParameters = @{ + Account = $script:testAccount + Database = $script:testDatabase + AutoGenerateEntraIdToken = $true + Verbose = $true + } + + { $script:result = New-CosmosDbContext @newCosmosDbContextParameters } | Should -Not -Throw + } + + It 'Should return expected result' { + $script:result.Account | Should -Be $script:testAccount + $script:result.Database | Should -Be $script:testDatabase + $script:result.BaseUri | Should -Be ('https://{0}.documents.azure.com/' -f $script:testAccount) + $script:result.Token | Should -BeNullOrEmpty + $script:result.EntraIdToken | Convert-CosmosDbSecureStringToString | Should -Be $script:testEntraIdToken + $script:result.Environment | Should -BeExactly 'AzureCloud' + } + + It 'Should call expected mocks' { + Assert-MockCalled -CommandName Get-AzAccessToken -Exactly -Times 1 ` + -ParameterFilter { $ResourceUrl -eq ('https://{0}.documents.azure.com' -f $script:testAccount) } + } + } + Context 'When called with Connection String parameter' { $script:result = $null + Mock Get-AzAccessToken -MockWith { + return @{ + Token = $script:testEntraIdToken + } + } + It 'Should not throw exception' { $newCosmosDbContextParameters = @{ ConnectionString = ($script:testConnectionString | ConvertTo-SecureString -AsPlainText -Force) @@ -931,16 +1020,16 @@ console.log("done"); } } - Describe 'New-CosmosDbAuthorizationToken' -Tag 'Unit' { + Describe 'Get-CosmosDbAuthorizationHeaderFromContext' -Tag 'Unit' { It 'Should exist' { - { Get-Command -Name New-CosmosDbAuthorizationToken } | Should -Not -Throw + { Get-Command -Name Get-CosmosDbAuthorizationHeaderFromContext } | Should -Not -Throw } Context 'When called with all parameters' { $script:result = $null It 'Should not throw exception' { - $newCosmosDbAuthorizationTokenParameters = @{ + $getCosmosDbAuthorizationHeaderFromContextParameters = @{ Key = $script:testKeySecureString KeyType = 'master' Method = 'Get' @@ -950,11 +1039,12 @@ console.log("done"); Verbose = $true } - { $script:result = New-CosmosDbAuthorizationToken @newCosmosDbAuthorizationTokenParameters } | Should -Not -Throw + { $script:result = Get-CosmosDbAuthorizationHeaderFromContext @getCosmosDbAuthorizationHeaderFromContextParameters } | Should -Not -Throw } It 'Should return expected result when' { - $script:result | Should -Be 'type%3dmaster%26ver%3d1.0%26sig%3dr3RhzxX7rv4ZHqo4aT1jDszfV7svQ7JFXoi7hz1Iwto%3d' + $script:result.authorization | Should -Be 'type%3dmaster%26ver%3d1.0%26sig%3dr3RhzxX7rv4ZHqo4aT1jDszfV7svQ7JFXoi7hz1Iwto%3d' + $script:result.'x-ms-date' | Should -Be $script:testUniversalDate } } @@ -962,7 +1052,7 @@ console.log("done"); $script:result = $null It 'Should not throw exception' { - $newCosmosDbAuthorizationTokenParameters = @{ + $getCosmosDbAuthorizationHeaderFromContextParameters = @{ Key = $script:testKeySecureString KeyType = 'master' Method = 'Get' @@ -972,11 +1062,117 @@ console.log("done"); Verbose = $true } - { $script:result = New-CosmosDbAuthorizationToken @newCosmosDbAuthorizationTokenParameters } | Should -Not -Throw + { $script:result = Get-CosmosDbAuthorizationHeaderFromContext @getCosmosDbAuthorizationHeaderFromContextParameters } | Should -Not -Throw + } + + It 'Should return expected result when' { + $script:result.authorization | Should -Be 'type%3dmaster%26ver%3d1.0%26sig%3dncZem2Awq%2b0LkrQ7mlwJePX%2f2qyEPG3bQDrnuedrjZU%3d' + $script:result.'x-ms-date' | Should -Be $script:testUniversalDate + } + } + } + + Describe 'Get-CosmosDbAuthorizationHeaderFromContextResourceToken' -Tag 'Unit' { + It 'Should exist' { + { Get-Command -Name Get-CosmosDbAuthorizationHeaderFromContextResourceToken } | Should -Not -Throw + } + + Context 'When called with a context without any resource tokens' { + $script:result = $null + + It 'Should not throw exception' { + $getCosmosDbAuthorizationHeaderFromContextResourceTokenParameters = @{ + Context = $script:testEntraIdContext + ResourceLink = $script:testAuthorizationContextResource + Verbose = $true + } + + { $script:result = Get-CosmosDbAuthorizationHeaderFromContextResourceToken @getCosmosDbAuthorizationHeaderFromContextResourceTokenParameters } | Should -Not -Throw + } + + It 'Should return null' { + $script:result | Should -BeNullOrEmpty + } + } + + Context 'When called with a context that contains a resource token that matches the resource link and is not expired' { + $script:result = $null + + It 'Should not throw exception' { + $getCosmosDbAuthorizationHeaderFromContextResourceTokenParameters = @{ + Context = $script:testResourceContext + ResourceLink = $script:testAuthorizationContextResource + Verbose = $true + } + + { $script:result = Get-CosmosDbAuthorizationHeaderFromContextResourceToken @getCosmosDbAuthorizationHeaderFromContextResourceTokenParameters } | Should -Not -Throw + } + + It 'Should return expected result when' { + $script:result.authorization | Should -Be ([System.Web.HttpUtility]::UrlEncode($script:testAuthorizationHeaderResourceToken)) + # The date is not the same as the test date because the date format ends up being different on Azure DevOps pipelines vs. Local + # $script:result.'x-ms-date' | Should -Be $script:testUniversalDate + } + } + + Context 'When called with a context that contains a resource token that matches the resource link but has expired' { + $script:result = $null + + It 'Should not throw exception' { + $getCosmosDbAuthorizationHeaderFromContextResourceTokenParameters = @{ + Context = $script:testResourceContextExpired + ResourceLink = $script:testAuthorizationContextResource + Verbose = $true + } + + { $script:result = Get-CosmosDbAuthorizationHeaderFromContextResourceToken @getCosmosDbAuthorizationHeaderFromContextResourceTokenParameters } | Should -Not -Throw + } + + It 'Should return expected result when' { + $script:result | Should -BeNullOrEmpty + } + } + } + + Describe 'Get-CosmosDbAuthorizationHeaderFromContextEntraId' -Tag 'Unit' { + It 'Should exist' { + { Get-Command -Name Get-CosmosDbAuthorizationHeaderFromContextEntraId } | Should -Not -Throw + } + + Context 'When called with a context containing an EntraIdToken' { + $script:result = $null + + It 'Should not throw exception' { + $getCosmosDbAuthorizationHeaderFromContextEntraIdParameters = @{ + Context = $script:testEntraIdContext + Date = $script:testUniversalDate + Verbose = $true + } + + { $script:result = Get-CosmosDbAuthorizationHeaderFromContextEntraId @getCosmosDbAuthorizationHeaderFromContextEntraIdParameters } | Should -Not -Throw } It 'Should return expected result when' { - $script:result | Should -Be 'type%3dmaster%26ver%3d1.0%26sig%3dncZem2Awq%2b0LkrQ7mlwJePX%2f2qyEPG3bQDrnuedrjZU%3d' + $script:result.authorization | Should -Be ([System.Web.HttpUtility]::UrlEncode($script:testAuthorizationHeaderEntraIdToken)) + $script:result.'x-ms-date' | Should -Be $script:testUniversalDate + } + } + + Context 'When called with a context that does not contain an EntraIdToken' { + $script:result = $null + + It 'Should not throw exception' { + $getCosmosDbAuthorizationHeaderFromContextEntraIdParameters = @{ + Context = $script:testResourceContext + Date = $script:testUniversalDate + Verbose = $true + } + + { $script:result = Get-CosmosDbAuthorizationHeaderFromContextEntraId @getCosmosDbAuthorizationHeaderFromContextEntraIdParameters } | Should -Not -Throw + } + + It 'Should return expected result when' { + $script:result | Should -BeNullOrEmpty } } } @@ -1068,6 +1264,43 @@ console.log("done"); } } + Context 'When called with context parameter with Entra ID Token and Get method and ResourceType is ''dbs''' { + $InvokeWebRequest_parameterfilter = { + $Method -eq 'Get' -and ` + $ContentType -eq 'application/json' -and ` + $Uri -eq ('{0}{1}' -f $script:testContext.BaseUri, 'dbs') + } + + Mock ` + -CommandName Invoke-WebRequest ` + -MockWith $InvokeWebRequest_mockwith + + $script:result = $null + + It 'Should not throw exception' { + $invokeCosmosDbRequestparameters = @{ + Context = $script:testEntraIdContext + Method = 'Get' + ResourceType = 'dbs' + Verbose = $true + } + + { $script:result = (Invoke-CosmosDbRequest @invokeCosmosDbRequestparameters).Content | ConvertFrom-Json } | Should -Not -Throw + } + + It 'Should return expected result' { + $script:result._count | Should -Be 1 + } + + It 'Should call expected mocks' { + Assert-MockCalled ` + -CommandName Invoke-WebRequest ` + -ParameterFilter $InvokeWebRequest_parameterfilter ` + -Exactly -Times 1 + Assert-MockCalled -CommandName Get-Date -Exactly -Times 1 + } + } + Context 'When called with context parameter and Get method and ResourceType is ''offers''' { $InvokeWebRequest_parameterfilter = { $Method -eq 'Get' -and ` @@ -1725,4 +1958,106 @@ console.log("done"); } } } + + Describe 'Get-CosmosDbEntraIdToken' -Tag 'Unit' { + It 'Should exist' { + { Get-Command -Name Get-CosmosDbEntraIdToken -ErrorAction Stop } | Should -Not -Throw + } + + Context 'When called without endpoint specified' { + $script:result = $null + + Mock Get-AzAccessToken -MockWith { + return @{ + Token = $script:testEntraIdToken + } + } + + It 'Should not throw exception' { + { $script:result = Get-CosmosDbEntraIdToken } | Should -Not -Throw + } + + It 'Should return secure string containing token' { + $script:result | Convert-CosmosDbSecureStringToString | Should -Be $script:testEntraIdToken + } + + It 'Should call expected mocks' { + Assert-MockCalled -CommandName Get-AzAccessToken -Exactly -Times 1 ` + -ParameterFilter { $ResourceUrl -eq 'https://cosmos.azure.com' } + } + } + + Context 'When called with endpoint specified' { + $script:result = $null + + Mock Get-AzAccessToken -MockWith { + return @{ + Token = $script:testEntraIdToken + } + } + + It 'Should not throw exception' { + $getCosmosDbEntraIdTokenParameters = @{ + Endpoint = 'https://cdbtest1pzqjjk3jfe.documents.azure.com' + } + { $script:result = Get-CosmosDbEntraIdToken @getCosmosDbEntraIdTokenParameters } | Should -Not -Throw + } + + It 'Should return secure string containing token' { + $script:result | Convert-CosmosDbSecureStringToString | Should -Be $script:testEntraIdToken + } + + It 'Should call expected mocks' { + Assert-MockCalled -CommandName Get-AzAccessToken -Exactly -Times 1 ` + -ParameterFilter { $ResourceUrl -eq 'https://cdbtest1pzqjjk3jfe.documents.azure.com' } + } + } + + Context 'When called with endpoint specified ending with a /' { + $script:result = $null + + Mock Get-AzAccessToken -MockWith { + return @{ + Token = $script:testEntraIdToken + } + } + + It 'Should not throw exception' { + $getCosmosDbEntraIdTokenParameters = @{ + Endpoint = 'https://cdbtest1pzqjjk3jfe.documents.azure.com/' + } + { $script:result = Get-CosmosDbEntraIdToken @getCosmosDbEntraIdTokenParameters } | Should -Not -Throw + } + + It 'Should return secure string containing token' { + $script:result | Convert-CosmosDbSecureStringToString | Should -Be $script:testEntraIdToken + } + + It 'Should call expected mocks' { + Assert-MockCalled -CommandName Get-AzAccessToken -Exactly -Times 1 ` + -ParameterFilter { $ResourceUrl -eq 'https://cdbtest1pzqjjk3jfe.documents.azure.com' } + } + } + + Context 'When called, but an Azure Context is not connected' { + $script:result = $null + + Mock Get-AzAccessToken -MockWith { + return $null + } + + It 'Should throw exception' { + { $script:result = Get-CosmosDbEntraIdToken } | Should -Not -Throw + } + + It 'Should return null' { + $script:result | Should -BeNullOrEmpty + } + + It 'Should call expected mocks' { + Assert-MockCalled -CommandName Get-AzAccessToken -Exactly -Times 1 ` + -ParameterFilter { $ResourceUrl -eq 'https://cosmos.azure.com' } + } + } + } }