Skip to content

Commit 3b03a80

Browse files
author
Justin Grote
committed
Fixup version matching
1 parent 89e7f00 commit 3b03a80

2 files changed

Lines changed: 114 additions & 49 deletions

File tree

ModuleFast.ps1

Lines changed: 101 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,19 @@ function Install-ModuleFast {
7171
$WhatIfPreference = $currentWhatIfPreference
7272

7373
if ($plan.Count -eq 0) {
74+
if ($WhatIfPreference) {
75+
Write-Host -fore DarkGreen '✅ No modules found to install or all modules are already installed.'
76+
}
7477
Write-Verbose 'No modules found to install or all modules are already installed. Exiting.'
7578
return
7679
}
7780

7881
if (-not $PSCmdlet.ShouldProcess($Destination, "Install $($plan.Count) Modules")) {
7982
Write-Host -fore DarkGreen '🚀 ModuleFast Install Plan BEGIN'
83+
#TODO: Separate planned installs and dependencies
8084
$plan
81-
| Select-Object Name, @{N = 'Version'; E = { $_.Required } }
85+
| Select-Object Name, @{N = 'Version'; E = { [ModuleFastSpec]::VersionToString($_.Required) } }
86+
| Sort-Object Name
8287
| Format-Table -AutoSize
8388
| Out-String
8489
| Write-Host -ForegroundColor DarkGray
@@ -194,7 +199,7 @@ function Get-ModuleFastPlan {
194199
#This try finally is so that we can interrupt all http call tasks if Ctrl-C is pressed
195200
try {
196201
foreach ($moduleSpec in $ModulesToResolve) {
197-
$localMatch = Find-LocalModule $moduleSpec
202+
[string]$localMatch = Find-LocalModule $moduleSpec
198203
if ($localMatch -and -not $Update) {
199204
Write-Verbose "Found local module $localMatch that satisfies $moduleSpec. Skipping..."
200205
#TODO: Capture this somewhere that we can use it to report in the deploy plan
@@ -254,6 +259,7 @@ function Get-ModuleFastPlan {
254259
#TODO: Type the responses and check on the type, not the existence of a property.
255260

256261
#HACK: Add the download URI to the catalog entry, this makes life easier.
262+
#TODO: This needs to be moved to a function so it isn't duplicated down in the "else" section below
257263
$pageLeaves = $response.items.items
258264
$pageLeaves | ForEach-Object {
259265
if ($PSItem.packageContent -and -not $PSItem.catalogEntry.packagecontent) {
@@ -271,9 +277,12 @@ function Get-ModuleFastPlan {
271277
Limit-ModuleFastSpecVersions -ModuleSpec $currentModuleSpec -Highest -Versions $inlinedVersions
272278
}
273279

274-
if ($versionMatch) {
280+
281+
$selectedEntry = if ($versionMatch) {
275282
Write-Debug "$currentModuleSpec`: Found satisfying version $versionMatch in the inlined index."
276-
$selectedEntry = $entries | Where-Object version -EQ $versionMatch
283+
284+
#Output
285+
$entries | Where-Object version -EQ $versionMatch
277286
} else {
278287
#TODO: This should maybe be a separate function
279288

@@ -317,7 +326,7 @@ function Get-ModuleFastPlan {
317326
if (-not $pages) {
318327
throw [InvalidOperationException]"$currentModuleSpec`: a matching module was not found in the $Source repository that satisfies the version constraints. If this happens during dependency lookup, it is a bug in ModuleFast."
319328
}
320-
Write-Debug "$currentModuleSpec`: Found $($pages.Count) additional pages that might match the query."
329+
Write-Debug "$currentModuleSpec`: Found $(@($pages).Count) additional pages that might match the query: $($pages.'@id' -join ',')"
321330

322331
#TODO: This is relatively slow and blocking, but we would need complicated logic to process it in the main task handler loop.
323332
#I really should make a pipeline that breaks off tasks based on the type of the response.
@@ -330,7 +339,18 @@ function Get-ModuleFastPlan {
330339
while ($false -in $tasks.IsCompleted) {
331340
[void][Task]::WaitAll($tasks, 500)
332341
}
333-
$entries = ($tasks.GetAwaiter().GetResult() | ConvertFrom-Json).items.catalogEntry
342+
$response = $tasks.GetAwaiter().GetResult() | ConvertFrom-Json
343+
$items = $response.items
344+
345+
$pageLeaves = $items
346+
$pageLeaves | ForEach-Object {
347+
if ($PSItem.packageContent -and -not $PSItem.catalogEntry.packagecontent) {
348+
$PSItem.catalogEntry
349+
| Add-Member -NotePropertyName 'PackageContent' -NotePropertyValue $PSItem.packageContent
350+
}
351+
}
352+
353+
$entries = $pageLeaves.catalogEntry
334354

335355
# TODO: Dedupe this logic with the above
336356
[HashSet[Version]]$inlinedVersions = $entries.version
@@ -341,7 +361,9 @@ function Get-ModuleFastPlan {
341361
[Version]$versionMatch = Limit-ModuleFastSpecVersions -ModuleSpec $moduleSpec -Versions $inlinedVersions -Highest
342362
if ($versionMatch) {
343363
Write-Debug "$currentModuleSpec`: Found satisfying version $versionMatch in one of the additional pages."
344-
$selectedEntry = $entries | Where-Object version -EQ $versionMatch
364+
365+
#Output
366+
$entries | Where-Object version -EQ $versionMatch
345367
#TODO: Resolve dependencies in separate function
346368
}
347369
}
@@ -350,6 +372,7 @@ function Get-ModuleFastPlan {
350372
throw 'Something other than exactly 1 selectedModule was specified. This should never happen and is a bug'
351373
}
352374

375+
if (-not $selectedEntry.packageContent) { throw "No package content found for $($selectedEntry.packageContent). This should never happen and is a bug" }
353376
[ModuleFastSpec]$moduleInfo = [ModuleFastSpec]::new(
354377
$selectedEntry.id,
355378
$selectedEntry.version,
@@ -430,16 +453,19 @@ function Get-ModuleFastPlan {
430453
# We do this here rather than populate modulesToResolve because the tasks wont start until all the existing tasks complete
431454
# TODO: Figure out a way to dedupe this logic maybe recursively but I guess a function would be fine too
432455
foreach ($dependencySpec in $dependenciesToResolve) {
433-
$localMatch = Find-LocalModule $dependencySpec
456+
[string]$localMatch = Find-LocalModule $dependencySpec
434457
if ($localMatch -and -not $Update) {
435458
Write-Verbose "Found local module $localMatch that satisfies dependency $dependencySpec. Skipping..."
436459
#TODO: Capture this somewhere that we can use it to report in the deploy plan
437460
continue
461+
} else {
462+
Write-Debug "No local modules that satisfies dependency $dependencySpec. Checking Remote..."
438463
}
439464
# TODO: Deduplicate in-flight queries (az.accounts is a good example)
440465
# Write-Debug "$moduleSpec`: Checking if $dependencySpec already has an in-flight request that satisfies the requirement"
441466

442467
Write-Debug "$currentModuleSpec`: Fetching dependency $dependencySpec"
468+
#TODO: Do a direct version lookup if the dependency is a required version
443469
$task = Get-ModuleInfoAsync @httpContext -Endpoint $Source -Name $dependencySpec.Name
444470
$resolveTasks[$task] = $dependencySpec
445471
#Used to track progress as tasks can get removed
@@ -793,18 +819,28 @@ class ModuleFastSpec : IComparable {
793819
[string] ToString() {
794820
$name = $this._Name + ($this._Guid -ne [Guid]::Empty ? " [$($this._Guid)]" : '')
795821
$versionString = switch ($true) {
796-
($this.Min -eq [ModuleFastSpec]::MinVersion -and $this.Max -eq [ModuleFastSpec]::MaxVersion) {
822+
($this.Min -eq [ModuleFastSpec]::MinVersion -and $this.Max -eq [ModuleFastSpec]::MaxVersion) {
797823
#This is the default, so we don't need to print it
798824
break
799825
}
800-
($null -ne $this.required) { "@$($this.Required)"; break }
801-
($this.Min -eq [ModuleFastSpec]::MinVersion) { "<$($this.Max)"; break }
802-
($this.Max -eq [ModuleFastSpec]::MaxVersion) { ">$($this.Min)"; break }
826+
($null -ne $this.required) { "@$([ModuleFastSpec]::VersionToString($this.Required))"; break }
827+
($this.Min -eq [ModuleFastSpec]::MinVersion) { "<$([ModuleFastSpec]::VersionToString($this.Max))"; break }
828+
($this.Max -eq [ModuleFastSpec]::MaxVersion) { ">$([ModuleFastSpec]::VersionToString($this.Min))"; break }
803829
default { ":$($this.Min)-$($this.Max)" }
804830
}
805831
return $name + $versionString
806832
}
807833

834+
#Converts a stored version to a string representation. This handles cases where the value was originally a System.Version
835+
static [string] VersionToString([SemanticVersion]$version) {
836+
if ($null -eq $version) { return $null }
837+
if ($Version.BuildLabel -match 'SYSTEMVERSION' -and $version.PrereleaseLabel -as [int]) {
838+
#This is a system version, we need to convert it back to a system version
839+
return [ModuleFastSpec]::ParseSemanticVersion($version).ToString()
840+
}
841+
return $version.ToString()
842+
}
843+
808844
#BUG: We cannot implement IEquatable directly because we need to self-reference ModuleFastSpec before it exists.
809845
#We can however just add Equals() method
810846

@@ -948,8 +984,8 @@ class NugetRange {
948984
return
949985
}
950986
$minString, $maxString = $range.split(',')
951-
if (-not [String]::IsNullOrWhiteSpace($minString.trim())) { $minString.trim() }
952-
if (-not [String]::IsNullOrWhiteSpace($maxString.trim())) { $maxString.trim() }
987+
if (-not [String]::IsNullOrWhiteSpace($minString)) { $this.Min = [ModuleFastSpec]::ParseVersionString($minString) }
988+
if (-not [String]::IsNullOrWhiteSpace($maxString)) { $this.Max = [ModuleFastSpec]::ParseVersionString($maxString) }
953989
}
954990

955991
static [SemanticVersion] Decrement([SemanticVersion]$version) {
@@ -1126,17 +1162,17 @@ function Find-LocalModule {
11261162
}
11271163

11281164
#Linux/Mac support requires a case insensitive search on a user supplied variable.
1129-
$moduleDir = [Directory]::GetDirectories($modulePath, $moduleSpec.Name, [EnumerationOptions]@{MatchCasing = 'CaseInsensitive' })
1130-
if ($moduleDir.count -gt 1) { throw "$($moduleSpec.Name) folder is ambiguous, please delete one of these folders: $moduleDir" }
1131-
if (-not $moduleDir) {
1165+
$moduleBaseDir = [Directory]::GetDirectories($modulePath, $moduleSpec.Name, [EnumerationOptions]@{MatchCasing = 'CaseInsensitive' })
1166+
if ($moduleBaseDir.count -gt 1) { throw "$($moduleSpec.Name) folder is ambiguous, please delete one of these folders: $moduleBaseDir" }
1167+
if (-not $moduleBaseDir) {
11321168
Write-Debug "$modulePath does not have a $($moduleSpec.Name) folder. Skipping..."
11331169
continue
11341170
}
11351171

11361172
if ($moduleSpec.Required) {
11371173
#We can speed up the search for explicit requiredVersion matches
11381174
$moduleVersion = $ModuleSpec.Version #We want to search using a nuget translated path
1139-
$moduleFolder = Join-Path $modulePath $ModuleSpec.Name $moduleVersion
1175+
$moduleFolder = Join-Path $moduleBaseDir $moduleVersion
11401176

11411177
$manifestPath = Join-Path $moduleFolder "$($ModuleSpec.Name).psd1"
11421178

@@ -1148,43 +1184,65 @@ function Find-LocalModule {
11481184
if ($manifestPath.count -eq 1) { return $manifestPath }
11491185
}
11501186
} else {
1151-
$folders = [System.IO.Directory]::GetDirectories($moduleDir) | Split-Path -Leaf
1152-
[Version[]]$candidateVersions = foreach ($folder in $folders) {
1187+
#This is used to keep a map of versions to manifests, needed to support both "classic" and "versioned" module folders
1188+
[Dictionary[Version, String]]$candidateVersions = @{}
1189+
1190+
#If not a classic module, check for versioned folders
1191+
$folders = [Directory]::GetDirectories($moduleBaseDir)
1192+
foreach ($folder in $folders) {
1193+
$versionCandidate = Split-Path -Leaf $folder
11531194
[Version]$version = $null
1154-
if ([Version]::TryParse($folder, [ref]$version)) {
1155-
$version
1195+
if ([Version]::TryParse($versionCandidate, [ref]$version)) {
1196+
#Try to retrieve the manifest
1197+
#TODO: Create a "Assert-CaseSensitiveFileExists" function for this pattern used multiple times
1198+
$versionedManifestPath = [Directory]::GetFiles($folder, "$($ModuleSpec.Name).psd1", [EnumerationOptions]@{MatchCasing = 'CaseInsensitive' })
1199+
if ($versionedManifestPath.count -gt 1) { throw "$folder manifest is ambiguous, please delete one of these: $versionedManifestPath" }
1200+
if ($versionedManifestPath) {
1201+
$candidateVersions.Add($version, $versionedManifestPath)
1202+
}
11561203
} else {
1157-
#Check for a "classic" non-versioned module folder and get the version from the manifest
1158-
$manifestPath = [Directory]::GetFiles((Join-Path $modulePath $moduleSpec.Name), "$($ModuleSpec.Name).psd1", [EnumerationOptions]@{MatchCasing = 'CaseInsensitive' })
1159-
if ($manifestPath.count -gt 1) { throw "$moduleFolder manifest is ambiguous, please delete one of these: $manifestPath" }
1204+
Write-Debug "Could not parse $folder in $moduleBaseDir as a valid version. This is either a bad version directory or this folder is a classic module."
1205+
continue
1206+
}
11601207

1161-
if ($manifestPath) {
1162-
$manifestData = Import-PowerShellDataFile $manifestPath
1208+
#Check for a "classic" module if no versioned folders were found
1209+
if ($candidateVersions.count -eq 0) {
1210+
$classicManifestPath = [Directory]::GetFiles($moduleBaseDir, "$($ModuleSpec.Name).psd1", [EnumerationOptions]@{MatchCasing = 'CaseInsensitive' })
1211+
if ($classicManifestPath.count -gt 1) { throw "$moduleBaseDir manifest is ambiguous, please delete one of these: $classicManifestPath" }
1212+
if ($classicManifestPath) {
1213+
$manifestData = Import-PowerShellDataFile $classicManifestPath
11631214
#Return the version in the manifest
1164-
$manifestData.ModuleVersion
1215+
$candidateVersions.Add($manifestData.ModuleVersion, $classicManifestPath)
1216+
continue
11651217
}
1218+
}
11661219

1167-
Write-Warning "Could not parse $folder in $moduleDir as a valid version. This is probably a bad module directory and should be removed."
1220+
if (-not $candidateVersions.count) {
1221+
Write-Verbose "$moduleSpec`: module folder exists at $moduleBaseDir but no modules found that match the version spec."
1222+
continue
11681223
}
1169-
}
11701224

1171-
if (-not $candidateVersions) {
1172-
Write-Verbose "$moduleSpec`: module folder exists at $moduleDir but no modules found that match the version spec."
1173-
continue
1174-
}
1175-
$versionMatch = Limit-ModuleFastSpecVersions -ModuleSpec $ModuleSpec -Versions $candidateVersions -Highest
1176-
if ($versionMatch) {
1177-
$manifestPath = Join-Path $moduleDir $([Version]$versionMatch) "$($ModuleSpec.Name).psd1"
1178-
if (-not [File]::Exists($manifestPath)) {
1179-
# Our matching method doesn't make it easy to match on "next highest" version, so we have to do this.
1180-
throw "A matching module folder was found for $ModuleSpec but the manifest is not present at $manifestPath. This indicates a corrupt module and should be removed before proceeding."
1181-
} else {
1182-
return $manifestPath
1225+
[Version]$versionMatch = Limit-ModuleFastSpecVersions -ModuleSpec $ModuleSpec -Versions $candidateVersions.Keys -Highest
1226+
if (-not $versionMatch) {
1227+
[string[]]$candidateStrings = foreach ($candidate in $candidateVersions.keys) {
1228+
'{0} ({1})' -f $candidateVersions[$candidate], $candidate
1229+
}
1230+
Write-Debug "$moduleSpec`: Module versions were found but none that match the module spec requirement. Found Candidates: $($candidateStrings -join ';')"
1231+
return $null
1232+
}
1233+
1234+
[string]$matchingManifest = $candidateVersions[$versionMatch]
1235+
1236+
if (-not [File]::Exists($matchingManifest)) {
1237+
throw "A matching module folder was found for $ModuleSpec but the manifest is not present at $matchingManifest. This is a bug and should never happen as we should have checked this ahead of time."
11831238
}
1239+
#TODO: Verify the manifest isn't corrupt by checking the module version? Should we be doing this for all manifests even tho it's a perf hit? Configurable option makes sense
1240+
1241+
return $matchingManifest
11841242
}
11851243
}
11861244
}
1187-
return $false
1245+
return $null
11881246
}
11891247

11901248
# Find all normalized versions of a version, for example 1.0.1.0 also is 1.0.1

ModuleFast.tests.ps1

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -360,19 +360,26 @@ Describe 'Install-ModuleFast' -Tag 'E2E' {
360360
BeforeEach {
361361
#Remove all PSModulePath to not affect existing environment
362362
$SCRIPT:__existingPSModulePath = $env:PSModulePath
363+
$testDrivePath = (Get-Item testdrive:).fullname
364+
$installTempPath = Join-Path $testDrivePath $(New-Guid)
365+
New-Item -ItemType Directory -Path $installTempPath -ErrorAction stop
366+
$imfParams = @{
367+
Destination = $installTempPath
368+
NoProfileUpdate = $true
369+
NoPSModulePathUpdate = $true
370+
Confirm = $false
371+
}
363372
}
364373
It 'Installs Module' {
365374
#HACK: The testdrive mount is not available in the threadjob runspaces so we need to translate it
366-
$testDrivePath = (Get-Item testdrive:).fullname
367-
Install-ModuleFast 'Az.Accounts' -Destination $testDrivePath -NoProfileUpdate -NoPSModulePathUpdate
368-
Get-Item TestDrive:\Az.Accounts\*\Az.Accounts.psd1 | Should -Not -BeNullOrEmpty
375+
Install-ModuleFast @imfParams 'Az.Accounts'
376+
Get-Item $installTempPath\Az.Accounts\*\Az.Accounts.psd1 | Should -Not -BeNullOrEmpty
369377
}
370378
It 'Installs Module with lots of dependencies (Az)' {
371-
Install-ModuleFast 'Az' -Destination (Get-Item testdrive:).fullname -NoProfileUpdate -NoPSModulePathUpdate
379+
Install-ModuleFast @imfParams 'Az'
372380
}
373381
It 'Installs Module with 4 section version numbers (VMware.PowerCLI)' {
374-
$testDrivePath = (Get-Item testdrive:).fullname
375-
Install-ModuleFast 'VMware.VimAutomation.Common' -Destination $testDrivePath -NoProfileUpdate -NoPSModulePathUpdate
382+
Install-ModuleFast @imfParams 'VMware.VimAutomation.Common'
376383
Get-Item TestDrive:\*\*\*.psd1 | ForEach-Object {
377384
$moduleFolderVersion = $_ | Split-Path | Split-Path -Leaf
378385
Import-PowerShellDataFile -Path $_.FullName | ForEach-Object ModuleVersion | Should -Be $moduleFolderVersion

0 commit comments

Comments
 (0)