Skip to content

Commit 16dd735

Browse files
committed
♻️ Fixes up a lot of the planning process
Also fixes BestCandidate scoping issue. Fixed #35
1 parent e7c0009 commit 16dd735

File tree

3 files changed

+74
-64
lines changed

3 files changed

+74
-64
lines changed

ModuleFast.psd1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
Description = 'Optimizes the PowerShell Module Installation Process to be as fast as possible and operate in CI/CD scenarios in a declarative manner'
3434

3535
# Minimum version of the PowerShell engine required by this module
36-
PowerShellVersion = '7.2'
36+
PowerShellVersion = '7.3' #Due to use of CLEAN block
3737

3838
# Name of the PowerShell host required by this module
3939
# PowerShellHostName = ''

ModuleFast.psm1

Lines changed: 62 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#requires -version 7.2 -Modules Microsoft.Powershell.SecretManagement
1+
#requires -version 7.3
22
using namespace Microsoft.PowerShell.Commands
33
using namespace System.Management.Automation
44
using namespace System.Management.Automation.Language
@@ -79,6 +79,8 @@ function Install-ModuleFast {
7979
#The path to the lockfile. By default it is requires.lock.json in the current folder. This is ignored if CI is not present. It is generally not recommended to change this setting.
8080
[string]$CILockFilePath = $(Join-Path $PWD 'requires.lock.json'),
8181
[Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ModuleFastInfo')][ModuleFastInfo]$ModuleFastInfo,
82+
#Output a list of specifications for the modules to install. This is the same as -WhatIf but without the additional WhatIf Output
83+
[Switch]$Plan,
8284
#This will output the resulting modules that were installed.
8385
[Switch]$PassThru
8486
)
@@ -118,10 +120,6 @@ function Install-ModuleFast {
118120
Add-DestinationToPSModulePath @addtoPathParams
119121
}
120122

121-
$currentWhatIfPreference = $WhatIfPreference
122-
#We do some stuff here that doesn't affect the system but triggers whatif, so we disable it
123-
$WhatIfPreference = $false
124-
125123
#We want to maintain a single HttpClient for the life of the module. This isn't as big of a deal as it used to be but
126124
#it is still a best practice.
127125
if (-not $SCRIPT:__ModuleFastHttpClient -or $Source -ne $SCRIPT:__ModuleFastHttpClient.BaseAddress) {
@@ -133,26 +131,27 @@ function Install-ModuleFast {
133131
$httpClient = $SCRIPT:__ModuleFastHttpClient
134132

135133
$cancelSource = [CancellationTokenSource]::new()
134+
135+
[List[ModuleFastSpec]]$ModulesToInstall = @()
136+
[List[ModuleFastInfo]]$installPlan = @()
136137
}
137138

138139
process {
139140
#We initialize and type the container list here because there is a bug where the ParameterSet is not correct in the begin block if the pipeline is used. Null conditional keeps it from being reinitialized
140-
[List[ModuleFastSpec]]$ModulesToInstall = @()
141+
141142
switch ($PSCmdlet.ParameterSetName) {
142143
'Specification' {
143-
[List[ModuleFastSpec]]$ModulesToInstall ??= @()
144144
foreach ($ModuleToInstall in $Specification) {
145145
$ModulesToInstall.Add($ModuleToInstall)
146146
}
147147
break
148+
148149
}
149150
'ModuleFastInfo' {
150-
[List[ModuleFastInfo]]$ModulesToInstall ??= @()
151-
foreach ($ModuleToInstall in $ModuleFastInfo) {
152-
$ModulesToInstall.Add($ModuleToInstall)
151+
foreach ($info in $ModuleFastInfo) {
152+
$installPlan.Add($info)
153153
}
154154
break
155-
156155
}
157156
'Path' {
158157
$ModulesToInstall = ConvertFrom-RequiredSpec -RequiredSpecPath $Path
@@ -161,36 +160,35 @@ function Install-ModuleFast {
161160
}
162161

163162
end {
164-
165-
if ($ModulesToInstall.Count -eq 0 -and $PSCmdlet.ParameterSetName -eq 'Specification') {
166-
Write-Verbose 'No modules specified to install. Beginning SpecFile detection...'
167-
$modulesToInstall = if ($CI -and (Test-Path $CILockFilePath)) {
168-
Write-Debug "Found lockfile at $CILockFilePath. Using for specification evaluation and ignoring all others."
169-
ConvertFrom-RequiredSpec -RequiredSpecPath $CILockFilePath
170-
} else {
171-
$specFiles = Find-RequiredSpecFile $PWD -CILockFileHint $CILockFilePath
172-
foreach ($specfile in $specFiles) {
173-
ConvertFrom-RequiredSpec -RequiredSpecPath $Path
163+
if (-not $installPlan) {
164+
if ($ModulesToInstall.Count -eq 0 -and $PSCmdlet.ParameterSetName -eq 'Specification') {
165+
Write-Verbose 'No modules specified to install. Beginning SpecFile detection...'
166+
$modulesToInstall = if ($CI -and (Test-Path $CILockFilePath)) {
167+
Write-Debug "Found lockfile at $CILockFilePath. Using for specification evaluation and ignoring all others."
168+
ConvertFrom-RequiredSpec -RequiredSpecPath $CILockFilePath
169+
} else {
170+
$specFiles = Find-RequiredSpecFile $PWD -CILockFileHint $CILockFilePath
171+
foreach ($specfile in $specFiles) {
172+
ConvertFrom-RequiredSpec -RequiredSpecPath $Path
173+
}
174174
}
175175
}
176-
}
177176

178-
if (-not $ModulesToInstall) {
179-
throw [InvalidDataException]'No modules specifications found to evaluate.'
180-
}
177+
if (-not $ModulesToInstall) {
178+
throw [InvalidDataException]'No modules specifications found to evaluate.'
179+
}
181180

182-
#If we do not have an explicit implementation plan, fetch it
183-
#This is done so that Get-ModuleFastPlan | Install-ModuleFastPlan and Install-ModuleFastPlan have the same flow.
184-
[ModuleFastInfo[]]$plan = if ($PSCmdlet.ParameterSetName -eq 'ModuleFastInfo') {
185-
$ModulesToInstall.ToArray()
186-
} else {
187-
Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status 'Plan' -PercentComplete 1
188-
Get-ModuleFastPlan -Specification $ModulesToInstall -HttpClient $httpClient -Source $Source -Update:$Update -PreRelease:$Prerelease.IsPresent
181+
#If we do not have an explicit implementation plan, fetch it
182+
#This is done so that Get-ModuleFastPlan | Install-ModuleFastPlan and Install-ModuleFastPlan have the same flow.
183+
[ModuleFastInfo[]]$installPlan = if ($PSCmdlet.ParameterSetName -eq 'ModuleFastInfo') {
184+
$ModulesToInstall.ToArray()
185+
} else {
186+
Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status 'Plan' -PercentComplete 1
187+
Get-ModuleFastPlan -Specification $ModulesToInstall -HttpClient $httpClient -Source $Source -Update:$Update -PreRelease:$Prerelease.IsPresent
188+
}
189189
}
190190

191-
$WhatIfPreference = $currentWhatIfPreference
192-
193-
if ($plan.Count -eq 0) {
191+
if ($installPlan.Count -eq 0) {
194192
$planAlreadySatisfiedMessage = "`u{2705} $($ModulesToInstall.count) Module Specifications have all been satisfied by installed modules. If you would like to check for newer versions remotely, specify -Update"
195193
if ($WhatIfPreference) {
196194
Write-Host -fore DarkGreen $planAlreadySatisfiedMessage
@@ -200,31 +198,34 @@ function Install-ModuleFast {
200198
return
201199
}
202200

203-
if (-not $PSCmdlet.ShouldProcess($Destination, "Install $($plan.Count) Modules")) {
204-
# Write-Host -fore DarkGreen "`u{1F680} ModuleFast Install Plan BEGIN"
205-
#TODO: Separate planned installs and dependencies
206-
$plan
207-
# Write-Host -fore DarkGreen "`u{1F680} ModuleFast Install Plan END"
208-
return
201+
#Unless Plan was specified, run the process (WhatIf will also short circuit).
202+
#Plan is specified first so that WhatIf message will only show if Plan is not specified due to -or short circuit logic.
203+
if ($Plan -or -not $PSCmdlet.ShouldProcess($Destination, "Install $($installPlan.Count) Modules")) {
204+
if ($Plan) {
205+
Write-Verbose "📑 -Plan was specified. Returning a plan including $($installPlan.Count) Module Specifications"
206+
}
207+
#TODO: Separate planned installs and dependencies. Can probably do this with a dependency flag on the ModuleInfo item and some custom formatting.
208+
Write-Output $installPlan
209+
} else {
210+
Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status "Installing: $($installPlan.count) Modules" -PercentComplete 50
211+
212+
$installHelperParams = @{
213+
ModuleToInstall = $installPlan
214+
Destination = $Destination
215+
CancellationToken = $cancelSource.Token
216+
HttpClient = $httpClient
217+
Update = $Update
218+
}
219+
Install-ModuleFastHelper @installHelperParams
220+
Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Completed
221+
Write-Verbose "`u{2705} All required modules installed! Exiting."
209222
}
210223

211-
Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status "Installing: $($plan.count) Modules" -PercentComplete 50
212-
213-
$installHelperParams = @{
214-
ModuleToInstall = $plan
215-
Destination = $Destination
216-
CancellationToken = $cancelSource.Token
217-
HttpClient = $httpClient
218-
Update = $Update
219-
}
220-
Install-ModuleFastHelper @installHelperParams
221-
Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Completed
222-
Write-Verbose "`u{2705} All required modules installed! Exiting."
223224
if ($CI) {
224225
#FIXME: If a package was already installed, it doesn't show up in this lockfile.
225226
Write-Verbose "Writing lockfile to $CILockFilePath"
226227
[Dictionary[string, string]]$lockFile = @{}
227-
$plan
228+
$installPlan
228229
| ForEach-Object {
229230
$lockFile.Add($PSItem.Name, $PSItem.ModuleVersion)
230231
}
@@ -332,12 +333,10 @@ function Get-ModuleFastPlan {
332333
#We dont need this to be ConcurrentList because we only manipulate it in the "main" runspace.
333334
[List[Task[String]]]$currentTasks = @()
334335

336+
#This is used to track the highest candidate if -Update was specified to force a remote lookup. If the candidate is still the most valid after remote lookup we can skip it without hitting disk to read the manifest again.
337+
[Dictionary[ModuleFastSpec, ModuleFastInfo]]$bestLocalCandidate = @{}
335338

336-
#This try finally is so that we can interrupt all http call tasks if Ctrl-C is pressed
337339
foreach ($moduleSpec in $ModulesToResolve) {
338-
#This is used to track the highest candidate if -Update was specified to force a remote lookup. If the candidate is still the most valid after remote lookup we can skip it without hitting disk to read the manifest again.
339-
[ModuleFastInfo]$bestLocalCandidate = $null
340-
341340
Write-Verbose "${moduleSpec}: Evaluating Module Specification"
342341
[ModuleFastInfo]$localMatch = Find-LocalModule $moduleSpec -Update:$Update -BestCandidate:([ref]$bestLocalCandidate)
343342
if ($localMatch) {
@@ -516,7 +515,7 @@ function Get-ModuleFastPlan {
516515

517516
#If -Update was specified, we need to re-check that none of the selected modules are already installed.
518517
#TODO: Persist state of the local modules found to this point so we don't have to recheck.
519-
if ($Update -and $bestLocalCandidate -and $bestLocalCandidate.ModuleVersion -eq $selectedModule.ModuleVersion) {
518+
if ($Update -and $bestLocalCandidate[$currentModuleSpec].ModuleVersion -eq $selectedModule.ModuleVersion) {
520519
Write-Debug "${selectedModule}: ✅ -Update was specified and the best remote candidate matches what is locally installed, so we can skip this module."
521520
#TODO: Fix the flow so this isn't stated twice
522521
[void]$taskSpecMap.Remove($completedTask)
@@ -568,7 +567,7 @@ function Get-ModuleFastPlan {
568567
return $true
569568
}
570569

571-
$modulesToInstall
570+
$modulesToInstall
572571
| Where-Object Name -EQ $dependency.Name
573572
| Sort-Object ModuleVersion -Descending
574573
| ForEach-Object {
@@ -1075,7 +1074,7 @@ class ModuleFastSpec {
10751074
[string] ToString() {
10761075
$guid = $this._Guid -ne [Guid]::Empty ? " [$($this._Guid)]" : ''
10771076
$versionRange = $this._VersionRange.ToString() -eq '(, )' ? '' : " $($this._VersionRange)"
1078-
if ($this._VersionRange.MaxVersion -eq $this._VersionRange.MinVersion) {
1077+
if ($this._VersionRange.MaxVersion -and $this._VersionRange.MaxVersion -eq $this._VersionRange.MinVersion) {
10791078
$versionRange = "($($this._VersionRange.MinVersion))"
10801079
}
10811080
return "$($this._Name)$guid$versionRange"
@@ -1414,9 +1413,9 @@ function Find-LocalModule {
14141413
if ($Update -and ($ModuleSpec.Max -ne $candidateVersion)) {
14151414
Write-Debug "${ModuleSpec}: Skipping $candidateVersion because -Update was specified and the version does not exactly meet the upper bound of the spec or no upper bound was specified at all, meaning there is a possible newer version remotely."
14161415
#We can use this ref later to find out if our best remote version matches what is installed without having to read the manifest again
1417-
if ($BestCandidate -and $manifestCandidate.ModuleVersion -gt $bestCandidate.Value.ModuleVersion) {
1416+
if ($BestCandidate -and $manifestCandidate.ModuleVersion -gt $bestCandidate.Value[$moduleSpec]) {
14181417
Write-Debug "${ModuleSpec}: New Best Candidate Version $($manifestCandidate.ModuleVersion)"
1419-
$BestCandidate.Value = $manifestCandidate
1418+
$BestCandidate.Value.Add($moduleSpec, $manifestCandidate)
14201419
}
14211420
continue
14221421
}

ModuleFast.tests.ps1

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,11 @@ Describe 'Install-ModuleFast' -Tag 'E2E' {
453453
| Select-String 'best remote candidate matches what is locally installed'
454454
| Should -Not -BeNullOrEmpty
455455
}
456+
It 'Only installs once when Update is specified and latest has not changed for multiple modules' {
457+
Install-ModuleFast @imfParams 'Az.Compute', 'Az.CosmosDB' -Update
458+
Install-ModuleFast @imfParams 'Az.Compute', 'Az.CosmosDB' -Update -WhatIf
459+
| Should -BeNullOrEmpty
460+
}
456461

457462
It 'Updates only dependent module that requires update' {
458463
Install-ModuleFast @imfParams @{ ModuleName = 'Az.Accounts'; RequiredVersion = '2.10.2' }
@@ -625,5 +630,11 @@ Describe 'Install-ModuleFast' -Tag 'E2E' {
625630
}
626631
}
627632

633+
Describe 'Plan Parameter' {
634+
It 'Does not install if Plan is specified' {
635+
Install-ModuleFast @imfParams -Specification 'PrereleaseTest' -Plan | Should -Match 'PreReleaseTest'
636+
Test-Path $installTempPath\PreReleaseTest | Should -BeFalse
637+
}
638+
}
628639
}
629640

0 commit comments

Comments
 (0)