AzureArtifactsPowerShellModuleHelper.psm1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
<#
.SYNOPSIS
    Registers a PSRepository to the given Azure Artifacts feed if one does not already exist.
.DESCRIPTION
    Registers a PSRepository to the given Azure Artifacts feed if one does not already exist.
.EXAMPLE
    ```
    PS C:\> [string] $repositoryName = Register-AzureArtifactsPSRepository -FeedUrl https://pkgs.dev.azure.com/YourOrganization/_packaging/YourFeed/nuget/v2 -RepositoryName 'MyAzureArtifacts'
    ```
 
    Attempts to create a PSRepository to the given FeedUrl if one doesn't exist.
    If one does not exist, one will be created with the name `MyAzureArtifacts`.
    Since no Credential was provided, it will attempt to retrieve a PAT from the environmental variables.
    The name of the PSRepository to the FeedUrl is returned.
 
    ```
    [System.Security.SecureString] $securePersonalAccessToken = 'YourPatGoesHere' | ConvertTo-SecureString -AsPlainText -Force
    [System.Management.Automation.PSCredential] $Credential = New-Object System.Management.Automation.PSCredential 'Username@DoesNotMatter.com', $securePersonalAccessToken
    [string] $feedUrl = 'https://pkgs.dev.azure.com/YourOrganization/_packaging/YourFeed/nuget/v2'
    [string] $repositoryName = Register-AzureArtifactsPSRepository -Credential $credential -FeedUrl $feedUrl
    ```
 
    Attempts to create a PSRepository to the given FeedUrl if one doesn't exist, using the Credential provided.
.INPUTS
    FeedUrl: (Required) The URL of the Azure Artifacts PowerShell feed to register. e.g. https://pkgs.dev.azure.com/YourOrganization/_packaging/YourFeed/nuget/v2. Note: PowerShell does not yet support the "/v3" endpoint, so use v2.
 
    RepositoryName: The name to use for the PSRepository if one must be created. If not provided, one will be generated. A PSRepository with the given name will only be created if one to the Feed URL does not already exist.
 
    Credential: The credential to use to connect to the Azure Artifacts feed. This should be created from a personal access token that has at least Read permissions to the Azure Artifacts feed. If not provided, the VSS_NUGET_EXTERNAL_FEED_ENDPOINTS environment variable will be checked, as per https://github.com/Microsoft/artifacts-credprovider#environment-variables.
.OUTPUTS
    System.String
    Returns the Name of the PSRepository that can be used to connect to the given Feed URL.
.NOTES
    If a Credential is not provided, it will attempt to retrieve a PAT from the environment variables, as per https://github.com/Microsoft/artifacts-credprovider#environment-variables
 
    This function writes to the error, warning, and information streams in different scenarios, as well as may throw exceptions for catastrophic errors.
#>

