diff --git a/src/code/FindHelper.cs b/src/code/FindHelper.cs index 388be9090..78c769901 100644 --- a/src/code/FindHelper.cs +++ b/src/code/FindHelper.cs @@ -1024,7 +1024,8 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R foreach (PSResourceInfo currentPkg in parentPkgs) { _cmdletPassedIn.WriteDebug($"Finding dependency packages for '{currentPkg.Name}'"); - foreach (PSResourceInfo pkgDep in FindDependencyPackages(currentServer, currentResponseUtil, currentPkg, repository)) + string[] emptyExternalModuleDependencies = Utils.EmptyStrArray; + foreach (PSResourceInfo pkgDep in FindDependencyPackages(currentServer, currentResponseUtil, currentPkg, emptyExternalModuleDependencies, repository)) { yield return pkgDep; } @@ -1100,6 +1101,7 @@ internal IEnumerable FindDependencyPackages( ServerApiCall currentServer, ResponseUtil currentResponseUtil, PSResourceInfo currentPkg, + string[] externalModuleDependencies, PSRepositoryInfo repository) { if (currentPkg.Dependencies.Length > 0) @@ -1108,6 +1110,13 @@ internal IEnumerable FindDependencyPackages( { PSResourceInfo depPkg = null; + if (externalModuleDependencies.Contains(dep.Name, StringComparer.OrdinalIgnoreCase)) + { + _cmdletPassedIn.WriteVerbose($"Dependency '{dep.Name}' is listed as an external module dependency, skipping search/install for this dependency."); + continue; + } + + string[] emptyExternalModuleDependencies = Utils.EmptyStrArray; if (dep.VersionRange.Equals(VersionRange.All)) { FindResults responses = currentServer.FindName(dep.Name, includePrerelease: true, _type, out ErrorRecord errRecord); @@ -1153,7 +1162,7 @@ internal IEnumerable FindDependencyPackages( if (!_packagesFound.ContainsKey(depPkg.Name)) { - foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, repository)) + foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, emptyExternalModuleDependencies, repository)) { yield return depRes; } @@ -1164,7 +1173,7 @@ internal IEnumerable FindDependencyPackages( // _packagesFound has depPkg.name in it, but the version is not the same if (!pkgVersions.Contains(FormatPkgVersionString(depPkg))) { - foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, repository)) + foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, emptyExternalModuleDependencies, repository)) { yield return depRes; } @@ -1217,7 +1226,7 @@ internal IEnumerable FindDependencyPackages( if (!_packagesFound.ContainsKey(depPkg.Name)) { - foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, repository)) + foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, emptyExternalModuleDependencies, repository)) { yield return depRes; } @@ -1228,7 +1237,7 @@ internal IEnumerable FindDependencyPackages( // _packagesFound has depPkg.name in it, but the version is not the same if (!pkgVersions.Contains(FormatPkgVersionString(depPkg))) { - foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, repository)) + foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, emptyExternalModuleDependencies, repository)) { yield return depRes; } @@ -1299,7 +1308,7 @@ internal IEnumerable FindDependencyPackages( if (!_packagesFound.ContainsKey(depPkg.Name)) { - foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, repository)) + foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, emptyExternalModuleDependencies, repository)) { yield return depRes; } @@ -1310,7 +1319,7 @@ internal IEnumerable FindDependencyPackages( // _packagesFound has depPkg.name in it, but the version is not the same if (!pkgVersions.Contains(FormatPkgVersionString(depPkg))) { - foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, repository)) + foreach (PSResourceInfo depRes in FindDependencyPackages(currentServer, currentResponseUtil, depPkg, emptyExternalModuleDependencies, repository)) { yield return depRes; } diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 0616cf040..818aceeac 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -558,6 +558,7 @@ private List InstallPackages( Hashtable parentPkgInfo = packagesHash[parentPackage] as Hashtable; PSResourceInfo parentPkgObj = parentPkgInfo["psResourceInfoPkg"] as PSResourceInfo; + string[] externalModuleDependencies = parentPkgInfo["externalModuleDependencies"] as string[]; if (!skipDependencyCheck) { @@ -565,7 +566,7 @@ private List InstallPackages( if (parentPkgObj.Dependencies.Length > 0) { bool depFindFailed = false; - foreach (PSResourceInfo depPkg in findHelper.FindDependencyPackages(currentServer, currentResponseUtil, parentPkgObj, repository)) + foreach (PSResourceInfo depPkg in findHelper.FindDependencyPackages(currentServer, currentResponseUtil, parentPkgObj, externalModuleDependencies, repository)) { if (depPkg == null) { @@ -824,7 +825,8 @@ private Hashtable BeginPackageInstall( { "tempDirNameVersionPath", tempInstallPath }, { "pkgVersion", "" }, { "scriptPath", "" }, - { "installPath", "" } + { "installPath", "" }, + { "externalModuleDependencies", Utils.EmptyStrArray } }); } } @@ -840,7 +842,8 @@ private Hashtable BeginPackageInstall( { "tempDirNameVersionPath", tempInstallPath }, { "pkgVersion", "" }, { "scriptPath", "" }, - { "installPath", "" } + { "installPath", "" }, + { "externalModuleDependencies", Utils.EmptyStrArray } }); } } @@ -933,6 +936,7 @@ private bool TryInstallToTempPath( _cmdletPassedIn.WriteDebug("In InstallHelper::TryInstallToTempPath()"); error = null; updatedPackagesHash = packagesHash; + string[] externalModuleDependencies = Utils.EmptyStrArray; try { var pathToFile = Path.Combine(tempInstallPath, $"{pkgName}.{normalizedPkgVersion}.zip"); @@ -1004,6 +1008,11 @@ private bool TryInstallToTempPath( return false; } + if (!RetrieveExternalModuleDependenciesForModule(pkgName, parsedMetadataHashtable, out externalModuleDependencies, out error)) + { + return false; + } + // Accept License verification if (!CallAcceptLicense(pkgToInstall, moduleManifest, tempInstallPath, pkgVersion, out error)) { @@ -1022,7 +1031,6 @@ private bool TryInstallToTempPath( { installPath = _pathsToInstallPkg.Find(path => path.EndsWith("Scripts", StringComparison.InvariantCultureIgnoreCase)); - // is script if (!PSScriptFileInfo.TryTestPSScriptFileInfo( scriptFileInfoPath: scriptPath, parsedScript: out PSScriptFileInfo scriptToInstall, @@ -1042,6 +1050,8 @@ private bool TryInstallToTempPath( return false; } + + externalModuleDependencies = scriptToInstall.ScriptMetadataComment.ExternalModuleDependencies; } else { @@ -1075,7 +1085,8 @@ private bool TryInstallToTempPath( { "tempDirNameVersionPath", tempDirNameVersion }, { "pkgVersion", pkgVersion }, { "scriptPath", scriptPath }, - { "installPath", installPath } + { "installPath", installPath }, + { "externalModuleDependencies", externalModuleDependencies } }); } @@ -1111,6 +1122,7 @@ private bool TrySaveNupkgToTempPath( _cmdletPassedIn.WriteDebug("In InstallHelper::TrySaveNupkgToTempPath()"); error = null; updatedPackagesHash = packagesHash; + string[] externalModuleDependencies = Utils.EmptyStrArray; try { @@ -1120,6 +1132,85 @@ private bool TrySaveNupkgToTempPath( responseStream.CopyTo(fs); fs.Close(); + var pkgVersion = pkgToInstall.Version.ToString(); + var tempDirNameVersion = Path.Combine(tempInstallPath, pkgName, pkgVersion); + Directory.CreateDirectory(tempDirNameVersion); + + if (!TryExtractToDirectory(pathToFile, tempDirNameVersion, out error)) + { + return false; + } + + var moduleManifest = Path.Combine(tempDirNameVersion, pkgName + PSDataFileExt); + var scriptPath = Path.Combine(tempDirNameVersion, pkgName + PSScriptFileExt); + + bool isModule = File.Exists(moduleManifest); + bool isScript = File.Exists(scriptPath); + + if (!isModule && !isScript) + { + scriptPath = ""; + } + + if (isModule) + { + if (!File.Exists(moduleManifest)) + { + error = new ErrorRecord( + new ArgumentException("Package '{pkgName}' could not be installed: Module manifest file: {moduleManifest} does not exist. This is not a valid PowerShell module."), + "PSDataFileNotExistError", + ErrorCategory.ReadError, + _cmdletPassedIn); + + return false; + } + + if (!Utils.TryReadManifestFile( + manifestFilePath: moduleManifest, + manifestInfo: out Hashtable parsedMetadataHashtable, + error: out Exception manifestReadError)) + { + error = new ErrorRecord( + manifestReadError, + "ManifestFileReadParseError", + ErrorCategory.ReadError, + _cmdletPassedIn); + + return false; + } + + if (!RetrieveExternalModuleDependenciesForModule(pkgName, parsedMetadataHashtable, out externalModuleDependencies, out error)) + { + return false; + } + } + else if(isScript) + { + if (!PSScriptFileInfo.TryTestPSScriptFileInfo( + scriptFileInfoPath: scriptPath, + parsedScript: out PSScriptFileInfo scriptToInstall, + out ErrorRecord[] parseScriptFileErrors, + out string[] _)) + { + foreach (ErrorRecord parseError in parseScriptFileErrors) + { + _cmdletPassedIn.WriteError(parseError); + } + + error = new ErrorRecord( + new InvalidOperationException($"PSScriptFile could not be parsed"), + "PSScriptParseError", + ErrorCategory.ReadError, + _cmdletPassedIn); + + return false; + } + + externalModuleDependencies = scriptToInstall.ScriptMetadataComment.ExternalModuleDependencies; + } + + DeleteExtraneousFiles(pkgName, tempDirNameVersion); + string installPath = _pathsToInstallPkg.First(); if (_includeXml) { @@ -1140,7 +1231,8 @@ private bool TrySaveNupkgToTempPath( { "tempDirNameVersionPath", tempInstallPath }, { "pkgVersion", "" }, { "scriptPath", "" }, - { "installPath", installPath } + { "installPath", installPath }, + { "externalModuleDependencies", externalModuleDependencies } }); } @@ -1531,6 +1623,39 @@ private void DeleteExtraneousFiles(string packageName, string dirNameVersion) } } + private bool RetrieveExternalModuleDependenciesForModule(string pkgName, Hashtable moduleMetadata, out string[] externalModuleDependencies, out ErrorRecord error) + { + error = null; + externalModuleDependencies = Utils.EmptyStrArray; + List externalModuleDependenciesForPkg = new List(); + + Hashtable privateData = moduleMetadata.ContainsKey("PrivateData") ? moduleMetadata["PrivateData"] as Hashtable : new Hashtable(StringComparer.OrdinalIgnoreCase); + Hashtable psData = privateData.ContainsKey("PSData") ? privateData["PSData"] as Hashtable : new Hashtable(StringComparer.OrdinalIgnoreCase); + object[] externalModDepObjects = psData.ContainsKey("ExternalModuleDependencies") ? psData["ExternalModuleDependencies"] as object[] : new object[0]; + if (externalModDepObjects != null) + { + foreach (var dep in externalModDepObjects) + { + string dependencyName = dep as string; + if (dependencyName.Contains("=")) + { + error = new ErrorRecord( + new ArgumentException($"Package '{pkgName}' could not be installed: ExternalModuleDependencies should only contain module names, not other metadata. Invalid entry: '{dependencyName}'"), + "ExternalModuleDependencyInvalidEntry", + ErrorCategory.ReadError, + _cmdletPassedIn); + + return false; + } + + externalModuleDependenciesForPkg.Add(dependencyName); + } + } + + externalModuleDependencies = externalModuleDependenciesForPkg.ToArray(); + return true; + } + #endregion } } diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index abdced37b..de00d5780 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -885,7 +885,7 @@ private string CreateNuspec( _cmdletPassedIn.WriteDebug("In PublishHelper::CreateNuspec()"); bool isModule = resourceType != ResourceType.Script; - requiredModules = new Hashtable(); + requiredModules = new Hashtable(StringComparer.OrdinalIgnoreCase); if (parsedMetadataHash == null || parsedMetadataHash.Count == 0) { @@ -946,6 +946,18 @@ private string CreateNuspec( { if (privateData["PSData"] is Hashtable psData) { + if (psData.ContainsKey("ExternalModuleDependencies")) + { + if (psData["ExternalModuleDependencies"] is string externalModuleDepNameStr) + { + parsedMetadataHash["ExternalModuleDependencies"] = new string[]{ externalModuleDepNameStr }; + } + else if(psData["ExternalModuleDependencies"] is string[] externalModuleDepNameArr) + { + parsedMetadataHash["ExternalModuleDependencies"] = externalModuleDepNameArr; + } + } + if (psData.ContainsKey("prerelease") && psData["prerelease"] is string preReleaseVersion) { if (!string.IsNullOrEmpty(preReleaseVersion)) diff --git a/test/InstallPSResourceTests/InstallPSResourceV2Server.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceV2Server.Tests.ps1 index ee35c3396..306193e26 100644 --- a/test/InstallPSResourceTests/InstallPSResourceV2Server.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceV2Server.Tests.ps1 @@ -646,7 +646,15 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { $depRes = Get-InstalledPSResource $depPkgName1, $depPkgName2 $depRes.Name | Should -Contain $depPkgName1 $depRes.Name | Should -Contain $depPkgName2 - } + } + + It "Install resource and dependency, while skipping dependency that is listed as external module dependency" { + $testParentModule = "test_module_ext_dep" + $requiredDependency = "test_module10" + $res = Install-PSresource -Name "test_module_ext_dep" -Repository $PSGalleryName -TrustRepository -PassThru + $res.Name | Should -Contain $testParentModule + $res.Name | Should -Contain $requiredDependency + } } Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'ManualValidationOnly' { diff --git a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 index bb58ddeed..f2b1f0719 100644 --- a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 @@ -167,6 +167,31 @@ Describe "Test Publish-PSResource" -tags 'CI' { (Get-ChildItem $script:repositoryPath).FullName | Should -Be $expectedPath } + It "Publish a module with ExternalModuleDependencies should automatically skip that dependency" { + $version = "1.0.0" + $moduleName = "test_ext_dep_module" + $externalDepName = "Appx" + $requiredModName = "test_module10" + $requiredModVersion = "2.0.0" + + # First publish RequiredModule dependency + $requiredModulePath = Join-Path -Path $script:PublishModuleBase -ChildPath $requiredModName.psd1 + New-ModuleManifest -Path $requiredModulePath -Description "$requiredModName test" -ModuleVersion $requiredModVersion + Publish-PSResource -Path $requiredModulePath -Repository $testRepository2 + $res = Find-PSResource $requiredModName -Version $requiredModVersion -Repository $testRepository2 + $res.Name | Should -Be $requiredModName + $res.Version | Should -Be $requiredModVersion + + # Next publish module which lists $requiredModName and an external module dependency under 'RequiredModules' section + $testModulePath = Join-Path -Path $script:PublishModuleBase -ChildPath $moduleName.psd1 + New-ModuleManifest -Path $testModulePath -ModuleVersion $version -Description "$moduleName module" -RequiredModules @( @{ ModuleName = $requiredModName; ModuleVersion = $requiredModVersion }, $externalDepName) -ExternalModuleDependencies $externalDepName + $manifest = Test-ModuleManifest $testModulePath + $manifest.PrivateData.PSData.ExternalModuleDependencies | Should -Contain $externalDepName + Publish-PSResource -Path $testModulePath -Repository $testRepository2 + $res2 = Find-PSResource $moduleName -Repository $testRepository2 + $res2.Name | Should -Be $moduleName + } + #region Local Source Path It "Publish a module with -Path and -Repository" { $version = "1.0.0" diff --git a/test/SavePSResourceTests/SavePSResourceV2.Tests.ps1 b/test/SavePSResourceTests/SavePSResourceV2.Tests.ps1 index 4b0269d82..b27bc3124 100644 --- a/test/SavePSResourceTests/SavePSResourceV2.Tests.ps1 +++ b/test/SavePSResourceTests/SavePSResourceV2.Tests.ps1 @@ -214,4 +214,20 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { $err.Count | Should -BeGreaterThan 0 $err[0].FullyQualifiedErrorId | Should -BeExactly 'ErrorFilteringNamesForUnsupportedWildcards,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource' } + + It "Save resource and dependency, while skipping dependency that is listed as external module dependency" { + $testParentModule = "test_module_ext_dep" + $requiredDependency = "test_module10" + $res = Save-PSresource -Name "test_module_ext_dep" -Repository $PSGalleryName -Path $SaveDir -TrustRepository -PassThru + $res.Name | Should -Contain $testParentModule + $res.Name | Should -Contain $requiredDependency + } + + It "Save resource and dependency, as .nupkg, while skipping dependency that is listed as external module dependency" { + $testParentModule = "test_module_ext_dep" + $requiredDependency = "test_module10" + $res = Save-PSresource -Name "test_module_ext_dep" -Repository $PSGalleryName -AsNupkg -Path $SaveDir -TrustRepository -PassThru + $res.Name | Should -Contain $testParentModule + $res.Name | Should -Contain $requiredDependency + } }