function Register-AzureArtifactsPSRepository
{
    param
    (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, HelpMessage = 'The URL of the Azure Artifacts PowerShell feed to register. e.g. https://pkgs.dev.azure.com/YourOrganization/_packaging/YourFeed/nuget/v2. Note: PowerShell does not yet support the "/v3" endpoint, so use v2.')]
        [ValidateNotNullOrEmpty()]
        [string] $FeedUrl,

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true, HelpMessage = 'The name to use for the PSRepository if one must be created. If not provided, one will be generated. A PSRepository with the given name will only be created if one to the Feed URL does not already exist.')]
        [string] $RepositoryName,

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true, HelpMessage = 'The credential to use to connect to the Azure Artifacts feed. This should be created from a personal access token that has at least Read permissions to the Azure Artifacts feed. If not provided, the VSS_NUGET_EXTERNAL_FEED_ENDPOINTS environment variable will be checked, as per https://github.com/Microsoft/artifacts-credprovider#environment-variables')]
        [System.Management.Automation.PSCredential] $Credential = $null
    )

    Process
    {
        if ([string]::IsNullOrWhitespace($RepositoryName))
        {
            [string] $organizationAndFeed = Get-AzureArtifactOrganizationAndFeedFromUrl -feedUrl $FeedUrl
            $RepositoryName = ('AzureArtifacts-' + $organizationAndFeed).TrimEnd('-')
        }

        $Credential = Get-AzureArtifactsCredential -credential $Credential

        Install-NuGetPackageProvider

        [string] $repositoryNameOfFeed = Register-AzureArtifactsPowerShellRepository -feedUrl $FeedUrl -repositoryName $RepositoryName -credential $Credential

        return $repositoryNameOfFeed
    }

    Begin
    {
        function Register-AzureArtifactsPowerShellRepository([string] $feedUrl, [string] $repositoryName, [System.Management.Automation.PSCredential] $credential)
        {
            $psRepositories = Get-PSRepository

            [PSCustomObject] $existingPsRepositoryForFeed = $psRepositories | Where-Object { $_.SourceLocation -ieq $feedUrl }
            [bool] $psRepositoryIsAlreadyRegistered = ($null -ne $existingPsRepositoryForFeed)
            if ($psRepositoryIsAlreadyRegistered)
            {
                return $existingPsRepositoryForFeed.Name
            }

            [PSCustomObject] $existingPsRepositoryWithSameName = $psRepositories | Where-Object { $_.Name -ieq $repositoryName }
            [bool] $psRepositoryWithDesiredNameAlreadyExists = ($null -ne $existingPsRepositoryWithSameName)
            if ($psRepositoryWithDesiredNameAlreadyExists)
            {
                $repositoryName += '-' + (Get-RandomCharacters -length 3)
            }

            if ($null -eq $credential)
            {
                [string] $computerName = $Env:ComputerName
                Write-Warning "Credentials were not provided, so we will attempt to register a new PSRepository to connect to '$feedUrl' on '$computerName' without credentials."
                Register-PSRepository -Name $repositoryName -SourceLocation $feedUrl -InstallationPolicy Trusted > $null
            }
            else
            {
                Register-PSRepository -Name $repositoryName -SourceLocation $feedUrl -InstallationPolicy Trusted -Credential $credential > $null
            }

            return $repositoryName
        }

        function Get-RandomCharacters([int] $length = 8)
        {
            [string] $word = (-join ((65..90) + (97..122) | Get-Random -Count $length | ForEach-Object { [char]$_ }))
            return $word
        }

        function Get-AzureArtifactOrganizationAndFeedFromUrl([string] $feedUrl)
        {
            # Azure Artifact feed URLs are of the format: 'https://pkgs.dev.azure.com/Organization/_packaging/Feed/nuget/v2'
            [bool] $urlMatchesRegex = $feedUrl -match 'https\:\/\/pkgs.dev.azure.com\/(?<Organization>.+?)\/_packaging\/(?<Feed>.+?)\/'
            if ($urlMatchesRegex)
            {
                return $Matches.Organization + '-' + $Matches.Feed
            }
            return [string]::Empty
        }

        function Install-NuGetPackageProvider
        {
            [bool] $nuGetPackageProviderIsNotInstalled = ($null -eq (Get-PackageProvider | Where-Object { $_.Name -ieq 'NuGet' }))
            if ($nuGetPackageProviderIsNotInstalled)
            {
                [string] $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
                Write-Information 'Installing NuGet package provider for user '$currentUser'.'
                Install-PackageProvider NuGet -Scope CurrentUser -Force > $null
            }
        }
    }
}

<#
.SYNOPSIS
    Install (if necessary) and import a module from the specified repository.
.DESCRIPTION
    Install (if necessary) and import a module from the specified repository.
.EXAMPLE
    PS C:\> <example usage>
    Explanation of what the example does
.INPUTS
    Name: (Required) The name of the PowerShell module to install (if necessary) and import.
 
    Version: The specific version of the PowerShell module to install (if necessary) and import. If not provided, the latest version will be used.
 
    AllowPrerelease: If provided, prerelease versions are allowed to be installed and imported. This must be provided if specifying a Prerelease version in the Version parameter.
 
    RepositoryName: (Required) The name to use for the PSRepository that contains the module to import. This should be obtained from the Register-AzureArtifactsPSRepository cmdlet.
 
    Credential: The credential to use to connect to the Azure Artifacts feed.
 
    Force: If provided, the specified PowerShell module will always be downloaded and installed, even if the version is already installed.
.OUTPUTS
    No outputs are returned.
.NOTES
    If a Credential is not provided, it will attempt to retrieve a PAT from the environment variables, as per https://github.com/Microsoft/artifacts-credprovider#environment-variables
 
    This function writes to the error, warning, and information streams in different scenarios, as well as may throw exceptions for catastrophic errors.
#>

function Import-AzureArtifactsModule
{
    [CmdletBinding(DefaultParameterSetName = 'PAT')]
    param
    (
        [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'The name of the PowerShell module to install (if necessary) and import.')]
        [ValidateNotNullOrEmpty()]
        [string] $Name,

        [Parameter(Mandatory = $false, HelpMessage = 'The specific version of the PowerShell module to install (if necessary) and import. If not provided, the latest version will be used.')]
        [string] $Version = $null,

        [Parameter(Mandatory = $false, HelpMessage = 'If provided, prerelease versions are allowed to be installed and imported. This must be provided if specifying a Prerelease version in the Version parameter.')]
        [switch] $AllowPrerelease = $false,

        [Parameter(Mandatory = $true, HelpMessage = 'The name to use for the PSRepository that contains the module to import. This should be obtained from the Register-AzureArtifactsPSRepository cmdlet.')]
        [string] $RepositoryName,

        [Parameter(Mandatory = $false, HelpMessage = 'The credential to use to connect to the Azure Artifacts feed. This should be created from a personal access token that has at least Read permissions to the Azure Artifacts feed. If not provided, the VSS_NUGET_EXTERNAL_FEED_ENDPOINTS environment variable will be checked, as per https://github.com/Microsoft/artifacts-credprovider#environment-variables')]
        [System.Management.Automation.PSCredential] $Credential = $null,

        [Parameter(Mandatory = $false, HelpMessage = 'If provided, the specified PowerShell module will always be downloaded and installed, even if the version is already installed.')]
        [switch] $Force = $false
    )

    Process
    {
        $Credential = Get-AzureArtifactsCredential -credential $Credential

        if ($null -eq $credential)
        {
            [string] $computerName = $Env:ComputerName
            Write-Error "A personal access token was not found, so we cannot ensure a specific version (or the latest version) of PowerShell module '$Name' is installed on '$computerName'."
        }
        else
        {
            $Version = Install-ModuleVersion -powerShellModuleName $Name -versionToInstall $Version -allowPrerelease:$AllowPrerelease -repositoryName $RepositoryName -credential $credential -force:$Force
        }

        Import-ModuleVersion -powerShellModuleName $Name -version $Version
    }

    Begin
    {
        function Install-ModuleVersion([string] $powerShellModuleName, [string] $versionToInstall, [switch] $allowPrerelease, [string] $repositoryName, [System.Management.Automation.PSCredential] $credential, [switch] $force)
        {
            [string] $computerName = $Env:ComputerName

            [string[]] $currentModuleVersionsInstalled = (Get-Module -Name $powerShellModuleName -ListAvailable) | Select-Object -ExpandProperty 'Version' -Unique | Sort-Object -Descending

            [bool] $specificVersionWasRequestedAndIsAlreadyInstalled = ((![string]::IsNullOrWhitespace($versionToInstall)) -and $versionToInstall -in $currentModuleVersionsInstalled)
            if ($specificVersionWasRequestedAndIsAlreadyInstalled)
            {
                if (!$force)
                {
                    return $versionToInstall
                }
            }

            [string] $existingLatestVersion = ($currentModuleVersionsInstalled | Select-Object -First 1)
            [bool] $moduleIsInstalledOnComputerAlready = ![string]::IsNullOrWhitespace($existingLatestVersion)

            [bool] $latestVersionShouldBeInstalled = [string]::IsNullOrWhitespace($versionToInstall)
            if ($latestVersionShouldBeInstalled)
            {
                [string] $latestModuleVersionAvailable = Get-LatestAvailableVersion -powerShellModuleName $powerShellModuleName -allowPrerelease:$allowPrerelease -repositoryName $repositoryName -credential $credential

                [bool] $moduleWasNotFoundInPsRepository = [string]::IsNullOrWhitespace($latestModuleVersionAvailable)
                if ($moduleWasNotFoundInPsRepository)
                {
                    if ($moduleIsInstalledOnComputerAlready)
                    {
                        Write-Error "The PowerShell module '$powerShellModuleName' could not be found in the PSRepository '$repositoryName', so the latest version of the module could not be obtained. Perhaps the credentials used are not valid. The module version '$existingLatestVersion' is installed on computer '$computerName' though so it will be used."
                        return $existingLatestVersion
                    }
                    else
                    {
                        throw "The PowerShell module '$powerShellModuleName' could not be found in the PSRepository '$repositoryName' so it cannot be downloaded and installed. Perhaps the credentials used are not valid. The module is not already installed on computer '$computerName', so it cannot be imported."
                    }
                }

                $versionToInstall = $latestModuleVersionAvailable
            }
            else
            {
                [bool] $specifiedVersionDoesNotExist = ($null -eq (Find-Module -Name $powerShellModuleName -AllowPrerelease:$allowPrerelease -RequiredVersion $versionToInstall -Repository $repositoryName -Credential $credential -ErrorAction SilentlyContinue))
                if ($specifiedVersionDoesNotExist)
                {
                    if ($moduleIsInstalledOnComputerAlready)
                    {
                        Write-Error "The specified version '$versionToInstall' of PowerShell module '$powerShellModuleName' does not exist in the PSRepository '$repositoryName', so it cannot be installed on computer '$computerName'. Version '$existingLatestVersion' is already installed and will be imported instead."
                        return $existingLatestVersion
                    }
                    else
                    {
                        [string] $latestModuleVersionAvailable = Get-LatestAvailableVersion -powerShellModuleName $powerShellModuleName -allowPrerelease:$allowPrerelease -repositoryName $repositoryName -credential $credential

                        [bool] $moduleWasNotFoundInPsRepository = [string]::IsNullOrWhitespace($latestModuleVersionAvailable)
                        if ($moduleWasNotFoundInPsRepository)
                        {
                            throw "The PowerShell module '$powerShellModuleName' could not be found in the PSRepository '$repositoryName' so it cannot be downloaded and installed. Perhaps the credentials used are not valid. The module is not already installed on computer '$computerName', so it cannot be imported."
                        }

                        Write-Error "The specified version '$versionToInstall' of PowerShell module '$powerShellModuleName' does not exist in the PSRepository '$repositoryName'. Version '$latestModuleVersionAvailable' will be installed instead."

                        $versionToInstall = $latestModuleVersionAvailable
                    }
                }
            }

            [bool] $versionNeedsToBeInstalled = ($versionToInstall -notin $currentModuleVersionsInstalled) -or $force
            if ($versionNeedsToBeInstalled)
            {
                [string] $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
                [string] $moduleVersionsInstalledString = $currentModuleVersionsInstalled -join ','
                Write-Information "Current installed versions of PowerShell module '$powerShellModuleName' on computer '$computerName' are '$moduleVersionsInstalledString'. Installing version '$versionToInstall' for user '$currentUser'."
                Install-Module -Name $powerShellModuleName -AllowPrerelease:$allowPrerelease -RequiredVersion $versionToInstall -Repository $repositoryName -Credential $credential -Scope CurrentUser -Force -AllowClobber
            }
            return $versionToInstall
        }

        function Get-LatestAvailableVersion([string] $powerShellModuleName, [switch] $allowPrerelease, [string] $repositoryName, [System.Management.Automation.PSCredential] $credential)
        {
            [string] $latestModuleVersionAvailable =
                Find-Module -Name $powerShellModuleName -AllowPrerelease:$allowPrerelease -Repository $repositoryName -Credential $credential -ErrorAction SilentlyContinue |
                Select-Object -ExpandProperty 'Version' -First 1
            return $latestModuleVersionAvailable
        }

        function Import-ModuleVersion([string] $powerShellModuleName, [string] $version)
        {
            [bool] $isPrereleaseVersion = Test-PrereleaseVersion -version $version
            if (!$isPrereleaseVersion)
            {
                Import-Module -Name $powerShellModuleName -RequiredVersion $version -Global -Force
            }
            else
            {
                Import-ModulePrereleaseVersion -powerShellModuleName $powerShellModuleName -version $version
            }

            Write-ModuleVersionImported -powerShellModuleName $powerShellModuleName -version $version
        }

        function Test-PrereleaseVersion([string] $version)
        {
            [bool] $isPrereleaseVersion = $true
            [System.Version] $parsedVersion = $null
            if ([System.Version]::TryParse($Version, [ref]$parsedVersion))
            {
                $isPrereleaseVersion = $false
            }
            return $isPrereleaseVersion
        }

        function Import-ModulePrereleaseVersion([string] $powerShellModuleName, [string] $version)
        {
            [string] $computerName = $Env:ComputerName

            # PowerShell is weird about the way it supports prerelease versions.
            # The directory it installs to and the version it gives it is just the version with the prerelease postfix removed.
            # So really Import-Module has no way of telling if a module is a stable version or a prerelease version.
            # So we need to strip off the prerelease portion of the version number (i.e. what comes after the hyphen) to
            # get the stable version number, which Import-Module will use to find it.
            [string] $prereleaseVersionsStablePortion = ($ValidModulePrereleaseVersionThatExists -split '-')[0]

            [bool] $stableVersionWasExtractedFromPrereleaseVersion = ($prereleaseVersionsStablePortion -ne $version)
            if ($stableVersionWasExtractedFromPrereleaseVersion)
            {
                Import-Module -Name $powerShellModuleName -RequiredVersion $prereleaseVersionsStablePortion -Global -Force
            }
            else
            {
                Write-Warning "The prerelease version '$version' of module '$powerShellModuleName' was requested to be imported on computer '$computerName', but we could not determine where it was installed to. The module will be imported without specifying the version to import."
                Import-Module -Name $powerShellModuleName -Global -Force
            }
        }

        function Write-ModuleVersionImported([string] $powerShellModuleName, [string] $version)
        {
            [string] $computerName = $Env:ComputerName

            $moduleImported = Get-Module -Name $powerShellModuleName

            [bool] $moduleWasImported = ($null -ne $moduleImported)
            if ($moduleWasImported)
            {
                [string] $moduleVersion = $moduleImported.Version
                Write-Information "Version '$moduleVersion' of module '$powerShellModuleName' was imported on computer '$computerName'."
            }
            else
            {
                Write-Error "The module '$powerShellModuleName' was not imported on computer '$computerName'."
            }
        }
    }
}

function Get-AzureArtifactsCredential([System.Management.Automation.PSCredential] $credential = $null)
{
    if ($null -ne $credential)
    {
        return $credential
    }

    [System.Security.SecureString] $personalAccessToken = Get-SecurePersonalAccessTokenFromEnvironmentVariable

    if ($null -ne $personalAccessToken)
    {
        $credential = New-Object System.Management.Automation.PSCredential 'Username@DoesNotMatter.com', $personalAccessToken
    }

    return $credential
}

# Microsoft recommends storing the PAT in an environment variable: https://github.com/Microsoft/artifacts-credprovider#environment-variables
function Get-SecurePersonalAccessTokenFromEnvironmentVariable
{
    [System.Security.SecureString] $securePersonalAccessToken = $null
    [string] $personalAccessToken = [string]::Empty
    [string] $computerName = $Env:ComputerName
    [string] $patJsonValue = $Env:VSS_NUGET_EXTERNAL_FEED_ENDPOINTS
    if (![string]::IsNullOrWhiteSpace($patJsonValue))
    {
        $patJson = ConvertFrom-Json $patJsonValue
        $personalAccessToken = $patJson.endpointCredentials.password

        if ([string]::IsNullOrWhitespace($personalAccessToken))
        {
            Write-Warning "Found the environmental variable 'VSS_NUGET_EXTERNAL_FEED_ENDPOINTS' on computer '$computerName', but could not retrieve the Personal Access Token from it."
        }
        else
        {
            $securePersonalAccessToken = ConvertTo-SecureString $personalAccessToken -AsPlainText -Force
        }
    }
    else
    {
        Write-Warning "Could not find the environment variable 'VSS_NUGET_EXTERNAL_FEED_ENDPOINTS' on computer '$computerName' to extract the Personal Access Token from it."
    }
    return $securePersonalAccessToken
}

Export-ModuleMember -Function Import-AzureArtifactsModule
Export-ModuleMember -Function Register-AzureArtifactsPSRepository