From edd5ff4a0b2ef1ef294fe46d488e2a1ff8a50145 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Oct 2023 13:02:53 -0700 Subject: [PATCH 001/160] Bump Microsoft.PowerShell.SDK in /test/perf/benchmarks (#1441) --- test/perf/benchmarks/benchmarks.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/perf/benchmarks/benchmarks.csproj b/test/perf/benchmarks/benchmarks.csproj index b7f2a4bf9..0117c7feb 100644 --- a/test/perf/benchmarks/benchmarks.csproj +++ b/test/perf/benchmarks/benchmarks.csproj @@ -20,6 +20,6 @@ - + From 7aa079aa777d3f1f9358a4fe957ed2bcf24cf657 Mon Sep 17 00:00:00 2001 From: Thomas Nieto <38873752+ThomasNieto@users.noreply.github.com> Date: Wed, 25 Oct 2023 15:58:42 -0500 Subject: [PATCH 002/160] Add Name and Repository pipeline by property name (#1451) --- src/code/FindPSResource.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/code/FindPSResource.cs b/src/code/FindPSResource.cs index f5bda3c87..d2a78b1a6 100644 --- a/src/code/FindPSResource.cs +++ b/src/code/FindPSResource.cs @@ -41,6 +41,7 @@ public sealed class FindPSResource : PSCmdlet [SupportsWildcards] [Parameter(Position = 0, ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true, ParameterSetName = NameParameterSet)] [ValidateNotNullOrEmpty] public string[] Name { get; set; } @@ -91,7 +92,7 @@ public sealed class FindPSResource : PSCmdlet /// Specifies one or more repository names to search. If not specified, search will include all currently registered repositories. /// [SupportsWildcards] - [Parameter()] + [Parameter(ValueFromPipelineByPropertyName = true)] [ArgumentCompleter(typeof(RepositoryNameCompleter))] [ValidateNotNullOrEmpty] public string[] Repository { get; set; } From 122cdcbc305c171d2deed70701b4d9ec0fa76562 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Wed, 25 Oct 2023 13:59:02 -0700 Subject: [PATCH 003/160] Add method to extract nupkg to directory (#1456) --- src/code/InstallHelper.cs | 72 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 2e1e7cc87..da538f812 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.IO.Compression; using System.Linq; using System.Management.Automation; using System.Net; @@ -936,7 +937,11 @@ private bool TryInstallToTempPath( var pkgVersion = pkgToInstall.Version.ToString(); var tempDirNameVersion = Path.Combine(tempInstallPath, pkgName.ToLower(), pkgVersion); Directory.CreateDirectory(tempDirNameVersion); - System.IO.Compression.ZipFile.ExtractToDirectory(pathToFile, tempDirNameVersion); + + if (!TryExtractToDirectory(pathToFile, tempDirNameVersion, out error)) + { + return false; + } File.Delete(pathToFile); @@ -1146,6 +1151,71 @@ private bool TrySaveNupkgToTempPath( } } + /// + /// Extracts files from .nupkg + /// Similar functionality as System.IO.Compression.ZipFile.ExtractToDirectory, + /// but while ExtractToDirectory cannot overwrite files, this method can. + /// + private bool TryExtractToDirectory(string zipPath, string extractPath, out ErrorRecord error) + { + error = null; + // Normalize the path + extractPath = Path.GetFullPath(extractPath); + + // Ensures that the last character on the extraction path is the directory separator char. + // Without this, a malicious zip file could try to traverse outside of the expected extraction path. + if (!extractPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + { + extractPath += Path.DirectorySeparatorChar; + } + + try + { + using (ZipArchive archive = ZipFile.OpenRead(zipPath)) + { + foreach (ZipArchiveEntry entry in archive.Entries) + { + // If a file has one or more parent directories. + if (entry.FullName.Contains(Path.DirectorySeparatorChar) || entry.FullName.Contains(Path.AltDirectorySeparatorChar)) + { + // Create the parent directories if they do not already exist + var lastPathSeparatorIdx = entry.FullName.Contains(Path.DirectorySeparatorChar) ? + entry.FullName.LastIndexOf(Path.DirectorySeparatorChar) : entry.FullName.LastIndexOf(Path.AltDirectorySeparatorChar); + var parentDirs = entry.FullName.Substring(0, lastPathSeparatorIdx); + var destinationDirectory = Path.Combine(extractPath, parentDirs); + if (!Directory.Exists(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + } + + // Gets the full path to ensure that relative segments are removed. + string destinationPath = Path.GetFullPath(Path.Combine(extractPath, entry.FullName)); + + // Validate that the resolved output path starts with the resolved destination directory. + // For example, if a zip file contains a file entry ..\sneaky-file, and the zip file is extracted to the directory c:\output, + // then naively combining the paths would result in an output file path of c:\output\..\sneaky-file, which would cause the file to be written to c:\sneaky-file. + if (destinationPath.StartsWith(extractPath, StringComparison.Ordinal)) + { + entry.ExtractToFile(destinationPath, overwrite: true); + } + } + } + } + catch (Exception e) + { + error = new ErrorRecord( + new Exception($"Error occured while extracting .nupkg: '{e.Message}'"), + "ErrorExtractingNupkg", + ErrorCategory.OperationStopped, + _cmdletPassedIn); + + return false; + } + + return true; + } + /// /// Moves package files/directories from the temp install path into the final install path location. /// From 91d5c46c106d546117a9aa8013f2669dde8e7a22 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 25 Oct 2023 14:01:27 -0700 Subject: [PATCH 004/160] Bugfix script parse whitespace (#1457) --- src/code/PSScriptFileInfo.cs | 8 ++-- .../TestPSScriptFile.Tests.ps1 | 7 ++++ ...riptWithWhitespaceBeforeClosingComment.ps1 | 42 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 test/testFiles/testScripts/ScriptWithWhitespaceBeforeClosingComment.ps1 diff --git a/src/code/PSScriptFileInfo.cs b/src/code/PSScriptFileInfo.cs index 87d1bb1c5..d71c34e78 100644 --- a/src/code/PSScriptFileInfo.cs +++ b/src/code/PSScriptFileInfo.cs @@ -127,7 +127,7 @@ internal static bool TryParseScriptFileContents( { string line = fileContents[i]; - if (line.StartsWith("<#PSScriptInfo")) + if (line.Trim().StartsWith("<#PSScriptInfo")) { int j = i + 1; // start at the next line // keep grabbing lines until we get to closing #> @@ -135,7 +135,7 @@ internal static bool TryParseScriptFileContents( { string blockLine = fileContents[j]; psScriptInfoCommentContent.Add(blockLine); - if (blockLine.StartsWith("#>")) + if (blockLine.Trim().StartsWith("#>")) { reachedPSScriptInfoCommentEnd = true; @@ -157,7 +157,7 @@ internal static bool TryParseScriptFileContents( return false; } } - else if (line.StartsWith("<#")) + else if (line.Trim().StartsWith("<#")) { // The next comment block must be the help comment block (containing description) // keep grabbing lines until we get to closing #> @@ -166,7 +166,7 @@ internal static bool TryParseScriptFileContents( { string blockLine = fileContents[j]; - if (blockLine.StartsWith("#>")) + if (blockLine.Trim().StartsWith("#>")) { reachedHelpInfoCommentEnd = true; i = j + 1; diff --git a/test/PSScriptFileInfoTests/TestPSScriptFile.Tests.ps1 b/test/PSScriptFileInfoTests/TestPSScriptFile.Tests.ps1 index b667e317e..47da1dfea 100644 --- a/test/PSScriptFileInfoTests/TestPSScriptFile.Tests.ps1 +++ b/test/PSScriptFileInfoTests/TestPSScriptFile.Tests.ps1 @@ -88,4 +88,11 @@ Describe "Test Test-PSScriptFileInfo" -tags 'CI' { Test-PSScriptFileInfo $scriptFilePath | Should -Be $true } + + It "determine script with whitespace before closing comment is valid" { + $scriptName = "ScriptWithWhitespaceBeforeClosingComment.ps1" + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + + Test-PSScriptFileInfo $scriptFilePath | Should -Be $true + } } diff --git a/test/testFiles/testScripts/ScriptWithWhitespaceBeforeClosingComment.ps1 b/test/testFiles/testScripts/ScriptWithWhitespaceBeforeClosingComment.ps1 new file mode 100644 index 000000000..c0e06af8b --- /dev/null +++ b/test/testFiles/testScripts/ScriptWithWhitespaceBeforeClosingComment.ps1 @@ -0,0 +1,42 @@ + +<#PSScriptInfo + +.VERSION 1.0 + +.GUID 3951be04-bd06-4337-8dc3-a620bf539fbd + +.AUTHOR annavied + +.COMPANYNAME + +.COPYRIGHT + +.TAGS + +.LICENSEURI + +.PROJECTURI + +.ICONURI + +.EXTERNALMODULEDEPENDENCIES + +.REQUIREDSCRIPTS + +.EXTERNALSCRIPTDEPENDENCIES + +.RELEASENOTES + + +.PRIVATEDATA + +#> + +<# + +.DESCRIPTION + this is a test for a script that will be published remotely + + #> +Param() + From 72e4675024646cc403c9ecfa4dcd6a73a9575e79 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Oct 2023 10:20:37 -0700 Subject: [PATCH 005/160] Bump Microsoft.PowerShell.SDK in /test/perf/benchmarks (#1463) --- test/perf/benchmarks/benchmarks.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/perf/benchmarks/benchmarks.csproj b/test/perf/benchmarks/benchmarks.csproj index 0117c7feb..2acd07179 100644 --- a/test/perf/benchmarks/benchmarks.csproj +++ b/test/perf/benchmarks/benchmarks.csproj @@ -20,6 +20,6 @@ - + From ece18721dc1df97d54caf4f55888dfa14c0b757f Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Fri, 27 Oct 2023 11:33:07 -0700 Subject: [PATCH 006/160] Update unix local user paths (#1464) --- src/code/Utils.cs | 49 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 1a025ffda..9b578d4d3 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -16,6 +16,7 @@ using Microsoft.PowerShell.Commands; using Microsoft.PowerShell.PSResourceGet.Cmdlets; using System.Net.Http; +using System.Globalization; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses { @@ -83,6 +84,12 @@ function ConvertToHash #endregion + #region Path fields + + private static string s_tempHome = null; + + #endregion + #region String methods public static string TrimQuotes(string name) @@ -976,23 +983,53 @@ public static List GetAllInstallationPaths( return installationPaths; } + private static string GetHomeOrCreateTempHome() + { + const string tempHomeFolderName = "psresourceget-{0}-98288ff9-5712-4a14-9a11-23693b9cd91a"; + + string envHome = Environment.GetEnvironmentVariable("HOME") ?? s_tempHome; + if (envHome is not null) + { + return envHome; + } + + try + { + var s_tempHome = Path.Combine(Path.GetTempPath(), string.Format(CultureInfo.CurrentCulture, tempHomeFolderName, Environment.UserName)); + Directory.CreateDirectory(s_tempHome); + } + catch (UnauthorizedAccessException) + { + // Directory creation may fail if the account doesn't have filesystem permission such as some service accounts. + // Return an empty string in this case so the process working directory will be used. + s_tempHome = string.Empty; + } + + return s_tempHome; + } + private readonly static Version PSVersion6 = new Version(6, 0); private static void GetStandardPlatformPaths( PSCmdlet psCmdlet, - out string myDocumentsPath, - out string programFilesPath) + out string localUserDir, + out string allUsersDir) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { string powerShellType = (psCmdlet.Host.Version >= PSVersion6) ? "PowerShell" : "WindowsPowerShell"; - myDocumentsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); - programFilesPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType); + localUserDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); + allUsersDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType); } else { // paths are the same for both Linux and macOS - myDocumentsPath = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "powershell"); - programFilesPath = System.IO.Path.Combine("/usr", "local", "share", "powershell"); + localUserDir = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share", "powershell"); + // Create the default data directory if it doesn't exist. + if (!Directory.Exists(localUserDir)) { + Directory.CreateDirectory(localUserDir); + } + + allUsersDir = System.IO.Path.Combine("/usr", "local", "share", "powershell"); } } From b22fe33679cd39aa5caee80feff274bb77c6721c Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Fri, 27 Oct 2023 12:15:30 -0700 Subject: [PATCH 007/160] Bug fix for Import-PSGetRepository in Windows PS (#1460) --- src/Microsoft.PowerShell.PSResourceGet.psm1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.PowerShell.PSResourceGet.psm1 b/src/Microsoft.PowerShell.PSResourceGet.psm1 index 02fe8e8d7..aebe4c357 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psm1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psm1 @@ -25,9 +25,11 @@ function Import-PSGetRepository { Microsoft.PowerShell.Utility\Write-Verbose ('Found {0} registered PowerShellGet repositories.' -f $PSGetRepositories.Count) if ($PSGetRepositories.Count) { - $repos = $PSGetRepositories.Values | + $repos = @( + $PSGetRepositories.Values | Microsoft.PowerShell.Core\Where-Object {$_.PackageManagementProvider -eq 'NuGet'-and $_.Name -ne 'PSGallery'} | Microsoft.PowerShell.Utility\Select-Object Name, Trusted, SourceLocation + ) Microsoft.PowerShell.Utility\Write-Verbose ('Selected {0} NuGet repositories.' -f $repos.Count) From 3ace9260fe57cf6f2daf7f81895e554acdc3693c Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Fri, 27 Oct 2023 12:24:17 -0700 Subject: [PATCH 008/160] Update README.md (#1458) --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ab4e8de0a..a2ebe4593 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,10 @@ Important Note If you were familiar with the PowerShellGet 3.0 project, we renamed the module to be PSResourceGet, for more information please read [this blog](https://devblogs.microsoft.com/powershell/powershellget-in-powershell-7-4-updates/). -This version of PSResourceGet is currently under development and is not quite complete. -As a result, we are currently only accepting PRs for tests. If you would like to open a PR please open an issue first so that necessary discussion can take place. Please open an issue for any feature requests, bug reports, or questions for PSResourceGet. -Please note, the repository for PowerShellGet is available at [PowerShell/PowerShellGetv2](https://github.com/PowerShell/PowerShellGetv2). +Please note, the repository for PowerShellGet v2 is available at [PowerShell/PowerShellGetv2](https://github.com/PowerShell/PowerShellGetv2). +The repository for the PowerShellGet v3, the compatibility layer between PowerShellGet v2 and PSResourceGet, is available at [PowerShell/PowerShellGet](https://github.com/PowerShell/PowerShellGet). Introduction ============ From cbac61807e4b294c95c33c1e398affc3125d92b6 Mon Sep 17 00:00:00 2001 From: Kris Borowinski Date: Wed, 20 Dec 2023 03:30:21 +0100 Subject: [PATCH 009/160] Verify whether SourceLocation is a UNC path and select the appropriate ApiVersion (#1479) --- src/Microsoft.PowerShell.PSResourceGet.psm1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.PowerShell.PSResourceGet.psm1 b/src/Microsoft.PowerShell.PSResourceGet.psm1 index aebe4c357..3e477cb1f 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psm1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psm1 @@ -45,7 +45,7 @@ function Import-PSGetRepository { Trusted = $_.Trusted PassThru = $true Force = $Force - ApiVersion = 'v2' + ApiVersion = if ([Uri]::new($_.SourceLocation).Scheme -eq 'file') {'local'} else {'v2'} } Register-PSResourceRepository @registerPSResourceRepositorySplat } From 6932fdaf27d75ab5e452bb7235ef463bfcfc1d8d Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Fri, 22 Dec 2023 10:25:20 -0800 Subject: [PATCH 010/160] Park 1 of ACR work integration and refactor (#1495) --- src/code/ACRResponseUtil.cs | 92 ++ src/code/ACRServerAPICalls.cs | 957 ++++++++++++++++++ src/code/PSRepositoryInfo.cs | 16 + src/code/PublishPSResource.cs | 26 +- src/code/RepositorySettings.cs | 24 + src/code/UninstallPSResource.cs | 6 +- src/code/Utils.cs | 242 +++++ .../FindPSResourceADOServer.Tests.ps1 | 3 +- .../PublishPSResourceADOServer.Tests.ps1 | 2 +- 9 files changed, 1358 insertions(+), 10 deletions(-) create mode 100644 src/code/ACRResponseUtil.cs create mode 100644 src/code/ACRServerAPICalls.cs diff --git a/src/code/ACRResponseUtil.cs b/src/code/ACRResponseUtil.cs new file mode 100644 index 000000000..ce09515cb --- /dev/null +++ b/src/code/ACRResponseUtil.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using System; +using System.Collections.Generic; +using System.Xml; + +namespace Microsoft.PowerShell.PSResourceGet.Cmdlets +{ + internal class ACRResponseUtil : ResponseUtil + { + #region Members + + internal override PSRepositoryInfo Repository { get; set; } + + #endregion + + #region Constructor + + public ACRResponseUtil(PSRepositoryInfo repository) : base(repository) + { + Repository = repository; + } + + #endregion + + #region Overriden Methods + public override IEnumerable ConvertToPSResourceResult(FindResults responseResults) + { + // in FindHelper: + // serverApi.FindName() -> return responses, and out errRecord + // check outErrorRecord + // + // v2Converter.ConvertToPSResourceInfo(responses) -> return PSResourceResult + // check resourceResult for error, write if needed + string[] responses = responseResults.StringResponse; + + foreach (string response in responses) + { + var elemList = ConvertResponseToXML(response); + if (elemList.Length == 0) + { + // this indicates we got a non-empty, XML response (as noticed for V2 server) but it's not a response that's meaningful (contains 'properties') + Exception notFoundException = new ResourceNotFoundException("Package does not exist on the server"); + + yield return new PSResourceResult(returnedObject: null, exception: notFoundException, isTerminatingError: false); + } + + foreach (var element in elemList) + { + if (!PSResourceInfo.TryConvertFromXml(element, out PSResourceInfo psGetInfo, Repository, out string errorMsg)) + { + Exception parseException = new XmlParsingException(errorMsg); + + yield return new PSResourceResult(returnedObject: null, exception: parseException, isTerminatingError: false); + } + + // Unlisted versions will have a published year as 1900 or earlier. + if (!psGetInfo.PublishedDate.HasValue || psGetInfo.PublishedDate.Value.Year > 1900) + { + yield return new PSResourceResult(returnedObject: psGetInfo, exception: null, isTerminatingError: false); + } + } + } + } + + #endregion + + #region V2 Specific Methods + + public XmlNode[] ConvertResponseToXML(string httpResponse) + { + + //Create the XmlDocument. + XmlDocument doc = new XmlDocument(); + doc.LoadXml(httpResponse); + + XmlNodeList elemList = doc.GetElementsByTagName("m:properties"); + + XmlNode[] nodes = new XmlNode[elemList.Count]; + for (int i = 0; i < elemList.Count; i++) + { + nodes[i] = elemList[i]; + } + + return nodes; + } + + #endregion + } +} diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs new file mode 100644 index 000000000..6582c2fd6 --- /dev/null +++ b/src/code/ACRServerAPICalls.cs @@ -0,0 +1,957 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using NuGet.Versioning; +using System.Threading.Tasks; +using System.Net; +using System.Management.Automation; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; +using System.Collections.ObjectModel; +using System.Net.Http.Headers; +using System.Linq; +using Microsoft.PowerShell.PSResourceGet.Cmdlets; +using System.Text; +using System.Security.Cryptography; + +namespace Microsoft.PowerShell.PSResourceGet +{ + internal class ACRServerAPICalls : ServerApiCall + { + // Any interface method that is not implemented here should be processed in the parent method and then call one of the implemented + // methods below. + #region Members + + public override PSRepositoryInfo Repository { get; set; } + private readonly PSCmdlet _cmdletPassedIn; + private HttpClient _sessionClient { get; set; } + private static readonly Hashtable[] emptyHashResponses = new Hashtable[] { }; + public FindResponseType v3FindResponseType = FindResponseType.ResponseString; + + const string acrRefreshTokenTemplate = "grant_type=access_token&service={0}&tenant={1}&access_token={2}"; // 0 - registry, 1 - tenant, 2 - access token + const string acrAccessTokenTemplate = "grant_type=refresh_token&service={0}&scope=repository:*:*&refresh_token={1}"; // 0 - registry, 1 - refresh token + const string acrOAuthExchangeUrlTemplate = "https://{0}/oauth2/exchange"; // 0 - registry + const string acrOAuthTokenUrlTemplate = "https://{0}/oauth2/token"; // 0 - registry + const string acrManifestUrlTemplate = "https://{0}/v2/{1}/manifests/{2}"; // 0 - registry, 1 - repo(modulename), 2 - tag(version) + const string acrBlobDownloadUrlTemplate = "https://{0}/v2/{1}/blobs/{2}"; // 0 - registry, 1 - repo(modulename), 2 - layer digest + const string acrFindImageVersionUrlTemplate = "https://{0}/acr/v1/{1}/_tags{2}"; // 0 - registry, 1 - repo(modulename), 2 - /tag(version) + const string acrStartUploadTemplate = "https://{0}/v2/{1}/blobs/uploads/"; // 0 - registry, 1 - packagename + const string acrEndUploadTemplate = "https://{0}{1}&digest=sha256:{2}"; // 0 - registry, 1 - location, 2 - digest + + private static readonly HttpClient s_client = new HttpClient(); + + #endregion + + #region Constructor + + public ACRServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, NetworkCredential networkCredential, string userAgentString) : base(repository, networkCredential) + { + Repository = repository; + _cmdletPassedIn = cmdletPassedIn; + HttpClientHandler handler = new HttpClientHandler() + { + Credentials = networkCredential + }; + + _sessionClient = new HttpClient(handler); + _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); + var repoURL = repository.Uri.ToString().ToLower(); + } + + #endregion + + #region Overriden Methods + + /// + /// Find method which allows for searching for all packages from a repository and returns latest version for each. + /// Examples: Search -Repository PSGallery + /// API call: + /// - No prerelease: http://www.powershellgallery.com/api/v2/Search()?$filter=IsLatestVersion + /// + public override FindResults FindAll(bool includePrerelease, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindAll()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find all is not supported for the ACR server protocol repository '{Repository.Name}'"), + "FindAllFailure", + ErrorCategory.InvalidOperation, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + } + + /// + /// Find method which allows for searching for packages with tag from a repository and returns latest version for each. + /// Examples: Search -Tag "JSON" -Repository PSGallery + /// API call: + /// - Include prerelease: https://www.powershellgallery.com/api/v2/Search()?includePrerelease=true&$filter=IsAbsoluteLatestVersion and substringof('PSModule', Tags) eq true and substringof('CrescendoBuilt', Tags) eq true&$orderby=Id desc&$inlinecount=allpages&$skip=0&$top=6000 + /// + public override FindResults FindTags(string[] tags, bool includePrerelease, ResourceType _type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindTags()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find tags is not supported for the ACR server protocol repository '{Repository.Name}'"), + "FindTagsFailure", + ErrorCategory.InvalidOperation, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + } + + /// + /// Find method which allows for searching for all packages that have specified Command or DSCResource name. + /// + public override FindResults FindCommandOrDscResource(string[] tags, bool includePrerelease, bool isSearchingForCommands, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindCommandOrDscResource()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find Command or DSC Resource is not supported for the ACR server protocol repository '{Repository.Name}'"), + "FindCommandOrDscResourceFailure", + ErrorCategory.InvalidOperation, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + } + + /// + /// Find method which allows for searching for single name and returns latest version. + /// Name: no wildcard support + /// Examples: Search "PowerShellGet" + /// API call: + /// - No prerelease: http://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet' + /// - Include prerelease: http://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet' + /// Implementation Note: Need to filter further for latest version (prerelease or non-prerelease dependening on user preference) + /// + public override FindResults FindName(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindName()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find name is not supported for the ACR server protocol repository '{Repository.Name}'"), + "FindNameFailure", + ErrorCategory.InvalidOperation, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + } + + /// + /// Find method which allows for searching for single name and tag and returns latest version. + /// Name: no wildcard support + /// Examples: Search "PowerShellGet" -Tag "Provider" + /// Implementation Note: Need to filter further for latest version (prerelease or non-prerelease dependening on user preference) + /// + public override FindResults FindNameWithTag(string packageName, string[] tags, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindNameWithTag()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find name with tag(s) is not supported for the ACR server protocol repository '{Repository.Name}'"), + "FindNameWithTagFailure", + ErrorCategory.InvalidOperation, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + + } + + /// + /// Find method which allows for searching for single name with wildcards and returns latest version. + /// Name: supports wildcards + /// Examples: Search "PowerShell*" + /// API call: + /// - No prerelease: http://www.powershellgallery.com/api/v2/Search()?$filter=IsLatestVersion&searchTerm='az*' + /// Implementation Note: filter additionally and verify ONLY package name was a match. + /// + public override FindResults FindNameGlobbing(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindNameGlobbing()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"FindNameGlobbing all is not supported for the ACR server protocol repository '{Repository.Name}'"), + "FindNameGlobbingFailure", + ErrorCategory.InvalidOperation, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + } + + /// + /// Find method which allows for searching for single name with wildcards and tag and returns latest version. + /// Name: supports wildcards + /// Examples: Search "PowerShell*" -Tag "Provider" + /// Implementation Note: filter additionally and verify ONLY package name was a match. + /// + public override FindResults FindNameGlobbingWithTag(string packageName, string[] tags, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindNameGlobbingWithTag()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find name globbing with tag(s) is not supported for the ACR server protocol repository '{Repository.Name}'"), + "FindNameGlobbingWithTagFailure", + ErrorCategory.InvalidOperation, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + } + + /// + /// Find method which allows for searching for single name with version range. + /// Name: no wildcard support + /// Version: supports wildcards + /// Examples: Search "PowerShellGet" "[3.0.0.0, 5.0.0.0]" + /// Search "PowerShellGet" "3.*" + /// API Call: http://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet' + /// Implementation note: Returns all versions, including prerelease ones. Later (in the API client side) we'll do filtering on the versions to satisfy what user provided. + /// + public override FindResults FindVersionGlobbing(string packageName, VersionRange versionRange, bool includePrerelease, ResourceType type, bool getOnlyLatest, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindVersionGlobbing()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find version globbing is not supported for the ACR server protocol repository '{Repository.Name}'"), + "FindVersionGlobbingFailure", + ErrorCategory.InvalidOperation, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + } + + /// + /// Find method which allows for searching for single name with specific version. + /// Name: no wildcard support + /// Version: no wildcard support + /// Examples: Search "PowerShellGet" "2.2.5" + /// API call: http://www.powershellgallery.com/api/v2/Packages(Id='PowerShellGet', Version='2.2.5') + /// + public override FindResults FindVersion(string packageName, string version, ResourceType type, out ErrorRecord errRecord) + { + + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindVersion()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find version is not supported for the ACR server protocol repository '{Repository.Name}'"), + "FindVersionFailure", + ErrorCategory.InvalidOperation, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + } + + /// + /// Find method which allows for searching for single name with specific version and tag. + /// Name: no wildcard support + /// Version: no wildcard support + /// Examples: Search "PowerShellGet" "2.2.5" -Tag "Provider" + /// + public override FindResults FindVersionWithTag(string packageName, string version, string[] tags, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindVersionWithTag()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find version with tag(s) is not supported for the ACR server protocol repository '{Repository.Name}'"), + "FindVersionWithTagFailure", + ErrorCategory.InvalidOperation, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + } + + /** INSTALL APIS **/ + + /// + /// Installs a specific package. + /// Name: no wildcard support. + /// Examples: Install "PowerShellGet" + /// Install "PowerShellGet" -Version "3.0.0" + /// + public override Stream InstallPackage(string packageName, string packageVersion, bool includePrerelease, out ErrorRecord errRecord) + { + Stream results = new MemoryStream(); + errRecord = null; + + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::InstallPackage()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Install is not supported for the ACR server protocol repository '{Repository.Name}'"), + "InstallFailure", + ErrorCategory.InvalidOperation, + this); + + return results; + } + + /// + /// Helper method that makes the HTTP request for the V2 server protocol url passed in for find APIs. + /// + private string HttpRequestCall(string requestUrlV2, out ErrorRecord errRecord) + { + string response = string.Empty; + errRecord = null; + + return response; + } + + /// + /// Helper method that makes the HTTP request for the V2 server protocol url passed in for install APIs. + /// + private HttpContent HttpRequestCallForContent(string requestUrlV2, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::HttpRequestCallForContent()"); + errRecord = null; + HttpContent content = null; + + return content; + } + + + internal static PSResourceInfo Install( + PSRepositoryInfo repo, + string moduleName, + string moduleVersion, + bool savePkg, + bool asZip, + List installPath, + PSCmdlet callingCmdlet) + { + string accessToken = string.Empty; + string tenantID = string.Empty; + string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempPath); + + // Need to set up secret management vault before hand + var repositoryCredentialInfo = repo.CredentialInfo; + if (repositoryCredentialInfo != null) + { + accessToken = Utils.GetACRAccessTokenFromSecretManagement( + repo.Name, + repositoryCredentialInfo, + callingCmdlet); + + callingCmdlet.WriteVerbose("Access token retrieved."); + + tenantID = Utils.GetSecretInfoFromSecretManagement( + repo.Name, + repositoryCredentialInfo, + callingCmdlet); + } + + // Call asynchronous network methods in a try/catch block to handle exceptions. + string registry = repo.Uri.Host; + + callingCmdlet.WriteVerbose("Getting acr refresh token"); + var acrRefreshToken = GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; + callingCmdlet.WriteVerbose("Getting acr access token"); + var acrAccessToken = GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; + callingCmdlet.WriteVerbose($"Getting manifest for {moduleName} - {moduleVersion}"); + var manifest = GetAcrRepositoryManifestAsync(registry, moduleName, moduleVersion, acrAccessToken).Result; + var digest = manifest["layers"].FirstOrDefault()["digest"].ToString(); + callingCmdlet.WriteVerbose($"Downloading blob for {moduleName} - {moduleVersion}"); + var responseContent = GetAcrBlobAsync(registry, moduleName, digest, acrAccessToken).Result; + + callingCmdlet.WriteVerbose($"Writing module zip to temp path: {tempPath}"); + + // download the module + var pathToFile = Path.Combine(tempPath, $"{moduleName}.{moduleVersion}.zip"); + using var content = responseContent.ReadAsStreamAsync().Result; + using var fs = File.Create(pathToFile); + content.Seek(0, SeekOrigin.Begin); + content.CopyTo(fs); + fs.Close(); + + PSResourceInfo pkgInfo = null; + /* + var pkgInfo = new PSResourceInfo( + additionalMetadata: new Hashtable { }, + author: string.Empty, + companyName: string.Empty, + copyright: string.Empty, + dependencies: new Dependency[] { }, + description: string.Empty, + iconUri: string.Empty, + includes: new ResourceIncludes(), + installedDate: null, + installedLocation: null, + isPrerelease: false, + licenseUri: string.Empty, + name: moduleName, + powershellGetFormatVersion: null, + prerelease: string.Empty, + projectUri: string.Empty, + publishedDate: null, + releaseNotes: string.Empty, + repository: string.Empty, + repositorySourceLocation: repo.Name, + tags: new string[] { }, + type: ResourceType.Module, + updatedDate: null, + version: moduleVersion); + */ + + // If saving the package as a zip + if (savePkg && asZip) + { + // Just move to the zip to the proper path + Utils.MoveFiles(pathToFile, Path.Combine(installPath.FirstOrDefault(), $"{moduleName}.{moduleVersion}.zip")); + + } + // If saving the package and unpacking OR installing the package + else + { + string expandedPath = Path.Combine(tempPath, moduleName.ToLower(), moduleVersion); + Directory.CreateDirectory(expandedPath); + callingCmdlet.WriteVerbose($"Expanding module to temp path: {expandedPath}"); + // Expand the zip file + System.IO.Compression.ZipFile.ExtractToDirectory(pathToFile, expandedPath); + Utils.DeleteExtraneousFiles(callingCmdlet, moduleName, expandedPath); + + callingCmdlet.WriteVerbose("Expanding completed"); + File.Delete(pathToFile); + + Utils.MoveFilesIntoInstallPath( + pkgInfo, + isModule: true, + isLocalRepo: false, + savePkg, + moduleVersion, + tempPath, + installPath.FirstOrDefault(), + moduleVersion, + moduleVersion, + scriptPath: null, + callingCmdlet); + + if (Directory.Exists(tempPath)) + { + try + { + Utils.DeleteDirectory(tempPath); + callingCmdlet.WriteVerbose(string.Format("Successfully deleted '{0}'", tempPath)); + } + catch (Exception e) + { + ErrorRecord TempDirCouldNotBeDeletedError = new ErrorRecord(e, "errorDeletingTempInstallPath", ErrorCategory.InvalidResult, null); + callingCmdlet.WriteError(TempDirCouldNotBeDeletedError); + } + } + } + + return pkgInfo; + } + + + #endregion + + internal static List Find(PSRepositoryInfo repo, string pkgName, string pkgVersion, PSCmdlet callingCmdlet) + { + List foundPkgs = new List(); + string accessToken = string.Empty; + string tenantID = string.Empty; + + // Need to set up secret management vault before hand + var repositoryCredentialInfo = repo.CredentialInfo; + if (repositoryCredentialInfo != null) + { + accessToken = Utils.GetACRAccessTokenFromSecretManagement( + repo.Name, + repositoryCredentialInfo, + callingCmdlet); + + callingCmdlet.WriteVerbose("Access token retrieved."); + + tenantID = Utils.GetSecretInfoFromSecretManagement( + repo.Name, + repositoryCredentialInfo, + callingCmdlet); + } + + // Call asynchronous network methods in a try/catch block to handle exceptions. + string registry = repo.Uri.Host; + + callingCmdlet.WriteVerbose("Getting acr refresh token"); + var acrRefreshToken = GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; + callingCmdlet.WriteVerbose("Getting acr access token"); + var acrAccessToken = GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; + + callingCmdlet.WriteVerbose("Getting tags"); + var foundTags = FindAcrImageTags(registry, pkgName, pkgVersion, acrAccessToken).Result; + + if (foundTags != null) + { + if (string.Equals(pkgVersion, "*", StringComparison.OrdinalIgnoreCase)) + { + foreach (var item in foundTags["tags"]) + { + // digest: {item["digest"]"; + string tagVersion = item["name"].ToString(); + + /* + foundPkgs.Add(new PSResourceInfo(name: pkgName, version: tagVersion, repository: repo.Name)); + */ + } + } + else + { + // pkgVersion was used in the API call (same as foundTags["name"]) + // digest: foundTags["tag"]["digest"]"; + /* + foundPkgs.Add(new PSResourceInfo(name: pkgName, version: pkgVersion, repository: repo.Name)); + */ + } + } + + return foundPkgs; + } + + #region Private Methods + internal static async Task GetAcrRefreshTokenAsync(string registry, string tenant, string accessToken) + { + string content = string.Format(acrRefreshTokenTemplate, registry, tenant, accessToken); + var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; + string exchangeUrl = string.Format(acrOAuthExchangeUrlTemplate, registry); + return (await GetHttpResponseJObject(exchangeUrl, HttpMethod.Post, content, contentHeaders))["refresh_token"].ToString(); + } + + internal static async Task GetAcrAccessTokenAsync(string registry, string refreshToken) + { + string content = string.Format(acrAccessTokenTemplate, registry, refreshToken); + var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; + string tokenUrl = string.Format(acrOAuthTokenUrlTemplate, registry); + return (await GetHttpResponseJObject(tokenUrl, HttpMethod.Post, content, contentHeaders))["access_token"].ToString(); + } + + internal static async Task GetAcrRepositoryManifestAsync(string registry, string repositoryName, string version, string acrAccessToken) + { + string manifestUrl = string.Format(acrManifestUrlTemplate, registry, repositoryName, version); + var defaultHeaders = GetDefaultHeaders(acrAccessToken); + return await GetHttpResponseJObject(manifestUrl, HttpMethod.Get, defaultHeaders); + } + + internal static async Task GetAcrBlobAsync(string registry, string repositoryName, string digest, string acrAccessToken) + { + string blobUrl = string.Format(acrBlobDownloadUrlTemplate, registry, repositoryName, digest); + var defaultHeaders = GetDefaultHeaders(acrAccessToken); + return await GetHttpContentResponseJObject(blobUrl, defaultHeaders); + } + + internal static async Task FindAcrImageTags(string registry, string repositoryName, string version, string acrAccessToken) + { + try + { + string resolvedVersion = string.Equals(version, "*", StringComparison.OrdinalIgnoreCase) ? null : $"/{version}"; + string findImageUrl = string.Format(acrFindImageVersionUrlTemplate, registry, repositoryName, resolvedVersion); + var defaultHeaders = GetDefaultHeaders(acrAccessToken); + return await GetHttpResponseJObject(findImageUrl, HttpMethod.Get, defaultHeaders); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error finding ACR artifact: " + e.Message); + } + } + + internal static async Task GetStartUploadBlobLocation(string registry, string pkgName, string acrAccessToken) + { + try + { + var defaultHeaders = GetDefaultHeaders(acrAccessToken); + var startUploadUrl = string.Format(acrStartUploadTemplate, registry, pkgName); + return (await GetHttpResponseHeader(startUploadUrl, HttpMethod.Post, defaultHeaders)).Location.ToString(); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error starting publishing to ACR: " + e.Message); + } + } + + internal static async Task EndUploadBlob(string registry, string location, string filePath, string digest, bool isManifest, string acrAccessToken) + { + try + { + var endUploadUrl = string.Format(acrEndUploadTemplate, registry, location, digest); + var defaultHeaders = GetDefaultHeaders(acrAccessToken); + return await PutRequestAsync(endUploadUrl, filePath, isManifest, defaultHeaders); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to uploading module to ACR: " + e.Message); + } + } + + internal static async Task CreateManifest(string registry, string pkgName, string pkgVersion, string configPath, bool isManifest, string acrAccessToken) + { + try + { + var createManifestUrl = string.Format(acrManifestUrlTemplate, registry, pkgName, pkgVersion); + var defaultHeaders = GetDefaultHeaders(acrAccessToken); + return await PutRequestAsync(createManifestUrl, configPath, isManifest, defaultHeaders); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to create manifest: " + e.Message); + } + } + + internal static async Task GetHttpContentResponseJObject(string url, Collection> defaultHeaders) + { + try + { + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url); + SetDefaultHeaders(defaultHeaders); + return await SendContentRequestAsync(request); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + } + } + + internal static async Task GetHttpResponseJObject(string url, HttpMethod method, Collection> defaultHeaders) + { + try + { + HttpRequestMessage request = new HttpRequestMessage(method, url); + SetDefaultHeaders(defaultHeaders); + return await SendRequestAsync(request); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + } + } + + internal static async Task GetHttpResponseJObject(string url, HttpMethod method, string content, Collection> contentHeaders) + { + try + { + HttpRequestMessage request = new HttpRequestMessage(method, url); + + if (string.IsNullOrEmpty(content)) + { + throw new ArgumentNullException("content"); + } + + request.Content = new StringContent(content); + request.Content.Headers.Clear(); + if (contentHeaders != null) + { + foreach (var header in contentHeaders) + { + request.Content.Headers.Add(header.Key, header.Value); + } + } + + return await SendRequestAsync(request); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + } + } + + internal static async Task GetHttpResponseHeader(string url, HttpMethod method, Collection> defaultHeaders) + { + try + { + HttpRequestMessage request = new HttpRequestMessage(method, url); + SetDefaultHeaders(defaultHeaders); + return await SendRequestHeaderAsync(request); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to retrieve response header: " + e.Message); + } + } + + private static void SetDefaultHeaders(Collection> defaultHeaders) + { + s_client.DefaultRequestHeaders.Clear(); + if (defaultHeaders != null) + { + foreach (var header in defaultHeaders) + { + if (string.Equals(header.Key, "Authorization", StringComparison.OrdinalIgnoreCase)) + { + s_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", header.Value); + } + else if (string.Equals(header.Key, "Accept", StringComparison.OrdinalIgnoreCase)) + { + s_client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(header.Value)); + } + else + { + s_client.DefaultRequestHeaders.Add(header.Key, header.Value); + } + } + } + } + + private static async Task SendContentRequestAsync(HttpRequestMessage message) + { + try + { + HttpResponseMessage response = await s_client.SendAsync(message); + response.EnsureSuccessStatusCode(); + return response.Content; + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + } + } + + private static async Task SendRequestAsync(HttpRequestMessage message) + { + try + { + HttpResponseMessage response = await s_client.SendAsync(message); + response.EnsureSuccessStatusCode(); + return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + } + } + + private static async Task SendRequestHeaderAsync(HttpRequestMessage message) + { + try + { + HttpResponseMessage response = await s_client.SendAsync(message); + response.EnsureSuccessStatusCode(); + return response.Headers; + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + } + } + + private static async Task PutRequestAsync(string url, string filePath, bool isManifest, Collection> contentHeaders) + { + try + { + SetDefaultHeaders(contentHeaders); + + FileInfo fileInfo = new FileInfo(filePath); + FileStream fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read); + HttpContent httpContent = new StreamContent(fileStream); + if (isManifest) + { + httpContent.Headers.Add("Content-Type", "application/vnd.oci.image.manifest.v1+json"); + } + else + { + httpContent.Headers.Add("Content-Type", "application/octet-stream"); + } + + HttpResponseMessage response = await s_client.PutAsync(url, httpContent); + response.EnsureSuccessStatusCode(); + fileStream.Close(); + return response.IsSuccessStatusCode; + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to uploading module to ACR: " + e.Message); + } + + } + + private static Collection> GetDefaultHeaders(string acrAccessToken) + { + return new Collection> { + new KeyValuePair("Authorization", acrAccessToken), + new KeyValuePair("Accept", "application/vnd.oci.image.manifest.v1+json") + }; + } + + private bool PushNupkgACR(string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, out ErrorRecord error) + { + error = null; + // Push the nupkg to the appropriate repository + var fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, pkgName + "." + pkgVersion.ToNormalizedString() + ".nupkg"); + + string accessToken = string.Empty; + string tenantID = string.Empty; + + // Need to set up secret management vault before hand + var repositoryCredentialInfo = repository.CredentialInfo; + if (repositoryCredentialInfo != null) + { + accessToken = Utils.GetACRAccessTokenFromSecretManagement( + repository.Name, + repositoryCredentialInfo, + _cmdletPassedIn); + + _cmdletPassedIn.WriteVerbose("Access token retrieved."); + + tenantID = Utils.GetSecretInfoFromSecretManagement( + repository.Name, + repositoryCredentialInfo, + _cmdletPassedIn); + } + + // Call asynchronous network methods in a try/catch block to handle exceptions. + string registry = repository.Uri.Host; + + _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); + var acrRefreshToken = GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; + _cmdletPassedIn.WriteVerbose("Getting acr access token"); + var acrAccessToken = GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; + + _cmdletPassedIn.WriteVerbose("Start uploading blob"); + var moduleLocation = GetStartUploadBlobLocation(registry, pkgName, acrAccessToken).Result; + + _cmdletPassedIn.WriteVerbose("Computing digest for .nupkg file"); + bool digestCreated = CreateDigest(fullNupkgFile, out string digest, out ErrorRecord digestError); + if (!digestCreated) + { + _cmdletPassedIn.ThrowTerminatingError(digestError); + } + + _cmdletPassedIn.WriteVerbose("Finish uploading blob"); + bool moduleUploadSuccess = EndUploadBlob(registry, moduleLocation, fullNupkgFile, digest, false, acrAccessToken).Result; + + _cmdletPassedIn.WriteVerbose("Create an empty file"); + string emptyFileName = "empty.txt"; + var emptyFilePath = System.IO.Path.Combine(outputNupkgDir, emptyFileName); + // Rename the empty file in case such a file already exists in the temp folder (although highly unlikely) + while (File.Exists(emptyFilePath)) + { + emptyFilePath = Guid.NewGuid().ToString() + ".txt"; + } + FileStream emptyStream = File.Create(emptyFilePath); + emptyStream.Close(); + + _cmdletPassedIn.WriteVerbose("Start uploading an empty file"); + var emptyLocation = GetStartUploadBlobLocation(registry, pkgName, acrAccessToken).Result; + + _cmdletPassedIn.WriteVerbose("Computing digest for empty file"); + bool emptyDigestCreated = CreateDigest(emptyFilePath, out string emptyDigest, out ErrorRecord emptyDigestError); + if (!emptyDigestCreated) + { + _cmdletPassedIn.ThrowTerminatingError(emptyDigestError); + } + + _cmdletPassedIn.WriteVerbose("Finish uploading empty file"); + bool emptyFileUploadSuccess = EndUploadBlob(registry, emptyLocation, emptyFilePath, emptyDigest, false, acrAccessToken).Result; + + _cmdletPassedIn.WriteVerbose("Create the config file"); + string configFileName = "config.json"; + var configFilePath = System.IO.Path.Combine(outputNupkgDir, configFileName); + while (File.Exists(configFilePath)) + { + configFilePath = Guid.NewGuid().ToString() + ".json"; + } + FileStream configStream = File.Create(configFilePath); + configStream.Close(); + + FileInfo nupkgFile = new FileInfo(fullNupkgFile); + var fileSize = nupkgFile.Length; + var fileName = System.IO.Path.GetFileName(fullNupkgFile); + string fileContent = CreateJsonContent(digest, emptyDigest, fileSize, fileName); + File.WriteAllText(configFilePath, fileContent); + + _cmdletPassedIn.WriteVerbose("Create the manifest layer"); + bool manifestCreated = CreateManifest(registry, pkgName, pkgVersion.OriginalVersion, configFilePath, true, acrAccessToken).Result; + + if (manifestCreated) + { + return true; + } + return false; + } + + private string CreateJsonContent(string digest, string emptyDigest, long fileSize, string fileName) + { + StringBuilder stringBuilder = new StringBuilder(); + StringWriter stringWriter = new StringWriter(stringBuilder); + JsonTextWriter jsonWriter = new JsonTextWriter(stringWriter); + + jsonWriter.Formatting = Newtonsoft.Json.Formatting.Indented; + + jsonWriter.WriteStartObject(); + + jsonWriter.WritePropertyName("schemaVersion"); + jsonWriter.WriteValue(2); + + jsonWriter.WritePropertyName("config"); + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("mediaType"); + jsonWriter.WriteValue("application/vnd.unknown.config.v1+json"); + jsonWriter.WritePropertyName("digest"); + jsonWriter.WriteValue($"sha256:{emptyDigest}"); + jsonWriter.WritePropertyName("size"); + jsonWriter.WriteValue(0); + jsonWriter.WriteEndObject(); + + jsonWriter.WritePropertyName("layers"); + jsonWriter.WriteStartArray(); + jsonWriter.WriteStartObject(); + + jsonWriter.WritePropertyName("mediaType"); + jsonWriter.WriteValue("application/vnd.oci.image.layer.nondistributable.v1.tar+gzip'"); + jsonWriter.WritePropertyName("digest"); + jsonWriter.WriteValue($"sha256:{digest}"); + jsonWriter.WritePropertyName("size"); + jsonWriter.WriteValue(fileSize); + jsonWriter.WritePropertyName("annotations"); + + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("org.opencontainers.image.title"); + jsonWriter.WriteValue(fileName); + jsonWriter.WriteEndObject(); + + jsonWriter.WriteEndObject(); + jsonWriter.WriteEndArray(); + + jsonWriter.WriteEndObject(); + + return stringWriter.ToString(); + } + + + + // ACR method + private bool CreateDigest(string fileName, out string digest, out ErrorRecord error) + { + FileInfo fileInfo = new FileInfo(fileName); + SHA256 mySHA256 = SHA256.Create(); + FileStream fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read); + digest = string.Empty; + + try + { + // Create a fileStream for the file. + // Be sure it's positioned to the beginning of the stream. + fileStream.Position = 0; + // Compute the hash of the fileStream. + byte[] hashValue = mySHA256.ComputeHash(fileStream); + StringBuilder stringBuilder = new StringBuilder(); + foreach (byte b in hashValue) + stringBuilder.AppendFormat("{0:x2}", b); + digest = stringBuilder.ToString(); + // Write the name and hash value of the file to the console. + _cmdletPassedIn.WriteVerbose($"{fileInfo.Name}: {hashValue}"); + error = null; + } + catch (IOException ex) + { + var IOError = new ErrorRecord(ex, $"IOException for .nupkg file: {ex.Message}", ErrorCategory.InvalidOperation, null); + error = IOError; + } + catch (UnauthorizedAccessException ex) + { + var AuthorizationError = new ErrorRecord(ex, $"UnauthorizedAccessException for .nupkg file: {ex.Message}", ErrorCategory.PermissionDenied, null); + error = AuthorizationError; + } + + fileStream.Close(); + if (error != null) + { + return false; + } + return true; + } + + #endregion + } +} diff --git a/src/code/PSRepositoryInfo.cs b/src/code/PSRepositoryInfo.cs index 10bab5baf..2ab93b192 100644 --- a/src/code/PSRepositoryInfo.cs +++ b/src/code/PSRepositoryInfo.cs @@ -38,6 +38,17 @@ public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCred #endregion + #region Enum + + public enum RepositoryProviderType + { + None, + ACR, + AzureDevOps + } + + #endregion + #region Properties /// @@ -61,6 +72,11 @@ public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCred [ValidateRange(0, 100)] public int Priority { get; } + /// + /// the type of repository provider (eg, AzureDevOps, ACR, etc.) + /// + public RepositoryProviderType RepositoryProvider { get; } + /// /// the credential information for repository authentication /// diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index e6447e1b3..bd132fc6c 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -15,6 +15,8 @@ using System.Management.Automation; using System.Net; using System.Net.Http; +using System.Security.Cryptography; +using System.Text; using System.Threading; using System.Xml; @@ -480,12 +482,26 @@ out string[] _ string repositoryUri = repository.Uri.AbsoluteUri; - // This call does not throw any exceptions, but it will write unsuccessful responses to the console - if (!PushNupkg(outputNupkgDir, repository.Name, repositoryUri, out ErrorRecord pushNupkgError)) + if (repository.RepositoryProvider == PSRepositoryInfo.RepositoryProviderType.ACR) { - WriteError(pushNupkgError); - // exit out of processing - return; + // TODO: Create instance of ACR server class and call PushNupkgACR + /* + if (!PushNupkgACR(outputNupkgDir, repository, out ErrorRecord pushNupkgACRError)) + { + WriteError(pushNupkgACRError); + return; + } + */ + } + else + { + // This call does not throw any exceptions, but it will write unsuccessful responses to the console + if (!PushNupkg(outputNupkgDir, repository.Name, repository.Uri.ToString(), out ErrorRecord pushNupkgError)) + { + WriteError(pushNupkgError); + // exit out of processing + return; + } } } finally diff --git a/src/code/RepositorySettings.cs b/src/code/RepositorySettings.cs index 495483d61..1ba44e8d4 100644 --- a/src/code/RepositorySettings.cs +++ b/src/code/RepositorySettings.cs @@ -7,8 +7,11 @@ using System.IO; using System.Linq; using System.Management.Automation; +using System.Security.Cryptography; +using System.Text; using System.Xml; using System.Xml.Linq; +using static Microsoft.PowerShell.PSResourceGet.UtilClasses.PSRepositoryInfo; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses { @@ -435,6 +438,7 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio node.Attribute(PSCredentialInfo.SecretNameAttribute).Value); } + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); updatedRepo = new PSRepositoryInfo(repoName, thisUrl, Int32.Parse(node.Attribute("Priority").Value), @@ -519,6 +523,8 @@ public static List Remove(string[] repoNames, out string[] err } string attributeUrlUriName = urlAttributeExists ? "Url" : "Uri"; + Uri repoUri = new Uri(node.Attribute(attributeUrlUriName).Value); + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(repoUri); removedRepos.Add( new PSRepositoryInfo(repo, new Uri(node.Attribute(attributeUrlUriName).Value), @@ -649,6 +655,7 @@ public static List Read(string[] repoNames, out string[] error continue; } + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); PSRepositoryInfo currentRepoItem = new PSRepositoryInfo(repo.Attribute("Name").Value, thisUrl, Int32.Parse(repo.Attribute("Priority").Value), @@ -752,6 +759,7 @@ public static List Read(string[] repoNames, out string[] error continue; } + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); PSRepositoryInfo currentRepoItem = new PSRepositoryInfo(node.Attribute("Name").Value, thisUrl, Int32.Parse(node.Attribute("Priority").Value), @@ -838,6 +846,22 @@ private static PSRepositoryInfo.APIVersion GetRepoAPIVersion(Uri repoUri) } } + private static RepositoryProviderType GetRepositoryProviderType(Uri repoUri) + { + string absoluteUri = repoUri.AbsoluteUri; + // We want to use contains instead of EndsWith to accomodate for trailing '/' + if (absoluteUri.Contains("azurecr.io")){ + return RepositoryProviderType.ACR; + } + // TODO: add a regex for this match + // eg: *pkgs.*/_packaging/* + else if (absoluteUri.Contains("pkgs.")){ + return RepositoryProviderType.AzureDevOps; + } + else { + return RepositoryProviderType.None; + } + } #endregion } } diff --git a/src/code/UninstallPSResource.cs b/src/code/UninstallPSResource.cs index 2769cd713..4d7862fbb 100644 --- a/src/code/UninstallPSResource.cs +++ b/src/code/UninstallPSResource.cs @@ -263,7 +263,7 @@ private bool UninstallPkgHelper(out List errRecords) /* uninstalls a module */ private bool UninstallModuleHelper(string pkgPath, string pkgName, out ErrorRecord errRecord) - { + { WriteDebug("In UninstallPSResource::UninstallModuleHelper"); errRecord = null; var successfullyUninstalledPkg = false; @@ -324,7 +324,7 @@ private bool UninstallModuleHelper(string pkgPath, string pkgName, out ErrorReco /* uninstalls a script */ private bool UninstallScriptHelper(string pkgPath, string pkgName, out ErrorRecord errRecord) - { + { WriteDebug("In UninstallPSResource::UninstallScriptHelper"); errRecord = null; var successfullyUninstalledPkg = false; @@ -375,7 +375,7 @@ private bool UninstallScriptHelper(string pkgPath, string pkgName, out ErrorReco } private bool CheckIfDependency(string pkgName, string version, out ErrorRecord errorRecord) - { + { WriteDebug("In UninstallPSResource::CheckIfDependency"); // Checking if a specific package version is a dependency anywhere // this is a primitive implementation diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 9b578d4d3..a6d8a10ca 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -17,6 +17,7 @@ using Microsoft.PowerShell.PSResourceGet.Cmdlets; using System.Net.Http; using System.Globalization; +using System.Security; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses { @@ -41,6 +42,7 @@ public enum MetadataFileType public static readonly string[] EmptyStrArray = Array.Empty(); public static readonly char[] WhitespaceSeparator = new char[]{' '}; public const string PSDataFileExt = ".psd1"; + public const string PSScriptFileExt = ".ps1"; private const string ConvertJsonToHashtableScript = @" param ( [string] $json @@ -632,6 +634,135 @@ public static PSCredential GetRepositoryCredentialFromSecretManagement( } } + public static string GetACRAccessTokenFromSecretManagement( + string repositoryName, + PSCredentialInfo repositoryCredentialInfo, + PSCmdlet cmdletPassedIn) + { + if (!IsSecretManagementVaultAccessible(repositoryName, repositoryCredentialInfo, cmdletPassedIn)) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException($"Cannot access Microsoft.PowerShell.SecretManagement vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication."), + "RepositoryCredentialSecretManagementInaccessibleVault", + ErrorCategory.ResourceUnavailable, + cmdletPassedIn)); + return null; + } + + var results = PowerShellInvoker.InvokeScriptWithHost( + cmdlet: cmdletPassedIn, + script: @" + param ( + [string] $VaultName, + [string] $SecretName + ) + $module = Microsoft.PowerShell.Core\Import-Module -Name Microsoft.PowerShell.SecretManagement -PassThru + if ($null -eq $module) { + return + } + & $module ""Get-Secret"" -Name $SecretName -Vault $VaultName + ", + args: new object[] { repositoryCredentialInfo.VaultName, repositoryCredentialInfo.SecretName }, + out Exception terminatingError); + + var secretValue = (results.Count == 1) ? results[0] : null; + if (secretValue == null) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException( + message: $"Microsoft.PowerShell.SecretManagement\\Get-Secret encountered an error while reading secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication.", + innerException: terminatingError), + "ACRRepositoryCannotGetSecretFromVault", + ErrorCategory.InvalidOperation, + cmdletPassedIn)); + } + + if (secretValue is SecureString secretSecureString) + { + string password = new NetworkCredential(string.Empty, secretSecureString).Password; + return password; + } + + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSNotSupportedException($"Secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" has an invalid type. The only supported type is PSCredential."), + "ACRRepositoryTokenIsInvalidSecretType", + ErrorCategory.InvalidType, + cmdletPassedIn)); + + return null; + } + + public static string GetSecretInfoFromSecretManagement( + string repositoryName, + PSCredentialInfo repositoryCredentialInfo, + PSCmdlet cmdletPassedIn) + { + if (!IsSecretManagementVaultAccessible(repositoryName, repositoryCredentialInfo, cmdletPassedIn)) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException($"Cannot access Microsoft.PowerShell.SecretManagement vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication."), + "RepositoryCredentialSecretManagementInaccessibleVault", + ErrorCategory.ResourceUnavailable, + cmdletPassedIn)); + return null; + } + + var results = PowerShellInvoker.InvokeScriptWithHost( + cmdlet: cmdletPassedIn, + script: @" + param ( + [string] $VaultName, + [string] $SecretName + ) + $module = Microsoft.PowerShell.Core\Import-Module -Name Microsoft.PowerShell.SecretManagement -PassThru + if ($null -eq $module) { + return + } + + $secretInfo = & $module ""Get-SecretInfo"" -Name $SecretName -Vault $VaultName + $secretInfo.Metadata + ", + args: new object[] { repositoryCredentialInfo.VaultName, repositoryCredentialInfo.SecretName }, + out Exception terminatingError); + + var secretInfoValue = (results.Count == 1) ? results[0] : null; + if (secretInfoValue == null) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException( + message: $"Microsoft.PowerShell.SecretManagement\\Get-Secret encountered an error while reading secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication.", + innerException: terminatingError), + "ACRRepositoryCannotGetSecretInfoFromVault", + ErrorCategory.InvalidOperation, + cmdletPassedIn)); + } + + var tenantMetadata = secretInfoValue as ReadOnlyDictionary; + + // "TenantID" is case sensitive so we want to loop through and do a string comparison to accommodate for this + foreach (var entry in tenantMetadata) + { + if (entry.Key.Equals("TenantId", StringComparison.OrdinalIgnoreCase)) + { + return entry.Value as string; + } + } + + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSNotSupportedException($"Secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" has an invalid type. The only supported type is PSCredential."), + "RepositorySecretInfoIsInvalidSecretType", + ErrorCategory.InvalidType, + cmdletPassedIn)); + + return null; + } + public static void SaveRepositoryCredentialToSecretManagementVault( string repositoryName, PSCredentialInfo repositoryCredentialInfo, @@ -1507,6 +1638,117 @@ private static void CopyDirContents( } } + public static void DeleteExtraneousFiles(PSCmdlet callingCmdlet, string pkgName, string dirNameVersion) + { + // Deleting .nupkg SHA file, .nuspec, and .nupkg after unpacking the module + var nuspecToDelete = Path.Combine(dirNameVersion, pkgName + ".nuspec"); + var contentTypesToDelete = Path.Combine(dirNameVersion, "[Content_Types].xml"); + var relsDirToDelete = Path.Combine(dirNameVersion, "_rels"); + var packageDirToDelete = Path.Combine(dirNameVersion, "package"); + + // Unforunately have to check if each file exists because it may or may not be there + if (File.Exists(nuspecToDelete)) + { + callingCmdlet.WriteVerbose(string.Format("Deleting '{0}'", nuspecToDelete)); + File.Delete(nuspecToDelete); + } + if (File.Exists(contentTypesToDelete)) + { + callingCmdlet.WriteVerbose(string.Format("Deleting '{0}'", contentTypesToDelete)); + File.Delete(contentTypesToDelete); + } + if (Directory.Exists(relsDirToDelete)) + { + callingCmdlet.WriteVerbose(string.Format("Deleting '{0}'", relsDirToDelete)); + Utils.DeleteDirectory(relsDirToDelete); + } + if (Directory.Exists(packageDirToDelete)) + { + callingCmdlet.WriteVerbose(string.Format("Deleting '{0}'", packageDirToDelete)); + Utils.DeleteDirectory(packageDirToDelete); + } + } + + public static void MoveFilesIntoInstallPath( + PSResourceInfo pkgInfo, + bool isModule, + bool isLocalRepo, + bool savePkg, + string dirNameVersion, + string tempInstallPath, + string installPath, + string newVersion, + string moduleManifestVersion, + string scriptPath, + PSCmdlet cmdletPassedIn) + { + // Creating the proper installation path depending on whether pkg is a module or script + var newPathParent = isModule ? Path.Combine(installPath, pkgInfo.Name) : installPath; + var finalModuleVersionDir = isModule ? Path.Combine(installPath, pkgInfo.Name, moduleManifestVersion) : installPath; + + // If script, just move the files over, if module, move the version directory over + var tempModuleVersionDir = (!isModule || isLocalRepo) ? dirNameVersion + : Path.Combine(tempInstallPath, pkgInfo.Name.ToLower(), newVersion); + + cmdletPassedIn.WriteVerbose(string.Format("Installation source path is: '{0}'", tempModuleVersionDir)); + cmdletPassedIn.WriteVerbose(string.Format("Installation destination path is: '{0}'", finalModuleVersionDir)); + + if (isModule) + { + // If new path does not exist + if (!Directory.Exists(newPathParent)) + { + cmdletPassedIn.WriteVerbose(string.Format("Attempting to move '{0}' to '{1}'", tempModuleVersionDir, finalModuleVersionDir)); + Directory.CreateDirectory(newPathParent); + Utils.MoveDirectory(tempModuleVersionDir, finalModuleVersionDir); + } + else + { + cmdletPassedIn.WriteVerbose(string.Format("Temporary module version directory is: '{0}'", tempModuleVersionDir)); + + if (Directory.Exists(finalModuleVersionDir)) + { + // Delete the directory path before replacing it with the new module. + // If deletion fails (usually due to binary file in use), then attempt restore so that the currently + // installed module is not corrupted. + cmdletPassedIn.WriteVerbose(string.Format("Attempting to delete with restore on failure.'{0}'", finalModuleVersionDir)); + Utils.DeleteDirectoryWithRestore(finalModuleVersionDir); + } + + cmdletPassedIn.WriteVerbose(string.Format("Attempting to move '{0}' to '{1}'", tempModuleVersionDir, finalModuleVersionDir)); + Utils.MoveDirectory(tempModuleVersionDir, finalModuleVersionDir); + } + } + else + { + if (!savePkg) + { + // Need to delete old xml files because there can only be 1 per script + var scriptXML = pkgInfo.Name + "_InstalledScriptInfo.xml"; + cmdletPassedIn.WriteVerbose(string.Format("Checking if path '{0}' exists: ", File.Exists(Path.Combine(installPath, "InstalledScriptInfos", scriptXML)))); + if (File.Exists(Path.Combine(installPath, "InstalledScriptInfos", scriptXML))) + { + cmdletPassedIn.WriteVerbose(string.Format("Deleting script metadata XML")); + File.Delete(Path.Combine(installPath, "InstalledScriptInfos", scriptXML)); + } + + cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, "InstalledScriptInfos", scriptXML))); + Utils.MoveFiles(Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, "InstalledScriptInfos", scriptXML)); + + // Need to delete old script file, if that exists + cmdletPassedIn.WriteVerbose(string.Format("Checking if path '{0}' exists: ", File.Exists(Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt)))); + if (File.Exists(Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt))) + { + cmdletPassedIn.WriteVerbose(string.Format("Deleting script file")); + File.Delete(Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt)); + } + } + + cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", scriptPath, Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt))); + Utils.MoveFiles(scriptPath, Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt)); + } + } + private static void RestoreDirContents( string sourceDirPath, string destDirPath) diff --git a/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 index 07197f426..17a8dff13 100644 --- a/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - +<## $modPath = "$psscriptroot/../PSGetTestUtils.psm1" Import-Module $modPath -Force -Verbose @@ -212,3 +212,4 @@ Describe 'Test HTTP Find-PSResource for ADO Server Protocol' -tags 'CI' { $err[0].FullyQualifiedErrorId | Should -BeExactly "FindAllFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } } +##> \ No newline at end of file diff --git a/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 index 10e6b2f23..87c3320c5 100644 --- a/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 @@ -106,6 +106,6 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $script:PublishModuleBase -Repository $ADOPrivateRepoName -Credential $incorrectRepoCred -ErrorAction SilentlyContinue - $Error[0].FullyQualifiedErrorId | Should -be "401FatalProtocolError,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + $Error[0].FullyQualifiedErrorId | Should -be ("401FatalProtocolError,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" -or "ProtocolFailError,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource") } } From 0a1836a4088ab0f4f13a4638fa8cd0f571c24140 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Fri, 22 Dec 2023 17:42:00 -0500 Subject: [PATCH 011/160] rough prototype of find name (#1497) --- src/code/ACRServerAPICalls.cs | 99 +++++++++++++++++++++++++++++---- src/code/PSRepositoryInfo.cs | 3 +- src/code/RepositorySettings.cs | 4 ++ src/code/ResponseUtilFactory.cs | 4 ++ src/code/ServerFactory.cs | 4 ++ src/code/Utils.cs | 5 ++ 6 files changed, 107 insertions(+), 12 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 6582c2fd6..a96734b0e 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -130,12 +130,53 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include /// public override FindResults FindName(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { + errRecord = null; _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindName()"); - errRecord = new ErrorRecord( - new InvalidOperationException($"Find name is not supported for the ACR server protocol repository '{Repository.Name}'"), - "FindNameFailure", - ErrorCategory.InvalidOperation, - this); + string accessToken = string.Empty; + string tenantID = string.Empty; + + // Need to set up secret management vault before hand + var repositoryCredentialInfo = Repository.CredentialInfo; + if (repositoryCredentialInfo != null) + { + accessToken = Utils.GetACRAccessTokenFromSecretManagement( + Repository.Name, + repositoryCredentialInfo, + _cmdletPassedIn); + + _cmdletPassedIn.WriteVerbose("Access token retrieved."); + + tenantID = Utils.GetSecretInfoFromSecretManagement( + Repository.Name, + repositoryCredentialInfo, + _cmdletPassedIn); + } + + // Call asynchronous network methods in a try/catch block to handle exceptions. + string registry = Repository.Uri.Host; + + _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); + var acrRefreshToken = GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; + _cmdletPassedIn.WriteVerbose("Getting acr access token"); + var acrAccessToken = GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; + + _cmdletPassedIn.WriteVerbose("Getting tags"); + var foundTags = FindAcrImageTags(registry, packageName, "*", acrAccessToken).Result; + Console.WriteLine(foundTags.ToString(Formatting.None)); + + if (foundTags != null) + { + foreach (var item in foundTags["tags"]) + { + // digest: {item["digest"]"; + string tagVersion = item["name"].ToString(); + Console.WriteLine("tag version: " + tagVersion); + + /* + foundPkgs.Add(new PSResourceInfo(name: pkgName, version: tagVersion, repository: repo.Name)); + */ + } + } return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); } @@ -227,13 +268,49 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange /// public override FindResults FindVersion(string packageName, string version, ResourceType type, out ErrorRecord errRecord) { - + errRecord = null; _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindVersion()"); - errRecord = new ErrorRecord( - new InvalidOperationException($"Find version is not supported for the ACR server protocol repository '{Repository.Name}'"), - "FindVersionFailure", - ErrorCategory.InvalidOperation, - this); + string accessToken = string.Empty; + string tenantID = string.Empty; + + // Need to set up secret management vault before hand + var repositoryCredentialInfo = Repository.CredentialInfo; + if (repositoryCredentialInfo != null) + { + accessToken = Utils.GetACRAccessTokenFromSecretManagement( + Repository.Name, + repositoryCredentialInfo, + _cmdletPassedIn); + + _cmdletPassedIn.WriteVerbose("Access token retrieved."); + + tenantID = Utils.GetSecretInfoFromSecretManagement( + Repository.Name, + repositoryCredentialInfo, + _cmdletPassedIn); + } + + // Call asynchronous network methods in a try/catch block to handle exceptions. + string registry = Repository.Uri.Host; + + _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); + var acrRefreshToken = GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; + _cmdletPassedIn.WriteVerbose("Getting acr access token"); + var acrAccessToken = GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; + + _cmdletPassedIn.WriteVerbose("Getting tags"); + var foundTags = FindAcrImageTags(registry, packageName, version, acrAccessToken).Result; + + if (foundTags != null) + { + var digest = foundTags["tag"]["digest"]; + Console.WriteLine("digest: " + digest); + // pkgVersion was used in the API call (same as foundTags["name"]) + // digest: foundTags["tag"]["digest"]"; + /* + foundPkgs.Add(new PSResourceInfo(name: pkgName, version: pkgVersion, repository: repo.Name)); + */ + } return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); } diff --git a/src/code/PSRepositoryInfo.cs b/src/code/PSRepositoryInfo.cs index 2ab93b192..4700d5d2a 100644 --- a/src/code/PSRepositoryInfo.cs +++ b/src/code/PSRepositoryInfo.cs @@ -19,7 +19,8 @@ public enum APIVersion v2, v3, local, - nugetServer + nugetServer, + acr } #endregion diff --git a/src/code/RepositorySettings.cs b/src/code/RepositorySettings.cs index 1ba44e8d4..b7d4e226e 100644 --- a/src/code/RepositorySettings.cs +++ b/src/code/RepositorySettings.cs @@ -840,6 +840,10 @@ private static PSRepositoryInfo.APIVersion GetRepoAPIVersion(Uri repoUri) // repositories with Uri Scheme "temp" may have PSPath Uri's like: "Temp:\repo" and we should consider them as local repositories. return PSRepositoryInfo.APIVersion.local; } + else if (repoUri.AbsoluteUri.EndsWith(".azurecr.io") || repoUri.AbsoluteUri.EndsWith(".azurecr.io/")) + { + return PSRepositoryInfo.APIVersion.acr; + } else { return PSRepositoryInfo.APIVersion.unknown; diff --git a/src/code/ResponseUtilFactory.cs b/src/code/ResponseUtilFactory.cs index 3f25d63c3..843f9042b 100644 --- a/src/code/ResponseUtilFactory.cs +++ b/src/code/ResponseUtilFactory.cs @@ -30,6 +30,10 @@ public static ResponseUtil GetResponseUtil(PSRepositoryInfo repository) currentResponseUtil = new NuGetServerResponseUtil(repository); break; + case PSRepositoryInfo.APIVersion.acr: + currentResponseUtil = new ACRResponseUtil(repository); + break; + case PSRepositoryInfo.APIVersion.unknown: break; } diff --git a/src/code/ServerFactory.cs b/src/code/ServerFactory.cs index 8be08662e..b3e85187d 100644 --- a/src/code/ServerFactory.cs +++ b/src/code/ServerFactory.cs @@ -58,6 +58,10 @@ public static ServerApiCall GetServer(PSRepositoryInfo repository, PSCmdlet cmdl currentServer = new NuGetServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); break; + case PSRepositoryInfo.APIVersion.acr: + currentServer = new ACRServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); + break; + case PSRepositoryInfo.APIVersion.unknown: break; } diff --git a/src/code/Utils.cs b/src/code/Utils.cs index a6d8a10ca..04dfba6af 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -684,6 +684,11 @@ public static string GetACRAccessTokenFromSecretManagement( string password = new NetworkCredential(string.Empty, secretSecureString).Password; return password; } + else if(secretValue is PSCredential psCredSecret) + { + string password = new NetworkCredential(string.Empty, psCredSecret.Password).Password; + return password; + } cmdletPassedIn.ThrowTerminatingError( new ErrorRecord( From b7eae3038769ac22398a47f4154a930ce63d4254 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Wed, 27 Dec 2023 15:00:03 -0800 Subject: [PATCH 012/160] Update SecretManagement credential use with ACR repos (#1498) --- src/code/ACRServerAPICalls.cs | 30 +++++---------- src/code/Utils.cs | 72 ++--------------------------------- 2 files changed, 14 insertions(+), 88 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index a96734b0e..6c284d075 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -146,10 +146,8 @@ public override FindResults FindName(string packageName, bool includePrerelease, _cmdletPassedIn.WriteVerbose("Access token retrieved."); - tenantID = Utils.GetSecretInfoFromSecretManagement( - Repository.Name, - repositoryCredentialInfo, - _cmdletPassedIn); + tenantID = repositoryCredentialInfo.SecretName; + _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); } // Call asynchronous network methods in a try/catch block to handle exceptions. @@ -284,10 +282,8 @@ public override FindResults FindVersion(string packageName, string version, Reso _cmdletPassedIn.WriteVerbose("Access token retrieved."); - tenantID = Utils.GetSecretInfoFromSecretManagement( - Repository.Name, - repositoryCredentialInfo, - _cmdletPassedIn); + tenantID = repositoryCredentialInfo.SecretName; + _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); } // Call asynchronous network methods in a try/catch block to handle exceptions. @@ -405,10 +401,8 @@ internal static PSResourceInfo Install( callingCmdlet.WriteVerbose("Access token retrieved."); - tenantID = Utils.GetSecretInfoFromSecretManagement( - repo.Name, - repositoryCredentialInfo, - callingCmdlet); + tenantID = repositoryCredentialInfo.SecretName; + callingCmdlet.WriteVerbose($"Tenant ID: {tenantID}"); } // Call asynchronous network methods in a try/catch block to handle exceptions. @@ -534,10 +528,8 @@ internal static List Find(PSRepositoryInfo repo, string pkgName, callingCmdlet.WriteVerbose("Access token retrieved."); - tenantID = Utils.GetSecretInfoFromSecretManagement( - repo.Name, - repositoryCredentialInfo, - callingCmdlet); + tenantID = repositoryCredentialInfo.SecretName; + callingCmdlet.WriteVerbose($"Tenant ID: {tenantID}"); } // Call asynchronous network methods in a try/catch block to handle exceptions. @@ -860,10 +852,8 @@ private bool PushNupkgACR(string outputNupkgDir, string pkgName, NuGetVersion pk _cmdletPassedIn.WriteVerbose("Access token retrieved."); - tenantID = Utils.GetSecretInfoFromSecretManagement( - repository.Name, - repositoryCredentialInfo, - _cmdletPassedIn); + tenantID = repositoryCredentialInfo.SecretName; + _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); } // Call asynchronous network methods in a try/catch block to handle exceptions. diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 04dfba6af..c3d2b178c 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -607,6 +607,10 @@ public static PSCredential GetRepositoryCredentialFromSecretManagement( { return secretCredential; } + else if (secretObject.BaseObject is SecureString secretString) + { + return new PSCredential(repositoryCredentialInfo.SecretName, secretString); + } } cmdletPassedIn.ThrowTerminatingError( @@ -700,74 +704,6 @@ public static string GetACRAccessTokenFromSecretManagement( return null; } - public static string GetSecretInfoFromSecretManagement( - string repositoryName, - PSCredentialInfo repositoryCredentialInfo, - PSCmdlet cmdletPassedIn) - { - if (!IsSecretManagementVaultAccessible(repositoryName, repositoryCredentialInfo, cmdletPassedIn)) - { - cmdletPassedIn.ThrowTerminatingError( - new ErrorRecord( - new PSInvalidOperationException($"Cannot access Microsoft.PowerShell.SecretManagement vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication."), - "RepositoryCredentialSecretManagementInaccessibleVault", - ErrorCategory.ResourceUnavailable, - cmdletPassedIn)); - return null; - } - - var results = PowerShellInvoker.InvokeScriptWithHost( - cmdlet: cmdletPassedIn, - script: @" - param ( - [string] $VaultName, - [string] $SecretName - ) - $module = Microsoft.PowerShell.Core\Import-Module -Name Microsoft.PowerShell.SecretManagement -PassThru - if ($null -eq $module) { - return - } - - $secretInfo = & $module ""Get-SecretInfo"" -Name $SecretName -Vault $VaultName - $secretInfo.Metadata - ", - args: new object[] { repositoryCredentialInfo.VaultName, repositoryCredentialInfo.SecretName }, - out Exception terminatingError); - - var secretInfoValue = (results.Count == 1) ? results[0] : null; - if (secretInfoValue == null) - { - cmdletPassedIn.ThrowTerminatingError( - new ErrorRecord( - new PSInvalidOperationException( - message: $"Microsoft.PowerShell.SecretManagement\\Get-Secret encountered an error while reading secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication.", - innerException: terminatingError), - "ACRRepositoryCannotGetSecretInfoFromVault", - ErrorCategory.InvalidOperation, - cmdletPassedIn)); - } - - var tenantMetadata = secretInfoValue as ReadOnlyDictionary; - - // "TenantID" is case sensitive so we want to loop through and do a string comparison to accommodate for this - foreach (var entry in tenantMetadata) - { - if (entry.Key.Equals("TenantId", StringComparison.OrdinalIgnoreCase)) - { - return entry.Value as string; - } - } - - cmdletPassedIn.ThrowTerminatingError( - new ErrorRecord( - new PSNotSupportedException($"Secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" has an invalid type. The only supported type is PSCredential."), - "RepositorySecretInfoIsInvalidSecretType", - ErrorCategory.InvalidType, - cmdletPassedIn)); - - return null; - } - public static void SaveRepositoryCredentialToSecretManagementVault( string repositoryName, PSCredentialInfo repositoryCredentialInfo, From 3df706353fe53e58fd454277862363f6726eadee Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 2 Jan 2024 10:09:40 -0800 Subject: [PATCH 013/160] ACR Integration: Find-PSResource methods and response class (#1499) --- src/code/ACRResponseUtil.cs | 70 +- src/code/ACRServerAPICalls.cs | 662 +++++++++++------- src/code/InstallHelper.cs | 4 +- src/code/PSResourceInfo.cs | 93 +++ .../FindPSResourceACRServer.Tests.ps1 | 142 ++++ 5 files changed, 661 insertions(+), 310 deletions(-) create mode 100644 test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 diff --git a/src/code/ACRResponseUtil.cs b/src/code/ACRResponseUtil.cs index ce09515cb..9f5fe4217 100644 --- a/src/code/ACRResponseUtil.cs +++ b/src/code/ACRResponseUtil.cs @@ -3,8 +3,9 @@ using Microsoft.PowerShell.PSResourceGet.UtilClasses; using System; +using System.Collections; using System.Collections.Generic; -using System.Xml; +using System.Text.Json; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets { @@ -26,67 +27,52 @@ public ACRResponseUtil(PSRepositoryInfo repository) : base(repository) #endregion #region Overriden Methods + public override IEnumerable ConvertToPSResourceResult(FindResults responseResults) { // in FindHelper: // serverApi.FindName() -> return responses, and out errRecord // check outErrorRecord // - // v2Converter.ConvertToPSResourceInfo(responses) -> return PSResourceResult + // acrConverter.ConvertToPSResourceInfo(responses) -> return PSResourceResult // check resourceResult for error, write if needed - string[] responses = responseResults.StringResponse; - - foreach (string response in responses) + Hashtable[] responses = responseResults.HashtableResponse; + foreach (Hashtable response in responses) { - var elemList = ConvertResponseToXML(response); - if (elemList.Length == 0) - { - // this indicates we got a non-empty, XML response (as noticed for V2 server) but it's not a response that's meaningful (contains 'properties') - Exception notFoundException = new ResourceNotFoundException("Package does not exist on the server"); + string responseConversionError = String.Empty; + PSResourceInfo pkg = null; - yield return new PSResourceResult(returnedObject: null, exception: notFoundException, isTerminatingError: false); - } + string packageName = string.Empty; + string packageMetadata = null; - foreach (var element in elemList) + foreach (DictionaryEntry entry in response) { - if (!PSResourceInfo.TryConvertFromXml(element, out PSResourceInfo psGetInfo, Repository, out string errorMsg)) - { - Exception parseException = new XmlParsingException(errorMsg); - - yield return new PSResourceResult(returnedObject: null, exception: parseException, isTerminatingError: false); - } + packageName = (string)entry.Key; + packageMetadata = (string)entry.Value; + } - // Unlisted versions will have a published year as 1900 or earlier. - if (!psGetInfo.PublishedDate.HasValue || psGetInfo.PublishedDate.Value.Year > 1900) + try + { + using (JsonDocument pkgVersionEntry = JsonDocument.Parse(packageMetadata)) { - yield return new PSResourceResult(returnedObject: psGetInfo, exception: null, isTerminatingError: false); + PSResourceInfo.TryConvertFromACRJson(packageName, pkgVersionEntry, out pkg, Repository, out responseConversionError); } } - } - } - - #endregion - - #region V2 Specific Methods - - public XmlNode[] ConvertResponseToXML(string httpResponse) - { - - //Create the XmlDocument. - XmlDocument doc = new XmlDocument(); - doc.LoadXml(httpResponse); + catch (Exception e) + { + responseConversionError = e.Message; + } - XmlNodeList elemList = doc.GetElementsByTagName("m:properties"); + if (!String.IsNullOrEmpty(responseConversionError)) + { + yield return new PSResourceResult(returnedObject: null, new ConvertToPSResourceException(responseConversionError), isTerminatingError: false); + } - XmlNode[] nodes = new XmlNode[elemList.Count]; - for (int i = 0; i < elemList.Count; i++) - { - nodes[i] = elemList[i]; + yield return new PSResourceResult(returnedObject: pkg, exception: null, isTerminatingError: false); } - - return nodes; } #endregion + } } diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 6c284d075..2633e1aaa 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -19,6 +19,7 @@ using Microsoft.PowerShell.PSResourceGet.Cmdlets; using System.Text; using System.Security.Cryptography; +using System.Text.Json; namespace Microsoft.PowerShell.PSResourceGet { @@ -32,7 +33,7 @@ internal class ACRServerAPICalls : ServerApiCall private readonly PSCmdlet _cmdletPassedIn; private HttpClient _sessionClient { get; set; } private static readonly Hashtable[] emptyHashResponses = new Hashtable[] { }; - public FindResponseType v3FindResponseType = FindResponseType.ResponseString; + public FindResponseType acrFindResponseType = FindResponseType.ResponseString; const string acrRefreshTokenTemplate = "grant_type=access_token&service={0}&tenant={1}&access_token={2}"; // 0 - registry, 1 - tenant, 2 - access token const string acrAccessTokenTemplate = "grant_type=refresh_token&service={0}&scope=repository:*:*&refresh_token={1}"; // 0 - registry, 1 - refresh token @@ -61,7 +62,6 @@ public ACRServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, N _sessionClient = new HttpClient(handler); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); - var repoURL = repository.Uri.ToString().ToLower(); } #endregion @@ -83,7 +83,7 @@ public override FindResults FindAll(bool includePrerelease, ResourceType type, o ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } /// @@ -101,7 +101,7 @@ public override FindResults FindTags(string[] tags, bool includePrerelease, Reso ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } /// @@ -116,7 +116,7 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } /// @@ -130,7 +130,6 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include /// public override FindResults FindName(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { - errRecord = null; _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindName()"); string accessToken = string.Empty; string tenantID = string.Empty; @@ -154,29 +153,80 @@ public override FindResults FindName(string packageName, bool includePrerelease, string registry = Repository.Uri.Host; _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; + var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); + if (errRecord != null) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } + _cmdletPassedIn.WriteVerbose("Getting acr access token"); - var acrAccessToken = GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; + var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + if (errRecord != null) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } _cmdletPassedIn.WriteVerbose("Getting tags"); - var foundTags = FindAcrImageTags(registry, packageName, "*", acrAccessToken).Result; - Console.WriteLine(foundTags.ToString(Formatting.None)); + var foundTags = FindAcrImageTags(registry, packageName, "*", acrAccessToken, out errRecord); + if (errRecord != null || foundTags == null) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } - if (foundTags != null) + /* response returned looks something like: + * "registry": "myregistry.azurecr.io" + * "imageName": "hello-world" + * "tags": [ + * { + * ""name"": ""1.0.0"", + * ""digest"": ""sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"", + * ""createdTime"": ""2023-12-23T18:06:48.9975733Z"", + * ""lastUpdateTime"": ""2023-12-23T18:06:48.9975733Z"", + * ""signed"": false, + * ""changeableAttributes"": { + * ""deleteEnabled"": true, + * ""writeEnabled"": true, + * ""readEnabled"": true, + * ""listEnabled"": true + * } + * }] + */ + List latestVersionResponse = new List(); + List allVersionsList = foundTags["tags"].ToList(); + allVersionsList.Reverse(); + + foreach (var packageVersion in allVersionsList) { - foreach (var item in foundTags["tags"]) + var packageVersionStr = packageVersion.ToString(); + using (JsonDocument pkgVersionEntry = JsonDocument.Parse(packageVersionStr)) { - // digest: {item["digest"]"; - string tagVersion = item["name"].ToString(); - Console.WriteLine("tag version: " + tagVersion); + JsonElement rootDom = pkgVersionEntry.RootElement; + if (!rootDom.TryGetProperty("name", out JsonElement pkgVersionElement)) + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain version element ('name') for package '{packageName}' in '{Repository.Name}'."), + "FindNameFailure", + ErrorCategory.InvalidResult, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } - /* - foundPkgs.Add(new PSResourceInfo(name: pkgName, version: tagVersion, repository: repo.Name)); - */ + if (NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) + { + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + if (!pkgVersion.IsPrerelease || includePrerelease) + { + // Versions are always in descending order i.e 5.0.0, 3.0.0, 1.0.0 so grabbing the first match suffices + latestVersionResponse.Add(new Hashtable() { { packageName, packageVersionStr } }); + + break; + } + } } } - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + return new FindResults(stringResponse: new string[] {}, hashtableResponse: latestVersionResponse.ToArray(), responseType: acrFindResponseType); } /// @@ -194,7 +244,7 @@ public override FindResults FindNameWithTag(string packageName, string[] tags, b ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } @@ -215,7 +265,7 @@ public override FindResults FindNameGlobbing(string packageName, bool includePre ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } /// @@ -233,7 +283,7 @@ public override FindResults FindNameGlobbingWithTag(string packageName, string[] ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } /// @@ -248,13 +298,102 @@ public override FindResults FindNameGlobbingWithTag(string packageName, string[] public override FindResults FindVersionGlobbing(string packageName, VersionRange versionRange, bool includePrerelease, ResourceType type, bool getOnlyLatest, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindVersionGlobbing()"); - errRecord = new ErrorRecord( - new InvalidOperationException($"Find version globbing is not supported for the ACR server protocol repository '{Repository.Name}'"), - "FindVersionGlobbingFailure", - ErrorCategory.InvalidOperation, - this); + string accessToken = string.Empty; + string tenantID = string.Empty; + + // Need to set up secret management vault beforehand + var repositoryCredentialInfo = Repository.CredentialInfo; + if (repositoryCredentialInfo != null) + { + accessToken = Utils.GetACRAccessTokenFromSecretManagement( + Repository.Name, + repositoryCredentialInfo, + _cmdletPassedIn); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + _cmdletPassedIn.WriteVerbose("Access token retrieved."); + + tenantID = repositoryCredentialInfo.SecretName; + _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); + } + + string registry = Repository.Uri.Host; + + _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); + var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); + if (errRecord != null) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } + + _cmdletPassedIn.WriteVerbose("Getting acr access token"); + var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + if (errRecord != null) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } + + _cmdletPassedIn.WriteVerbose("Getting tags"); + var foundTags = FindAcrImageTags(registry, packageName, "*", acrAccessToken, out errRecord); + if (errRecord != null || foundTags == null) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } + + /* response returned looks something like: + * "registry": "myregistry.azurecr.io" + * "imageName": "hello-world" + * "tags": [ + * { + * ""name"": ""1.0.0"", + * ""digest"": ""sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"", + * ""createdTime"": ""2023-12-23T18:06:48.9975733Z"", + * ""lastUpdateTime"": ""2023-12-23T18:06:48.9975733Z"", + * ""signed"": false, + * ""changeableAttributes"": { + * ""deleteEnabled"": true, + * ""writeEnabled"": true, + * ""readEnabled"": true, + * ""listEnabled"": true + * } + * }] + */ + List latestVersionResponse = new List(); + List allVersionsList = foundTags["tags"].ToList(); + foreach (var packageVersion in allVersionsList) + { + var packageVersionStr = packageVersion.ToString(); + using (JsonDocument pkgVersionEntry = JsonDocument.Parse(packageVersionStr)) + { + JsonElement rootDom = pkgVersionEntry.RootElement; + if (!rootDom.TryGetProperty("name", out JsonElement pkgVersionElement)) + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain version element ('name') for package '{packageName}' in '{Repository.Name}'."), + "FindNameFailure", + ErrorCategory.InvalidResult, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } + + if (NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) + { + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + if (versionRange.Satisfies(pkgVersion)) + { + if (!includePrerelease && pkgVersion.IsPrerelease == true) + { + _cmdletPassedIn.WriteDebug($"Prerelease version '{pkgVersion}' found, but not included."); + continue; + } + + latestVersionResponse.Add(new Hashtable() { { packageName, packageVersionStr } }); + } + } + } + } + + return new FindResults(stringResponse: new string[] { }, hashtableResponse: latestVersionResponse.ToArray(), responseType: acrFindResponseType); } /// @@ -266,12 +405,23 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange /// public override FindResults FindVersion(string packageName, string version, ResourceType type, out ErrorRecord errRecord) { - errRecord = null; _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindVersion()"); + if (!NuGetVersion.TryParse(version, out NuGetVersion requiredVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Version {version} to be found is not a valid NuGet version."), + "FindNameFailure", + ErrorCategory.InvalidArgument, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{requiredVersion}'"); + string accessToken = string.Empty; string tenantID = string.Empty; - // Need to set up secret management vault before hand + // Need to set up secret management vault beforehand var repositoryCredentialInfo = Repository.CredentialInfo; if (repositoryCredentialInfo != null) { @@ -286,29 +436,76 @@ public override FindResults FindVersion(string packageName, string version, Reso _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); } - // Call asynchronous network methods in a try/catch block to handle exceptions. string registry = Repository.Uri.Host; _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; + var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); + if (errRecord != null) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } + _cmdletPassedIn.WriteVerbose("Getting acr access token"); - var acrAccessToken = GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; + var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + if (errRecord != null) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } _cmdletPassedIn.WriteVerbose("Getting tags"); - var foundTags = FindAcrImageTags(registry, packageName, version, acrAccessToken).Result; + var foundTags = FindAcrImageTags(registry, packageName, requiredVersion.ToString(), acrAccessToken, out errRecord); + if (errRecord != null || foundTags == null) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } - if (foundTags != null) + /* response returned looks something like: + * "registry": "myregistry.azurecr.io" + * "imageName": "hello-world" + * "tags": [ + * { + * ""name"": ""1.0.0"", + * ""digest"": ""sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"", + * ""createdTime"": ""2023-12-23T18:06:48.9975733Z"", + * ""lastUpdateTime"": ""2023-12-23T18:06:48.9975733Z"", + * ""signed"": false, + * ""changeableAttributes"": { + * ""deleteEnabled"": true, + * ""writeEnabled"": true, + * ""readEnabled"": true, + * ""listEnabled"": true + * } + * }] + */ + List requiredVersionResponse = new List(); + + var packageVersionStr = foundTags["tag"].ToString(); + using (JsonDocument pkgVersionEntry = JsonDocument.Parse(packageVersionStr)) { - var digest = foundTags["tag"]["digest"]; - Console.WriteLine("digest: " + digest); - // pkgVersion was used in the API call (same as foundTags["name"]) - // digest: foundTags["tag"]["digest"]"; - /* - foundPkgs.Add(new PSResourceInfo(name: pkgName, version: pkgVersion, repository: repo.Name)); - */ + JsonElement rootDom = pkgVersionEntry.RootElement; + if (!rootDom.TryGetProperty("name", out JsonElement pkgVersionElement)) + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain version element ('name') for package '{packageName}' in '{Repository.Name}'."), + "FindNameFailure", + ErrorCategory.InvalidResult, + this); + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } + + if (NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) + { + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + + if (pkgVersion == requiredVersion) + { + requiredVersionResponse.Add(new Hashtable() { { packageName, packageVersionStr } }); + } + } } - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + return new FindResults(stringResponse: new string[] { }, hashtableResponse: requiredVersionResponse.ToArray(), responseType: acrFindResponseType); } /// @@ -326,7 +523,7 @@ public override FindResults FindVersionWithTag(string packageName, string versio ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } /** INSTALL APIS **/ @@ -339,276 +536,128 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// public override Stream InstallPackage(string packageName, string packageVersion, bool includePrerelease, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In V3ServerAPICalls::InstallPackage()"); Stream results = new MemoryStream(); - errRecord = null; - - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::InstallPackage()"); - errRecord = new ErrorRecord( - new InvalidOperationException($"Install is not supported for the ACR server protocol repository '{Repository.Name}'"), - "InstallFailure", - ErrorCategory.InvalidOperation, - this); + if (string.IsNullOrEmpty(packageVersion)) + { + results = InstallName(packageName, packageVersion, out errRecord); + } + else + { + results = InstallName(packageName, packageVersion, out errRecord); + } return results; } - /// - /// Helper method that makes the HTTP request for the V2 server protocol url passed in for find APIs. - /// - private string HttpRequestCall(string requestUrlV2, out ErrorRecord errRecord) - { - string response = string.Empty; - errRecord = null; - - return response; - } - - /// - /// Helper method that makes the HTTP request for the V2 server protocol url passed in for install APIs. - /// - private HttpContent HttpRequestCallForContent(string requestUrlV2, out ErrorRecord errRecord) - { - _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::HttpRequestCallForContent()"); - errRecord = null; - HttpContent content = null; - - return content; - } - - internal static PSResourceInfo Install( - PSRepositoryInfo repo, + private Stream InstallName( string moduleName, string moduleVersion, - bool savePkg, - bool asZip, - List installPath, - PSCmdlet callingCmdlet) + out ErrorRecord errRecord) { + errRecord = null; string accessToken = string.Empty; string tenantID = string.Empty; string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(tempPath); // Need to set up secret management vault before hand - var repositoryCredentialInfo = repo.CredentialInfo; + var repositoryCredentialInfo = Repository.CredentialInfo; if (repositoryCredentialInfo != null) { accessToken = Utils.GetACRAccessTokenFromSecretManagement( - repo.Name, + Repository.Name, repositoryCredentialInfo, - callingCmdlet); + _cmdletPassedIn); - callingCmdlet.WriteVerbose("Access token retrieved."); + _cmdletPassedIn.WriteVerbose("Access token retrieved."); tenantID = repositoryCredentialInfo.SecretName; - callingCmdlet.WriteVerbose($"Tenant ID: {tenantID}"); + _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); } // Call asynchronous network methods in a try/catch block to handle exceptions. - string registry = repo.Uri.Host; - - callingCmdlet.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; - callingCmdlet.WriteVerbose("Getting acr access token"); - var acrAccessToken = GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; - callingCmdlet.WriteVerbose($"Getting manifest for {moduleName} - {moduleVersion}"); - var manifest = GetAcrRepositoryManifestAsync(registry, moduleName, moduleVersion, acrAccessToken).Result; - var digest = manifest["layers"].FirstOrDefault()["digest"].ToString(); - callingCmdlet.WriteVerbose($"Downloading blob for {moduleName} - {moduleVersion}"); - var responseContent = GetAcrBlobAsync(registry, moduleName, digest, acrAccessToken).Result; + string registry = Repository.Uri.Host; - callingCmdlet.WriteVerbose($"Writing module zip to temp path: {tempPath}"); - - // download the module - var pathToFile = Path.Combine(tempPath, $"{moduleName}.{moduleVersion}.zip"); - using var content = responseContent.ReadAsStreamAsync().Result; - using var fs = File.Create(pathToFile); - content.Seek(0, SeekOrigin.Begin); - content.CopyTo(fs); - fs.Close(); - - PSResourceInfo pkgInfo = null; - /* - var pkgInfo = new PSResourceInfo( - additionalMetadata: new Hashtable { }, - author: string.Empty, - companyName: string.Empty, - copyright: string.Empty, - dependencies: new Dependency[] { }, - description: string.Empty, - iconUri: string.Empty, - includes: new ResourceIncludes(), - installedDate: null, - installedLocation: null, - isPrerelease: false, - licenseUri: string.Empty, - name: moduleName, - powershellGetFormatVersion: null, - prerelease: string.Empty, - projectUri: string.Empty, - publishedDate: null, - releaseNotes: string.Empty, - repository: string.Empty, - repositorySourceLocation: repo.Name, - tags: new string[] { }, - type: ResourceType.Module, - updatedDate: null, - version: moduleVersion); - */ - - // If saving the package as a zip - if (savePkg && asZip) - { - // Just move to the zip to the proper path - Utils.MoveFiles(pathToFile, Path.Combine(installPath.FirstOrDefault(), $"{moduleName}.{moduleVersion}.zip")); + _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); + var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); + _cmdletPassedIn.WriteVerbose("Getting acr access token"); + var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + _cmdletPassedIn.WriteVerbose($"Getting manifest for {moduleName} - {moduleVersion}"); + var manifest = GetAcrRepositoryManifestAsync(registry, moduleName, moduleVersion, acrAccessToken, out errRecord); + var digest = "sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"; + _cmdletPassedIn.WriteVerbose($"Downloading blob for {moduleName} - {moduleVersion}"); + var responseContent = GetAcrBlobAsync(registry, moduleName, digest, acrAccessToken).Result; - } - // If saving the package and unpacking OR installing the package - else - { - string expandedPath = Path.Combine(tempPath, moduleName.ToLower(), moduleVersion); - Directory.CreateDirectory(expandedPath); - callingCmdlet.WriteVerbose($"Expanding module to temp path: {expandedPath}"); - // Expand the zip file - System.IO.Compression.ZipFile.ExtractToDirectory(pathToFile, expandedPath); - Utils.DeleteExtraneousFiles(callingCmdlet, moduleName, expandedPath); - - callingCmdlet.WriteVerbose("Expanding completed"); - File.Delete(pathToFile); - - Utils.MoveFilesIntoInstallPath( - pkgInfo, - isModule: true, - isLocalRepo: false, - savePkg, - moduleVersion, - tempPath, - installPath.FirstOrDefault(), - moduleVersion, - moduleVersion, - scriptPath: null, - callingCmdlet); - - if (Directory.Exists(tempPath)) - { - try - { - Utils.DeleteDirectory(tempPath); - callingCmdlet.WriteVerbose(string.Format("Successfully deleted '{0}'", tempPath)); - } - catch (Exception e) - { - ErrorRecord TempDirCouldNotBeDeletedError = new ErrorRecord(e, "errorDeletingTempInstallPath", ErrorCategory.InvalidResult, null); - callingCmdlet.WriteError(TempDirCouldNotBeDeletedError); - } - } - } - return pkgInfo; + return responseContent.ReadAsStreamAsync().Result; } - #endregion - internal static List Find(PSRepositoryInfo repo, string pkgName, string pkgVersion, PSCmdlet callingCmdlet) - { - List foundPkgs = new List(); - string accessToken = string.Empty; - string tenantID = string.Empty; - - // Need to set up secret management vault before hand - var repositoryCredentialInfo = repo.CredentialInfo; - if (repositoryCredentialInfo != null) - { - accessToken = Utils.GetACRAccessTokenFromSecretManagement( - repo.Name, - repositoryCredentialInfo, - callingCmdlet); - - callingCmdlet.WriteVerbose("Access token retrieved."); - - tenantID = repositoryCredentialInfo.SecretName; - callingCmdlet.WriteVerbose($"Tenant ID: {tenantID}"); - } - - // Call asynchronous network methods in a try/catch block to handle exceptions. - string registry = repo.Uri.Host; - - callingCmdlet.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; - callingCmdlet.WriteVerbose("Getting acr access token"); - var acrAccessToken = GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; - - callingCmdlet.WriteVerbose("Getting tags"); - var foundTags = FindAcrImageTags(registry, pkgName, pkgVersion, acrAccessToken).Result; - - if (foundTags != null) - { - if (string.Equals(pkgVersion, "*", StringComparison.OrdinalIgnoreCase)) - { - foreach (var item in foundTags["tags"]) - { - // digest: {item["digest"]"; - string tagVersion = item["name"].ToString(); - - /* - foundPkgs.Add(new PSResourceInfo(name: pkgName, version: tagVersion, repository: repo.Name)); - */ - } - } - else - { - // pkgVersion was used in the API call (same as foundTags["name"]) - // digest: foundTags["tag"]["digest"]"; - /* - foundPkgs.Add(new PSResourceInfo(name: pkgName, version: pkgVersion, repository: repo.Name)); - */ - } - } - - return foundPkgs; - } - #region Private Methods - internal static async Task GetAcrRefreshTokenAsync(string registry, string tenant, string accessToken) + + internal string GetAcrRefreshToken(string registry, string tenant, string accessToken, out ErrorRecord errRecord) { string content = string.Format(acrRefreshTokenTemplate, registry, tenant, accessToken); var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; string exchangeUrl = string.Format(acrOAuthExchangeUrlTemplate, registry); - return (await GetHttpResponseJObject(exchangeUrl, HttpMethod.Post, content, contentHeaders))["refresh_token"].ToString(); + var results = GetHttpResponseJObjectUsingContentHeaders(exchangeUrl, HttpMethod.Post, content, contentHeaders, out errRecord); + + if (results != null && results["refresh_token"] != null) + { + return results["refresh_token"].ToString(); + } + + return string.Empty; } - internal static async Task GetAcrAccessTokenAsync(string registry, string refreshToken) + internal string GetAcrAccessToken(string registry, string refreshToken, out ErrorRecord errRecord) { string content = string.Format(acrAccessTokenTemplate, registry, refreshToken); var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; string tokenUrl = string.Format(acrOAuthTokenUrlTemplate, registry); - return (await GetHttpResponseJObject(tokenUrl, HttpMethod.Post, content, contentHeaders))["access_token"].ToString(); + var results = GetHttpResponseJObjectUsingContentHeaders(tokenUrl, HttpMethod.Post, content, contentHeaders, out errRecord); + + if (results != null && results["access_token"] != null) + { + return results["access_token"].ToString(); + } + + return string.Empty; } - internal static async Task GetAcrRepositoryManifestAsync(string registry, string repositoryName, string version, string acrAccessToken) + internal JObject GetAcrRepositoryManifestAsync(string registry, string repositoryName, string version, string acrAccessToken, out ErrorRecord errRecord) { string manifestUrl = string.Format(acrManifestUrlTemplate, registry, repositoryName, version); + + // GET acrapi.azurecr-test.io/v2/prod/bash/blobs/sha256:16463e0c481e161aabb735437d30b3c9c7391c2747cc564bb927e843b73dcb39 + manifestUrl = "https://psgetregistry.azurecr.io/hello-world:3.0.0"; + //https://psgetregistry.azurecr.io/hello-world@sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"; + // Address by digest: [loginServerUrl]/ [repository@sha256][:digest] + + // eg: myregistry.azurecr.io/acr-helloworld@sha256:0a2e01852872580b2c2fea9380ff8d7b637d3928783c55beb3f21a6e58d5d108 + var defaultHeaders = GetDefaultHeaders(acrAccessToken); - return await GetHttpResponseJObject(manifestUrl, HttpMethod.Get, defaultHeaders); + return GetHttpResponseJObjectUsingDefaultHeaders(manifestUrl, HttpMethod.Get, defaultHeaders, out errRecord); } - internal static async Task GetAcrBlobAsync(string registry, string repositoryName, string digest, string acrAccessToken) + internal async Task GetAcrBlobAsync(string registry, string repositoryName, string digest, string acrAccessToken) { string blobUrl = string.Format(acrBlobDownloadUrlTemplate, registry, repositoryName, digest); var defaultHeaders = GetDefaultHeaders(acrAccessToken); return await GetHttpContentResponseJObject(blobUrl, defaultHeaders); } - internal static async Task FindAcrImageTags(string registry, string repositoryName, string version, string acrAccessToken) + internal JObject FindAcrImageTags(string registry, string repositoryName, string version, string acrAccessToken, out ErrorRecord errRecord) { try { string resolvedVersion = string.Equals(version, "*", StringComparison.OrdinalIgnoreCase) ? null : $"/{version}"; string findImageUrl = string.Format(acrFindImageVersionUrlTemplate, registry, repositoryName, resolvedVersion); var defaultHeaders = GetDefaultHeaders(acrAccessToken); - return await GetHttpResponseJObject(findImageUrl, HttpMethod.Get, defaultHeaders); + return GetHttpResponseJObjectUsingDefaultHeaders(findImageUrl, HttpMethod.Get, defaultHeaders, out errRecord); } catch (HttpRequestException e) { @@ -616,7 +665,7 @@ internal static async Task FindAcrImageTags(string registry, string rep } } - internal static async Task GetStartUploadBlobLocation(string registry, string pkgName, string acrAccessToken) + internal async Task GetStartUploadBlobLocation(string registry, string pkgName, string acrAccessToken) { try { @@ -630,7 +679,7 @@ internal static async Task GetStartUploadBlobLocation(string registry, s } } - internal static async Task EndUploadBlob(string registry, string location, string filePath, string digest, bool isManifest, string acrAccessToken) + internal async Task EndUploadBlob(string registry, string location, string filePath, string digest, bool isManifest, string acrAccessToken) { try { @@ -644,7 +693,7 @@ internal static async Task EndUploadBlob(string registry, string location, } } - internal static async Task CreateManifest(string registry, string pkgName, string pkgVersion, string configPath, bool isManifest, string acrAccessToken) + internal async Task CreateManifest(string registry, string pkgName, string pkgVersion, string configPath, bool isManifest, string acrAccessToken) { try { @@ -658,7 +707,7 @@ internal static async Task CreateManifest(string registry, string pkgName, } } - internal static async Task GetHttpContentResponseJObject(string url, Collection> defaultHeaders) + internal async Task GetHttpContentResponseJObject(string url, Collection> defaultHeaders) { try { @@ -672,24 +721,57 @@ internal static async Task GetHttpContentResponseJObject(string url } } - internal static async Task GetHttpResponseJObject(string url, HttpMethod method, Collection> defaultHeaders) + internal JObject GetHttpResponseJObjectUsingDefaultHeaders(string url, HttpMethod method, Collection> defaultHeaders, out ErrorRecord errRecord) { try { + errRecord = null; HttpRequestMessage request = new HttpRequestMessage(method, url); SetDefaultHeaders(defaultHeaders); - return await SendRequestAsync(request); + + return SendRequestAsync(request).GetAwaiter().GetResult(); + } + catch (ResourceNotFoundException e) + { + errRecord = new ErrorRecord( + exception: e, + "ResourceNotFound", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + catch (UnauthorizedException e) + { + errRecord = new ErrorRecord( + exception: e, + "UnauthorizedRequest", + ErrorCategory.InvalidResult, + _cmdletPassedIn); } catch (HttpRequestException e) { - throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + errRecord = new ErrorRecord( + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, + _cmdletPassedIn); } + catch (Exception e) + { + errRecord = new ErrorRecord( + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + + return null; } - internal static async Task GetHttpResponseJObject(string url, HttpMethod method, string content, Collection> contentHeaders) + internal JObject GetHttpResponseJObjectUsingContentHeaders(string url, HttpMethod method, string content, Collection> contentHeaders, out ErrorRecord errRecord) { try { + errRecord = null; HttpRequestMessage request = new HttpRequestMessage(method, url); if (string.IsNullOrEmpty(content)) @@ -707,12 +789,42 @@ internal static async Task GetHttpResponseJObject(string url, HttpMetho } } - return await SendRequestAsync(request); + return SendRequestAsync(request).GetAwaiter().GetResult(); + } + catch (ResourceNotFoundException e) + { + errRecord = new ErrorRecord( + exception: e, + "ResourceNotFound", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + catch (UnauthorizedException e) + { + errRecord = new ErrorRecord( + exception: e, + "UnauthorizedRequest", + ErrorCategory.InvalidResult, + _cmdletPassedIn); } catch (HttpRequestException e) { - throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + errRecord = new ErrorRecord( + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, + _cmdletPassedIn); } + catch (Exception e) + { + errRecord = new ErrorRecord( + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + + return null; } internal static async Task GetHttpResponseHeader(string url, HttpMethod method, Collection> defaultHeaders) @@ -771,7 +883,23 @@ private static async Task SendRequestAsync(HttpRequestMessage message) try { HttpResponseMessage response = await s_client.SendAsync(message); - response.EnsureSuccessStatusCode(); + + switch (response.StatusCode) + { + case HttpStatusCode.OK: + break; + + case HttpStatusCode.Unauthorized: + throw new UnauthorizedException($"Response unauthorized: {response.ReasonPhrase}."); + + case HttpStatusCode.NotFound: + throw new ResourceNotFoundException($"Package not found: {response.ReasonPhrase}."); + + // all other errors + default: + throw new HttpRequestException($"Response returned error with status code {response.StatusCode}: {response.ReasonPhrase}."); + } + return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); } catch (HttpRequestException e) @@ -832,9 +960,9 @@ private static Collection> GetDefaultHeaders(string }; } - private bool PushNupkgACR(string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, out ErrorRecord error) + private bool PushNupkgACR(string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, out ErrorRecord errRecord) { - error = null; + errRecord = null; // Push the nupkg to the appropriate repository var fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, pkgName + "." + pkgVersion.ToNormalizedString() + ".nupkg"); @@ -860,9 +988,9 @@ private bool PushNupkgACR(string outputNupkgDir, string pkgName, NuGetVersion pk string registry = repository.Uri.Host; _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; + var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); _cmdletPassedIn.WriteVerbose("Getting acr access token"); - var acrAccessToken = GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; + var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); _cmdletPassedIn.WriteVerbose("Start uploading blob"); var moduleLocation = GetStartUploadBlobLocation(registry, pkgName, acrAccessToken).Result; diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index da538f812..1459971a4 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -779,7 +779,9 @@ private Hashtable BeginPackageInstall( pkgToInstall.RepositorySourceLocation = repository.Uri.ToString(); pkgToInstall.AdditionalMetadata.TryGetValue("NormalizedVersion", out string pkgVersion); - + if (pkgVersion == null) { + pkgVersion = pkgToInstall.Version.ToString(); + } // Check to see if the pkg is already installed (ie the pkg is installed and the version satisfies the version range provided via param) if (!_reinstall) { diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index ce217db1a..643833640 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -796,6 +796,99 @@ public static bool TryConvertFromJson( } } + /// + /// Converts ACR JsonDocument entry to PSResourceInfo instance + /// used for ACR Server API call find response conversion to PSResourceInfo object + /// + public static bool TryConvertFromACRJson( + string packageName, + JsonDocument packageMetadata, + out PSResourceInfo psGetInfo, + PSRepositoryInfo repository, + out string errorMsg) + { + psGetInfo = null; + errorMsg = String.Empty; + + if (packageMetadata == null) + { + errorMsg = "TryConvertJsonToPSResourceInfo: Invalid json object. Object cannot be null."; + return false; + } + + try + { + Hashtable metadata = new Hashtable(StringComparer.InvariantCultureIgnoreCase); + JsonElement rootDom = packageMetadata.RootElement; + + // Version + if (rootDom.TryGetProperty("name", out JsonElement versionElement)) + { + string versionValue = versionElement.ToString(); + metadata["Version"] = ParseHttpVersion(versionValue, out string prereleaseLabel); + metadata["Prerelease"] = prereleaseLabel; + metadata["IsPrerelease"] = !String.IsNullOrEmpty(prereleaseLabel); + + if (!NuGetVersion.TryParse(versionValue, out NuGetVersion parsedNormalizedVersion)) + { + errorMsg = string.Format( + CultureInfo.InvariantCulture, + @"TryReadPSGetInfo: Cannot parse NormalizedVersion"); + + parsedNormalizedVersion = new NuGetVersion("1.0.0.0"); + } + + metadata["NormalizedVersion"] = parsedNormalizedVersion; + } + + // PublishedDate + if (rootDom.TryGetProperty("lastUpdateTime", out JsonElement publishedElement)) + { + metadata["PublishedDate"] = ParseHttpDateTime(publishedElement.ToString()); + } + + var additionalMetadataHashtable = new Dictionary { }; + + psGetInfo = new PSResourceInfo( + additionalMetadata: additionalMetadataHashtable, + author: string.Empty, + companyName: string.Empty, + copyright: string.Empty, + dependencies: new Dependency[] { }, + description: string.Empty, + iconUri: null, + includes: null, + installedDate: null, + installedLocation: null, + isPrerelease: (bool)metadata["IsPrerelease"], + licenseUri: null, + name: packageName, + powershellGetFormatVersion: null, + prerelease: metadata["Prerelease"] as String, + projectUri: null, + publishedDate: metadata["PublishedDate"] as DateTime?, + releaseNotes: string.Empty, + repository: repository.Name, + repositorySourceLocation: repository.Uri.ToString(), + tags: new string[] { }, + type: ResourceType.None, + updatedDate: null, + version: metadata["Version"] as Version); + + return true; + + } + catch (Exception ex) + { + errorMsg = string.Format( + CultureInfo.InvariantCulture, + @"TryConvertFromJson: Cannot parse PSResourceInfo from json object with error: {0}", + ex.Message); + + return false; + } + } + public static bool TryConvertFromHashtableForPsd1( Hashtable pkgMetadata, out PSResourceInfo psGetInfo, diff --git a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 new file mode 100644 index 000000000..ed7e4bfc8 --- /dev/null +++ b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 @@ -0,0 +1,142 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +# These tests are working with manual validation but there is currently no automated testing for ACR repositories. + +$modPath = "$psscriptroot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { + + BeforeAll{ + $testModuleName = "hello-world" + $ACRRepoName = "ACRRepo" + $ACRRepoUri = "https://psgetregistry.azurecr.io" + Get-NewPSResourceRepositoryFile + Register-PSResourceRepository -Name $ACRRepoName -Uri $ACRepoUri -ApiVersion "ACR" + } + + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + It "Find resource given specific Name, Version null" { + # FindName() + $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0" + } + + It "Find resource given specific Name, Version null" { + # FindName() + $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName -Prerelease + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0-alpha001" + } + + It "Should not find resource given nonexistant Name" { + # FindName() + $res = Find-PSResource -Name NonExistantModule -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "ACRPackageNotFoundFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $res | Should -BeNullOrEmpty + } + + $testCases2 = @{Version="[5.0.0.0]"; ExpectedVersions=@("5.0.0"); Reason="validate version, exact match"}, + @{Version="5.0.0.0"; ExpectedVersions=@("5.0.0"); Reason="validate version, exact match without bracket syntax"}, + @{Version="[1.0.0.0, 5.0.0.0]"; ExpectedVersions=@("1.0.0", "3.0.0", "5.0.0"); Reason="validate version, exact range inclusive"}, + @{Version="(1.0.0.0, 5.0.0.0)"; ExpectedVersions=@("3.0.0"); Reason="validate version, exact range exclusive"}, + @{Version="(1.0.0.0,)"; ExpectedVersions=@("3.0.0", "5.0.0"); Reason="validate version, minimum version exclusive"}, + @{Version="[1.0.0.0,)"; ExpectedVersions=@("1.0.0", "3.0.0", "5.0.0"); Reason="validate version, minimum version inclusive"}, + @{Version="(,3.0.0.0)"; ExpectedVersions=@("1.0.0"); Reason="validate version, maximum version exclusive"}, + @{Version="(,3.0.0.0]"; ExpectedVersions=@("1.0.0", "3.0.0"); Reason="validate version, maximum version inclusive"}, + @{Version="[1.0.0.0, 5.0.0.0)"; ExpectedVersions=@("1.0.0", "3.0.0"); Reason="validate version, mixed inclusive minimum and exclusive maximum version"} + @{Version="(1.0.0.0, 5.0.0.0]"; ExpectedVersions=@("3.0.0", "5.0.0"); Reason="validate version, mixed exclusive minimum and inclusive maximum version"} + + It "Find resource when given Name to " -TestCases $testCases2{ + # FindVersionGlobbing() + param($Version, $ExpectedVersions) + $res = Find-PSResource -Name $testModuleName -Version $Version -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + foreach ($item in $res) { + $item.Name | Should -Be $testModuleName + $ExpectedVersions | Should -Contain $item.Version + } + } + + It "Find all versions of resource when given specific Name, Version not null --> '*'" { + # FindVersionGlobbing() + $res = Find-PSResource -Name $testModuleName -Version "*" -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res | ForEach-Object { + $_.Name | Should -Be $testModuleName + } + + $res.Count | Should -BeGreaterOrEqual 1 + } + + It "Find resource with latest (including prerelease) version given Prerelease parameter" { + # FindName() + # test_local_mod resource's latest version is a prerelease version, before that it has a non-prerelease version + $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName + $res.Version | Should -Be "5.0.0" + + $resPrerelease = Find-PSResource -Name $testModuleName -Prerelease -Repository $ACRRepoName + $resPrerelease.Version | Should -Be "5.0.0" + $resPrerelease.Prerelease | Should -Be "alpha001" + } + + It "Find resources, including Prerelease version resources, when given Prerelease parameter" { + # FindVersionGlobbing() + $resWithoutPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $ACRRepoName + $resWithPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $ACRRepoName -Prerelease + $resWithPrerelease.Count | Should -BeGreaterOrEqual $resWithoutPrerelease.Count + } + + It "Should not find resource if Name, Version and Tag property are not all satisfied (single tag)" { + # FindVersionWithTag() + $requiredTag = "windows" # tag "windows" is not present for test_local_mod package + $res = Find-PSResource -Name $testModuleName -Version "5.0.0.0" -Tag $requiredTag -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindVersionWithTagFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should not find resources given Tag property" { + # FindTag() + $tagToFind = "Tag2" + $res = Find-PSResource -Tag $tagToFind -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindTagsFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should not find resource given CommandName" { + # FindCommandOrDSCResource() + $res = Find-PSResource -CommandName "command" -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + write-Host $($err[0].FullyQualifiedErrorId) + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindCommandOrDscResourceFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should not find resource given DscResourceName" { + # FindCommandOrDSCResource() + $res = Find-PSResource -DscResourceName "dscResource" -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindCommandOrDscResourceFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should not find all resources given Name '*'" { + # FindAll() + $res = Find-PSResource -Name "*" -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindAllFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } +} + +#> \ No newline at end of file From 1b142828e642e51169f7df1b8c391a41b206d49c Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 2 Jan 2024 12:21:32 -0800 Subject: [PATCH 014/160] ACR Integration: Publish-PSResource methods (#1501) --- src/code/ACRServerAPICalls.cs | 2 +- src/code/PublishPSResource.cs | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 2633e1aaa..279c77a91 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -960,7 +960,7 @@ private static Collection> GetDefaultHeaders(string }; } - private bool PushNupkgACR(string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, out ErrorRecord errRecord) + internal bool PushNupkgACR(string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, out ErrorRecord errRecord) { errRecord = null; // Push the nupkg to the appropriate repository diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index bd132fc6c..cb6fac1ae 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -15,8 +15,6 @@ using System.Management.Automation; using System.Net; using System.Net.Http; -using System.Security.Cryptography; -using System.Text; using System.Threading; using System.Xml; @@ -138,6 +136,8 @@ public PSCredential ProxyCredential { private string pathToModuleDirToPublish = string.Empty; private ResourceType resourceType = ResourceType.None; private NetworkCredential _networkCredential; + string userAgentString = UserAgentInfo.UserAgentString(); + #endregion #region Method overrides @@ -482,16 +482,16 @@ out string[] _ string repositoryUri = repository.Uri.AbsoluteUri; - if (repository.RepositoryProvider == PSRepositoryInfo.RepositoryProviderType.ACR) + if (repository.ApiVersion == PSRepositoryInfo.APIVersion.acr) { - // TODO: Create instance of ACR server class and call PushNupkgACR - /* - if (!PushNupkgACR(outputNupkgDir, repository, out ErrorRecord pushNupkgACRError)) + ACRServerAPICalls acrServer = new ACRServerAPICalls(repository, this, _networkCredential, userAgentString); + + if (!acrServer.PushNupkgACR(outputNupkgDir, _pkgName, _pkgVersion, repository, out ErrorRecord pushNupkgACRError)) { WriteError(pushNupkgACRError); + // exit out of processing return; } - */ } else { From 48c0ee7424ab731a11748da6249337c7b424b3b1 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:48:00 -0800 Subject: [PATCH 015/160] Bug fix for publishing a module that is not all lowercase to ACR repo (#1502) --- src/code/PublishPSResource.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index cb6fac1ae..53c12f2b7 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -486,7 +486,7 @@ out string[] _ { ACRServerAPICalls acrServer = new ACRServerAPICalls(repository, this, _networkCredential, userAgentString); - if (!acrServer.PushNupkgACR(outputNupkgDir, _pkgName, _pkgVersion, repository, out ErrorRecord pushNupkgACRError)) + if (!acrServer.PushNupkgACR(outputNupkgDir, _pkgName.ToLower(), _pkgVersion, repository, out ErrorRecord pushNupkgACRError)) { WriteError(pushNupkgACRError); // exit out of processing From cf60367d7d8c92b6879a45373e0a96e0d3d7ee72 Mon Sep 17 00:00:00 2001 From: Sean Williams <72675818+sean-r-williams@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:53:22 -0800 Subject: [PATCH 016/160] TryConvertFromXml: Prevent NRE when NormalizedVersion is missing (#1503) --- src/code/PSResourceInfo.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 643833640..50bcb6360 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -571,7 +571,11 @@ public static bool TryConvertFromXml( }; var additionalMetadataHashtable = new Dictionary(); - additionalMetadataHashtable.Add("NormalizedVersion", metadata["NormalizedVersion"].ToString()); + + // Only add NormalizedVersion to additionalMetadata if server response included it + if (metadata.ContainsKey("NormalizedVersion")) { + additionalMetadataHashtable.Add("NormalizedVersion", metadata["NormalizedVersion"].ToString()); + } var includes = new ResourceIncludes(resourceHashtable); From e511b71bc64b87de8206e2c25763dca2907ba7a2 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Fri, 5 Jan 2024 12:01:22 -0500 Subject: [PATCH 017/160] Add Server API implementation for ACR install version functionality (#1505) * get install working for version with 4 digits (last digit non-zero) * code cleanup * code cleanup --- src/code/ACRServerAPICalls.cs | 147 ++++++++++++++++++++++++++++++---- 1 file changed, 131 insertions(+), 16 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 279c77a91..0d2ad65ae 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -536,24 +536,22 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// public override Stream InstallPackage(string packageName, string packageVersion, bool includePrerelease, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In V3ServerAPICalls::InstallPackage()"); + _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::InstallPackage()"); Stream results = new MemoryStream(); if (string.IsNullOrEmpty(packageVersion)) { - results = InstallName(packageName, packageVersion, out errRecord); + results = InstallName(packageName, out errRecord); } else { - results = InstallName(packageName, packageVersion, out errRecord); + results = InstallVersion(packageName, packageVersion, out errRecord); } return results; } - private Stream InstallName( string moduleName, - string moduleVersion, out ErrorRecord errRecord) { errRecord = null; @@ -561,8 +559,8 @@ private Stream InstallName( string tenantID = string.Empty; string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(tempPath); + string moduleVersion = String.Empty; - // Need to set up secret management vault before hand var repositoryCredentialInfo = Repository.CredentialInfo; if (repositoryCredentialInfo != null) { @@ -582,14 +580,96 @@ private Stream InstallName( _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); + if (errRecord != null) + { + return null; + } + _cmdletPassedIn.WriteVerbose("Getting acr access token"); var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + if (errRecord != null) + { + return null; + } + _cmdletPassedIn.WriteVerbose($"Getting manifest for {moduleName} - {moduleVersion}"); var manifest = GetAcrRepositoryManifestAsync(registry, moduleName, moduleVersion, acrAccessToken, out errRecord); - var digest = "sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"; + if (errRecord != null) + { + return null; + } + + string digest = GetDigestFromManifest(manifest, out errRecord); + if (errRecord != null) + { + return null; + } + _cmdletPassedIn.WriteVerbose($"Downloading blob for {moduleName} - {moduleVersion}"); + // TODO: error handling here? var responseContent = GetAcrBlobAsync(registry, moduleName, digest, acrAccessToken).Result; + return responseContent.ReadAsStreamAsync().Result; + } + + private Stream InstallVersion( + string moduleName, + string moduleVersion, + out ErrorRecord errRecord) + { + errRecord = null; + string accessToken = string.Empty; + string tenantID = string.Empty; + string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempPath); + + var repositoryCredentialInfo = Repository.CredentialInfo; + if (repositoryCredentialInfo != null) + { + accessToken = Utils.GetACRAccessTokenFromSecretManagement( + Repository.Name, + repositoryCredentialInfo, + _cmdletPassedIn); + + _cmdletPassedIn.WriteVerbose("Access token retrieved."); + + tenantID = repositoryCredentialInfo.SecretName; + _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); + } + + // Call asynchronous network methods in a try/catch block to handle exceptions. + string registry = Repository.Uri.Host; + + _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); + var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); + if (errRecord != null) + { + return null; + } + + _cmdletPassedIn.WriteVerbose("Getting acr access token"); + var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + if (errRecord != null) + { + return null; + } + + _cmdletPassedIn.WriteVerbose($"Getting manifest for {moduleName} - {moduleVersion}"); + var manifest = GetAcrRepositoryManifestAsync(registry, moduleName, moduleVersion, acrAccessToken, out errRecord); + if (errRecord != null) + { + return null; + } + + string digest = GetDigestFromManifest(manifest, out errRecord); + if (errRecord != null) + { + return null; + } + + _cmdletPassedIn.WriteVerbose($"Downloading blob for {moduleName} - {moduleVersion}"); + // TODO: error handling here? + var responseContent = GetAcrBlobAsync(registry, moduleName, digest, acrAccessToken).Result; return responseContent.ReadAsStreamAsync().Result; } @@ -598,6 +678,46 @@ private Stream InstallName( #region Private Methods + private string GetDigestFromManifest(JObject manifest, out ErrorRecord errRecord) + { + errRecord = null; + string digest = String.Empty; + + if (manifest == null) + { + errRecord = new ErrorRecord( + exception: new ArgumentNullException("Manifest (passed in to determine digest) is null."), + "ManifestNullError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return digest; + } + + JToken layers = manifest["layers"]; + if (layers == null || !layers.HasValues) + { + errRecord = new ErrorRecord( + exception: new ArgumentNullException("Manifest 'layers' property (passed in to determine digest) is null or does not have values."), + "ManifestLayersNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return digest; + } + + foreach (JObject item in layers) + { + if (item.ContainsKey("digest")) + { + digest = item.GetValue("digest").ToString(); + break; + } + } + + return digest; + } + internal string GetAcrRefreshToken(string registry, string tenant, string accessToken, out ErrorRecord errRecord) { string content = string.Format(acrRefreshTokenTemplate, registry, tenant, accessToken); @@ -628,16 +748,11 @@ internal string GetAcrAccessToken(string registry, string refreshToken, out Erro return string.Empty; } - internal JObject GetAcrRepositoryManifestAsync(string registry, string repositoryName, string version, string acrAccessToken, out ErrorRecord errRecord) + internal JObject GetAcrRepositoryManifestAsync(string registry, string packageName, string version, string acrAccessToken, out ErrorRecord errRecord) { - string manifestUrl = string.Format(acrManifestUrlTemplate, registry, repositoryName, version); - - // GET acrapi.azurecr-test.io/v2/prod/bash/blobs/sha256:16463e0c481e161aabb735437d30b3c9c7391c2747cc564bb927e843b73dcb39 - manifestUrl = "https://psgetregistry.azurecr.io/hello-world:3.0.0"; - //https://psgetregistry.azurecr.io/hello-world@sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"; - // Address by digest: [loginServerUrl]/ [repository@sha256][:digest] - - // eg: myregistry.azurecr.io/acr-helloworld@sha256:0a2e01852872580b2c2fea9380ff8d7b637d3928783c55beb3f21a6e58d5d108 + // the packageName parameter here maps to repositoryName in ACR, but to not conflict with PSGet definition of repository we will call it packageName + // example of manifestUrl: https://psgetregistry.azurecr.io/hello-world:3.0.0 + string manifestUrl = string.Format(acrManifestUrlTemplate, registry, packageName, version); var defaultHeaders = GetDefaultHeaders(acrAccessToken); return GetHttpResponseJObjectUsingDefaultHeaders(manifestUrl, HttpMethod.Get, defaultHeaders, out errRecord); From c64719ed3f35c31255de275ec119aebe34b5e13e Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Mon, 8 Jan 2024 14:14:35 -0500 Subject: [PATCH 018/160] Remove InstallName() (#1506) * remove InstallName dead code * code cleanup --- src/code/ACRServerAPICalls.cs | 75 ++++----------------------------- src/code/LocalServerApiCalls.cs | 14 ++++-- src/code/NuGetServerAPICalls.cs | 14 ++++-- src/code/V2ServerAPICalls.cs | 37 +++++----------- src/code/V3ServerAPICalls.cs | 15 ++++--- 5 files changed, 49 insertions(+), 106 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 0d2ad65ae..2f2d3114a 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -530,6 +530,8 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// /// Installs a specific package. + /// User may request to install package with or without providing version (as seen in examples below), but prior to calling this method the package is located and package version determined. + /// Therefore, package version should not be null in this method. /// Name: no wildcard support. /// Examples: Install "PowerShellGet" /// Install "PowerShellGet" -Version "3.0.0" @@ -540,76 +542,17 @@ public override Stream InstallPackage(string packageName, string packageVersion, Stream results = new MemoryStream(); if (string.IsNullOrEmpty(packageVersion)) { - results = InstallName(packageName, out errRecord); - } - else - { - results = InstallVersion(packageName, packageVersion, out errRecord); - } - - return results; - } - - private Stream InstallName( - string moduleName, - out ErrorRecord errRecord) - { - errRecord = null; - string accessToken = string.Empty; - string tenantID = string.Empty; - string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - Directory.CreateDirectory(tempPath); - string moduleVersion = String.Empty; - - var repositoryCredentialInfo = Repository.CredentialInfo; - if (repositoryCredentialInfo != null) - { - accessToken = Utils.GetACRAccessTokenFromSecretManagement( - Repository.Name, - repositoryCredentialInfo, + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Package version could not be found for {packageName}"), + "PackageVersionNullOrEmptyError", + ErrorCategory.InvalidArgument, _cmdletPassedIn); - _cmdletPassedIn.WriteVerbose("Access token retrieved."); - - tenantID = repositoryCredentialInfo.SecretName; - _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); - } - - // Call asynchronous network methods in a try/catch block to handle exceptions. - string registry = Repository.Uri.Host; - - _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); - if (errRecord != null) - { - return null; - } - - _cmdletPassedIn.WriteVerbose("Getting acr access token"); - var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); - if (errRecord != null) - { - return null; - } - - _cmdletPassedIn.WriteVerbose($"Getting manifest for {moduleName} - {moduleVersion}"); - var manifest = GetAcrRepositoryManifestAsync(registry, moduleName, moduleVersion, acrAccessToken, out errRecord); - if (errRecord != null) - { - return null; - } - - string digest = GetDigestFromManifest(manifest, out errRecord); - if (errRecord != null) - { - return null; + return results; } - _cmdletPassedIn.WriteVerbose($"Downloading blob for {moduleName} - {moduleVersion}"); - // TODO: error handling here? - var responseContent = GetAcrBlobAsync(registry, moduleName, digest, acrAccessToken).Result; - - return responseContent.ReadAsStreamAsync().Result; + results = InstallVersion(packageName, packageVersion, out errRecord); + return results; } private Stream InstallVersion( diff --git a/src/code/LocalServerApiCalls.cs b/src/code/LocalServerApiCalls.cs index 2bd862be3..c32f3db57 100644 --- a/src/code/LocalServerApiCalls.cs +++ b/src/code/LocalServerApiCalls.cs @@ -218,6 +218,8 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// /// Installs a specific package. + /// User may request to install package with or without providing version (as seen in examples below), but prior to calling this method the package is located and package version determined. + /// Therefore, package version should not be null in this method. /// Name: no wildcard support. /// Examples: Install "PowerShellGet" /// Install "PowerShellGet" -Version "3.0.0" @@ -227,12 +229,16 @@ public override Stream InstallPackage(string packageName, string packageVersion, Stream results = new MemoryStream(); if (string.IsNullOrEmpty(packageVersion)) { - results = InstallName(packageName, includePrerelease, out errRecord); - } - else { - results = InstallVersion(packageName, packageVersion, out errRecord); + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Package version could not be found for {packageName}"), + "PackageVersionNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return results; } + results = InstallVersion(packageName, packageVersion, out errRecord); return results; } diff --git a/src/code/NuGetServerAPICalls.cs b/src/code/NuGetServerAPICalls.cs index 7748695c6..73b7dfa93 100644 --- a/src/code/NuGetServerAPICalls.cs +++ b/src/code/NuGetServerAPICalls.cs @@ -394,6 +394,8 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// /// Installs a specific package. + /// User may request to install package with or without providing version (as seen in examples below), but prior to calling this method the package is located and package version determined. + /// Therefore, package version should not be null in this method. /// Name: no wildcard support. /// Examples: Install "PowerShellGet" /// Install "PowerShellGet" -Version "3.0.0" @@ -403,12 +405,16 @@ public override Stream InstallPackage(string packageName, string packageVersion, Stream results = new MemoryStream(); if (string.IsNullOrEmpty(packageVersion)) { - results = InstallName(packageName, out errRecord); - } - else { - results = InstallVersion(packageName, packageVersion, out errRecord); + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Package version could not be found for {packageName}"), + "PackageVersionNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return results; } + results = InstallVersion(packageName, packageVersion, out errRecord); return results; } diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index d0a766377..c492e58aa 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -666,6 +666,8 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// /// Installs a specific package. + /// User may request to install package with or without providing version (as seen in examples below), but prior to calling this method the package is located and package version determined. + /// Therefore, package version should not be null in this method. /// Name: no wildcard support. /// Examples: Install "PowerShellGet" /// Install "PowerShellGet" -Version "3.0.0" @@ -675,13 +677,16 @@ public override Stream InstallPackage(string packageName, string packageVersion, Stream results = new MemoryStream(); if (string.IsNullOrEmpty(packageVersion)) { - results = InstallName(packageName, out errRecord); - } - else - { - results = InstallVersion(packageName, packageVersion, out errRecord); + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Package version could not be found for {packageName}"), + "PackageVersionNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return results; } + results = InstallVersion(packageName, packageVersion, out errRecord); return results; } @@ -1112,28 +1117,6 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange return HttpRequestCall(requestUrlV2, out errRecord); } - /// - /// Installs specific package. - /// Name: no wildcard support. - /// Examples: Install "PowerShellGet" - /// Implementation Note: if not prerelease: https://www.powershellgallery.com/api/v2/package/powershellget (Returns latest stable) - /// if prerelease, call into InstallVersion instead. - /// - private Stream InstallName(string packageName, out ErrorRecord errRecord) - { - _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::InstallName()"); - var requestUrlV2 = $"{Repository.Uri}/package/{packageName}"; - var response = HttpRequestCallForContent(requestUrlV2, out errRecord); - if (errRecord != null) - { - return new MemoryStream(); - } - - var responseStream = response.ReadAsStreamAsync().Result; - - return responseStream; - } - /// /// Installs package with specific name and version. /// Name: no wildcard support. diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index dec0da5ea..fd7c39e63 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -285,6 +285,8 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// /// Installs a specific package. + /// User may request to install package with or without providing version (as seen in examples below), but prior to calling this method the package is located and package version determined. + /// Therefore, package version should not be null in this method. /// Name: no wildcard support. /// Examples: Install "PowerShellGet" /// Install "PowerShellGet" -Version "3.0.0" @@ -295,13 +297,16 @@ public override Stream InstallPackage(string packageName, string packageVersion, Stream results = new MemoryStream(); if (string.IsNullOrEmpty(packageVersion)) { - results = InstallName(packageName, out errRecord); - } - else - { - results = InstallVersion(packageName, packageVersion, out errRecord); + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Package version could not be found for {packageName}"), + "PackageVersionNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return results; } + results = InstallVersion(packageName, packageVersion, out errRecord); return results; } From 56428a480f11bd48274a2ee723965599e45a004c Mon Sep 17 00:00:00 2001 From: Sydney Smith <43417619+SydneyhSmith@users.noreply.github.com> Date: Wed, 17 Jan 2024 10:16:48 -0800 Subject: [PATCH 019/160] Create owners.txt (#1523) --- tool/owners.txt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tool/owners.txt diff --git a/tool/owners.txt b/tool/owners.txt new file mode 100644 index 000000000..9fc01f3f2 --- /dev/null +++ b/tool/owners.txt @@ -0,0 +1,16 @@ +; this is an example owners.txt file which can be added at any level in the repo to indicate owners of a subtree +; this file is used by ownership enforcer to determine reviewers to add to a pull request +; you can add comments using ; as prefix +; introduce each owner in a separate line with his/her alias (not email address) +; prefixing an alias with * means that the owner will not be automatically added as a reviewer to pull requests to reduce noise, but can still be manually added and can sign off if necessary +; to learn more you can read https://microsoft.sharepoint.com/teams/WAG/EngSys/EngPipeline/cdp/SitePages/Configure%20checkin%20gates.aspx +; if you do not wish to use this feature then you can delete this file +; example (pretend the following lines are not commented): +; +; developer1 +; developer2 +; *developer3 +annavied +americks +adityap +*slee From d0bf74e5d0a4c5c2d42604896e80751e12a0169e Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 17 Jan 2024 16:21:43 -0800 Subject: [PATCH 020/160] Add protection against empty response from server (#1525) --- src/code/NuGetServerAPICalls.cs | 104 +++++++------ src/code/V2ServerAPICalls.cs | 165 +++++++++++---------- src/code/V3ServerAPICalls.cs | 255 +++++++++++++++++--------------- 3 files changed, 282 insertions(+), 242 deletions(-) diff --git a/src/code/NuGetServerAPICalls.cs b/src/code/NuGetServerAPICalls.cs index 73b7dfa93..2c5df2a5b 100644 --- a/src/code/NuGetServerAPICalls.cs +++ b/src/code/NuGetServerAPICalls.cs @@ -143,9 +143,9 @@ public override FindResults FindTags(string[] tags, bool includePrerelease, Reso public override FindResults FindCommandOrDscResource(string[] tags, bool includePrerelease, bool isSearchingForCommands, out ErrorRecord errRecord) { errRecord = new ErrorRecord( - new InvalidOperationException($"Find by CommandName or DSCResource is not supported for the repository '{Repository.Name}'"), - "FindCommandOrDscResourceFailure", - ErrorCategory.InvalidOperation, + new InvalidOperationException($"Find by CommandName or DSCResource is not supported for the repository '{Repository.Name}'"), + "FindCommandOrDscResourceFailure", + ErrorCategory.InvalidOperation, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: FindResponseType); @@ -437,25 +437,25 @@ private string HttpRequestCall(string requestUrl, out ErrorRecord errRecord) catch (HttpRequestException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFallFailure", - ErrorCategory.ConnectionError, + exception: e, + "HttpRequestFallFailure", + ErrorCategory.ConnectionError, this); } catch (ArgumentNullException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFallFailure", + exception: e, + "HttpRequestFallFailure", ErrorCategory.ConnectionError, this); } catch (InvalidOperationException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFallFailure", - ErrorCategory.ConnectionError, + exception: e, + "HttpRequestFallFailure", + ErrorCategory.ConnectionError, this); } @@ -486,25 +486,25 @@ private HttpContent HttpRequestCallForContent(string requestUrl, out ErrorRecord catch (HttpRequestException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.ConnectionError , + exception: e, + "HttpRequestFailure", + ErrorCategory.ConnectionError , this); } catch (ArgumentNullException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.InvalidData, + exception: e, + "HttpRequestFailure", + ErrorCategory.InvalidData, this); } catch (InvalidOperationException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.InvalidOperation, + exception: e, + "HttpRequestFailure", + ErrorCategory.InvalidOperation, this); } @@ -571,9 +571,9 @@ private string FindNameGlobbing(string packageName, bool includePrerelease, int if (names.Length == 0) { errRecord = new ErrorRecord( - new ArgumentException("-Name '*' for NuGet.Server hosted feed repository is not supported"), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name '*' for NuGet.Server hosted feed repository is not supported"), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -607,9 +607,9 @@ private string FindNameGlobbing(string packageName, bool includePrerelease, int else { errRecord = new ErrorRecord( - new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -638,9 +638,9 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, bool i if (names.Length == 0) { errRecord = new ErrorRecord( - new ArgumentException("-Name '*' for NuGet.Server hosted feed repository is not supported"), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name '*' for NuGet.Server hosted feed repository is not supported"), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -674,9 +674,9 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, bool i else { errRecord = new ErrorRecord( - new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), - "FindNameGlobbing", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), + "FindNameGlobbing", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -786,16 +786,26 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange /// Name: no wildcard support. /// Examples: Install "PowerShellGet" /// Implementation Note: {repoUri}/Packages(Id='test_local_mod')/Download - /// if prerelease, call into InstallVersion instead. + /// if prerelease, call into InstallVersion instead. /// private Stream InstallName(string packageName, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::InstallName()"); var requestUrl = $"{Repository.Uri}/Packages/(Id='{packageName}')/Download"; var response = HttpRequestCallForContent(requestUrl, out errRecord); - var responseStream = response.ReadAsStreamAsync().Result; - return responseStream; + if (response is null) + { + errRecord = new ErrorRecord( + new Exception($"No content was returned by repository '{Repository.Name}'"), + "InstallFailureContentNullNuGetServer", + ErrorCategory.InvalidResult, + this); + + return null; + } + + return response.ReadAsStreamAsync().Result; } /// @@ -805,15 +815,25 @@ private Stream InstallName(string packageName, out ErrorRecord errRecord) /// Examples: Install "PowerShellGet" -Version "3.0.0.0" /// Install "PowerShellGet" -Version "3.0.0-beta16" /// API Call: {repoUri}/Packages(Id='Castle.Core',Version='5.1.1')/Download - /// + /// private Stream InstallVersion(string packageName, string version, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::InstallVersion()"); var requestUrl = $"{Repository.Uri}/Packages(Id='{packageName}',Version='{version}')/Download"; var response = HttpRequestCallForContent(requestUrl, out errRecord); - var responseStream = response.ReadAsStreamAsync().Result; - return responseStream; + if (response is null) + { + errRecord = new ErrorRecord( + new Exception($"No content was returned by repository '{Repository.Name}'"), + "InstallFailureContentNullNuGetServer", + ErrorCategory.InvalidResult, + this); + + return null; + } + + return response.ReadAsStreamAsync().Result; } /// @@ -835,9 +855,9 @@ public int GetCountFromResponse(string httpResponse, out ErrorRecord errRecord) catch (XmlException e) { errRecord = new ErrorRecord( - exception: e, - "GetCountFromResponse", - ErrorCategory.InvalidData, + exception: e, + "GetCountFromResponse", + ErrorCategory.InvalidData, this); } if (errRecord != null) @@ -892,7 +912,7 @@ public static async Task SendRequestForContentAsync(HttpRequestMess { HttpResponseMessage response = await s_client.SendAsync(message); response.EnsureSuccessStatusCode(); - + return response.Content; } catch (HttpRequestException e) diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index c492e58aa..1cf0d123a 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -240,9 +240,9 @@ public override FindResults FindTags(string[] tags, bool includePrerelease, Reso if (responses.Count == 0) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with Tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageWithSpecifiedTagsNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with Tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageWithSpecifiedTagsNotFound", + ErrorCategory.ObjectNotFound, this); } @@ -294,9 +294,9 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include { string parameterForErrorMsg = isSearchingForCommands ? "Command" : "DSC Resource"; errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with {parameterForErrorMsg} '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageWithSpecifiedCmdOrDSCNotFound", - ErrorCategory.InvalidResult, + new ResourceNotFoundException($"Package with {parameterForErrorMsg} '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageWithSpecifiedCmdOrDSCNotFound", + ErrorCategory.InvalidResult, this); } @@ -330,21 +330,21 @@ public override FindResults FindName(string packageName, bool includePrerelease, string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = GetCountFromResponse(response, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } if (count == 0) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); response = string.Empty; } @@ -382,21 +382,21 @@ public override FindResults FindNameWithTag(string packageName, string[] tags, b string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = GetCountFromResponse(response, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } if (count == 0) { errRecord = new ErrorRecord( new ResourceNotFoundException($"Package with name '{packageName}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); response = string.Empty; } @@ -436,7 +436,7 @@ public override FindResults FindNameGlobbing(string packageName, bool includePre // If count is 0, early out as this means no packages matching search criteria were found. We want to set the responses array to empty and not set ErrorRecord (as is a globbing scenario). if (initialCount == 0) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = (int)Math.Ceiling((double)(initialCount / 100)); @@ -488,7 +488,7 @@ public override FindResults FindNameGlobbingWithTag(string packageName, string[] if (initialCount == 0) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = (int)Math.Ceiling((double)(initialCount / 100)); @@ -540,7 +540,7 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange if (initialCount == 0) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } responses.Add(initialResponse); @@ -589,7 +589,7 @@ public override FindResults FindVersion(string packageName, string version, Reso string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = GetCountFromResponse(response, out errRecord); @@ -597,15 +597,15 @@ public override FindResults FindVersion(string packageName, string version, Reso if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } if (count == 0) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); response = string.Empty; } @@ -638,7 +638,7 @@ public override FindResults FindVersionWithTag(string packageName, string versio string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = GetCountFromResponse(response, out errRecord); @@ -646,19 +646,19 @@ public override FindResults FindVersionWithTag(string packageName, string versio if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } if (count == 0) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); response = string.Empty; } - + return new FindResults(stringResponse: new string[] { response }, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } @@ -709,33 +709,33 @@ private string HttpRequestCall(string requestUrlV2, out ErrorRecord errRecord) catch (ResourceNotFoundException e) { errRecord = new ErrorRecord( - exception: e, - "ResourceNotFound", - ErrorCategory.InvalidResult, + exception: e, + "ResourceNotFound", + ErrorCategory.InvalidResult, this); } catch (UnauthorizedException e) { errRecord = new ErrorRecord( - exception: e, - "UnauthorizedRequest", - ErrorCategory.InvalidResult, + exception: e, + "UnauthorizedRequest", + ErrorCategory.InvalidResult, this); } catch (HttpRequestException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestCallFailure", - ErrorCategory.ConnectionError, + exception: e, + "HttpRequestCallFailure", + ErrorCategory.ConnectionError, this); } catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestCallFailure", - ErrorCategory.ConnectionError, + exception: e, + "HttpRequestCallFailure", + ErrorCategory.ConnectionError, this); } @@ -766,25 +766,25 @@ private HttpContent HttpRequestCallForContent(string requestUrlV2, out ErrorReco catch (HttpRequestException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.ConnectionError, + exception: e, + "HttpRequestFailure", + ErrorCategory.ConnectionError, this); } catch (ArgumentNullException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.InvalidData, + exception: e, + "HttpRequestFailure", + ErrorCategory.InvalidData, this); } catch (InvalidOperationException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.InvalidOperation, + exception: e, + "HttpRequestFailure", + ErrorCategory.InvalidOperation, this); } @@ -792,7 +792,7 @@ private HttpContent HttpRequestCallForContent(string requestUrlV2, out ErrorReco { _cmdletPassedIn.WriteDebug("Response is empty"); } - + return content; } @@ -838,7 +838,7 @@ private string FindTagFromEndpoint(string[] tags, bool includePrerelease, bool i } var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?{prereleaseFilter}{typeFilterPart}{tagFilterPart}{paginationParam}"; - + return HttpRequestCall(requestUrlV2: requestUrlV2, out errRecord); } @@ -865,7 +865,7 @@ private string FindCommandOrDscResource(string[] tags, bool includePrerelease, b } var requestUrlV2 = $"{Repository.Uri}/Search()?{prereleaseFilter}&searchTerm='{tagSearchTermPart}'{paginationParam}"; - + return HttpRequestCall(requestUrlV2, out errRecord); } @@ -887,9 +887,9 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl if (names.Length == 0) { errRecord = new ErrorRecord( - new ArgumentException("-Name '*' for V2 server protocol repositories is not supported"), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name '*' for V2 server protocol repositories is not supported"), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -923,9 +923,9 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl else { errRecord = new ErrorRecord( - new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -933,7 +933,7 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl string typeFilterPart = GetTypeFilterForRequest(type); var requestUrlV2 = $"{Repository.Uri}/Search()?$filter={nameFilter}{typeFilterPart} and {prerelease}{extraParam}"; - + return HttpRequestCall(requestUrlV2, out errRecord); } @@ -955,9 +955,9 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour if (names.Length == 0) { errRecord = new ErrorRecord( - new ArgumentException("-Name '*' for V2 server protocol repositories is not supported"), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name '*' for V2 server protocol repositories is not supported"), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -991,9 +991,9 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour else { errRecord = new ErrorRecord( - new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), - "FindNameGlobbing", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), + "FindNameGlobbing", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -1007,7 +1007,7 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour string typeFilterPart = GetTypeFilterForRequest(type); var requestUrlV2 = $"{Repository.Uri}/Search()?$filter={nameFilter}{tagFilterPart}{typeFilterPart} and {prerelease}{extraParam}"; - + return HttpRequestCall(requestUrlV2, out errRecord); } @@ -1059,7 +1059,7 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange // because we want to retrieve all the prerelease versions for the upper end of the range // and PSGallery views prerelease as higher than its stable. // eg 3.0.0-prerelease > 3.0.0 - // If looking for versions within '[1.9.9,1.9.9]' including prerelease values, this will change it to search for '[1.9.9,1.9.99]' + // If looking for versions within '[1.9.9,1.9.9]' including prerelease values, this will change it to search for '[1.9.9,1.9.99]' // and find any pkg versions that are 1.9.9-prerelease. string maxString = includePrerelease ? $"{versionRange.MaxVersion.Major}.{versionRange.MaxVersion.Minor}.{versionRange.MaxVersion.Patch.ToString() + "9"}" : $"{versionRange.MaxVersion.ToNormalizedString()}"; @@ -1067,7 +1067,7 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange { maxPart = String.Format(format, operation, $"'{maxVersion.ToNormalizedString()}'"); } - else { + else { maxPart = String.Format(format, operation, $"'{versionRange.MaxVersion.ToNormalizedString()}'"); } } @@ -1113,7 +1113,7 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange filterQuery = filterQuery.EndsWith("=") ? string.Empty : filterQuery; var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$orderby=NormalizedVersion desc&{paginationParam}{filterQuery}"; - + return HttpRequestCall(requestUrlV2, out errRecord); } @@ -1146,13 +1146,24 @@ private Stream InstallVersion(string packageName, string version, out ErrorRecor } var response = HttpRequestCallForContent(requestUrlV2, out errRecord); - var responseStream = response.ReadAsStreamAsync().Result; + if (errRecord != null) { return new MemoryStream(); } - return responseStream; + if (response is null) + { + errRecord = new ErrorRecord( + new Exception($"No content was returned by repository '{Repository.Name}'"), + "InstallFailureContentNullv2", + ErrorCategory.InvalidResult, + this); + + return null; + } + + return response.ReadAsStreamAsync().Result; } private string GetTypeFilterForRequest(ResourceType type) { @@ -1205,7 +1216,7 @@ public int GetCountFromResponse(string httpResponse, out ErrorRecord errRecord) countSearchSucceeded = int.TryParse(node.InnerText, out count); } } - + if (!countSearchSucceeded) { // Note: not all V2 servers may have the 'count' property implemented or valid (i.e CloudSmith server), in this case try to get 'd:Id' property. @@ -1217,16 +1228,16 @@ public int GetCountFromResponse(string httpResponse, out ErrorRecord errRecord) } else { - _cmdletPassedIn.WriteDebug($"Property 'count' and 'd:Id' could not be found in response. This may indicate that the package could not be found"); + _cmdletPassedIn.WriteDebug($"Property 'count' and 'd:Id' could not be found in response. This may indicate that the package could not be found"); } } } catch (XmlException e) { errRecord = new ErrorRecord( - exception: e, - "GetCountFromResponse", - ErrorCategory.InvalidData, + exception: e, + "GetCountFromResponse", + ErrorCategory.InvalidData, this); } diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index fd7c39e63..b7f580f2a 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -78,8 +78,8 @@ public override FindResults FindAll(bool includePrerelease, ResourceType type, o _cmdletPassedIn.WriteDebug("In V3ServerAPICalls::FindAll()"); errRecord = new ErrorRecord( new InvalidOperationException($"Find all is not supported for the V3 server protocol repository '{Repository.Name}'"), - "FindAllFailure", - ErrorCategory.InvalidOperation, + "FindAllFailure", + ErrorCategory.InvalidOperation, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -116,9 +116,9 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include { _cmdletPassedIn.WriteDebug("In V3ServerAPICalls::FindCommandOrDscResource()"); errRecord = new ErrorRecord( - new InvalidOperationException($"Find by CommandName or DSCResource is not supported for the V3 server protocol repository '{Repository.Name}'"), - "FindCommandOrDscResourceFailure", - ErrorCategory.InvalidOperation, + new InvalidOperationException($"Find by CommandName or DSCResource is not supported for the V3 server protocol repository '{Repository.Name}'"), + "FindCommandOrDscResourceFailure", + ErrorCategory.InvalidOperation, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -162,9 +162,9 @@ public override FindResults FindNameGlobbing(string packageName, bool includePre else { errRecord = new ErrorRecord( - new InvalidOperationException($"Find with Name containing wildcards is not supported for the V3 server protocol repository '{Repository.Name}'"), - "FindNameGlobbingFailure", - ErrorCategory.InvalidOperation, + new InvalidOperationException($"Find with Name containing wildcards is not supported for the V3 server protocol repository '{Repository.Name}'"), + "FindNameGlobbingFailure", + ErrorCategory.InvalidOperation, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -185,9 +185,9 @@ public override FindResults FindNameGlobbingWithTag(string packageName, string[] else { errRecord = new ErrorRecord( - new InvalidOperationException($"Find with Name containing wildcards is not supported for the V3 server protocol repository '{Repository.Name}'"), - "FindNameGlobbingWithTagFailure", - ErrorCategory.InvalidOperation, + new InvalidOperationException($"Find with Name containing wildcards is not supported for the V3 server protocol repository '{Repository.Name}'"), + "FindNameGlobbingWithTagFailure", + ErrorCategory.InvalidOperation, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -222,9 +222,9 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange if (!rootDom.TryGetProperty(versionName, out JsonElement pkgVersionElement)) { errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element."), - "FindVersionGlobbingFailure", - ErrorCategory.InvalidData, + new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element."), + "FindVersionGlobbingFailure", + ErrorCategory.InvalidData, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -243,9 +243,9 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange catch (Exception e) { errRecord = new ErrorRecord( - exception: e, + exception: e, "FindVersionGlobbingFailure", - ErrorCategory.InvalidResult, + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -327,8 +327,8 @@ private FindResults FindNameGlobbingFromNuGetRepo(string packageName, string[] t { errRecord = new ErrorRecord( new ArgumentException("-Name '*' for V3 server protocol repositories is not supported"), - "FindNameGlobbingFromNuGetRepoFailure", - ErrorCategory.InvalidArgument, + "FindNameGlobbingFromNuGetRepoFailure", + ErrorCategory.InvalidArgument, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -384,9 +384,9 @@ private FindResults FindNameGlobbingFromNuGetRepo(string packageName, string[] t if (!pkgEntry.TryGetProperty(tagsName, out JsonElement tagsItem)) { errRecord = new ErrorRecord( - new JsonParsingException("FindNameGlobbing(): Tags element could not be found in response."), - "GetEntriesFromSearchQueryResourceFailure", - ErrorCategory.InvalidResult, + new JsonParsingException("FindNameGlobbing(): Tags element could not be found in response."), + "GetEntriesFromSearchQueryResourceFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -415,8 +415,8 @@ private FindResults FindNameGlobbingFromNuGetRepo(string packageName, string[] t { errRecord = new ErrorRecord( exception: e, - "GetEntriesFromSearchQueryResourceFailure", - ErrorCategory.InvalidResult, + "GetEntriesFromSearchQueryResourceFailure", + ErrorCategory.InvalidResult, this); break; @@ -444,9 +444,9 @@ private FindResults FindTagsFromNuGetRepo(string[] tags, bool includePrerelease, if (tagPkgEntries.Count == 0) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with Tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageWithSpecifiedTagsNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with Tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageWithSpecifiedTagsNotFound", + ErrorCategory.ObjectNotFound, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -485,9 +485,9 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu if (!rootDom.TryGetProperty(versionName, out JsonElement pkgVersionElement)) { errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element for search with Name '{packageName}' in '{Repository.Name}'."), - "FindNameFailure", - ErrorCategory.InvalidResult, + new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element for search with Name '{packageName}' in '{Repository.Name}'."), + "FindNameFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -495,9 +495,9 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu if (!rootDom.TryGetProperty(tagsName, out JsonElement tagsItem)) { errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain '{tagsName}' element for search with Name '{packageName}' in '{Repository.Name}'."), - "FindNameFailure", - ErrorCategory.InvalidResult, + new InvalidOrEmptyResponse($"Response does not contain '{tagsName}' element for search with Name '{packageName}' in '{Repository.Name}'."), + "FindNameFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -520,9 +520,9 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "FindNameFailure", - ErrorCategory.InvalidResult, + exception: e, + "FindNameFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -532,9 +532,9 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu if (String.IsNullOrEmpty(latestVersionResponse)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -546,9 +546,9 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu if (errRecord == null) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); } @@ -567,9 +567,9 @@ private FindResults FindVersionHelper(string packageName, string version, string if (!NuGetVersion.TryParse(version, out NuGetVersion requiredVersion)) { errRecord = new ErrorRecord( - new ArgumentException($"Version {version} to be found is not a valid NuGet version."), - "FindNameFailure", - ErrorCategory.InvalidArgument, + new ArgumentException($"Version {version} to be found is not a valid NuGet version."), + "FindNameFailure", + ErrorCategory.InvalidArgument, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -595,9 +595,9 @@ private FindResults FindVersionHelper(string packageName, string version, string if (!rootDom.TryGetProperty(versionName, out JsonElement pkgVersionElement)) { errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element for search with name '{packageName}' and version '{version}' in repository '{Repository.Name}'."), - "FindVersionFailure", - ErrorCategory.InvalidResult, + new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element for search with name '{packageName}' and version '{version}' in repository '{Repository.Name}'."), + "FindVersionFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -605,9 +605,9 @@ private FindResults FindVersionHelper(string packageName, string version, string if (!rootDom.TryGetProperty(tagsName, out JsonElement tagsItem)) { errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain '{tagsName}' element for search with name '{packageName}' and version '{version}' in repository '{Repository.Name}'."), - "FindVersionFailure", - ErrorCategory.InvalidResult, + new InvalidOrEmptyResponse($"Response does not contain '{tagsName}' element for search with name '{packageName}' and version '{version}' in repository '{Repository.Name}'."), + "FindVersionFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -627,9 +627,9 @@ private FindResults FindVersionHelper(string packageName, string version, string catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "FindVersionFailure", - ErrorCategory.InvalidResult, + exception: e, + "FindVersionFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -639,9 +639,9 @@ private FindResults FindVersionHelper(string packageName, string version, string if (String.IsNullOrEmpty(latestVersionResponse)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -652,9 +652,9 @@ private FindResults FindVersionHelper(string packageName, string version, string if (errRecord == null) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"FindVersion(): Package with name '{packageName}', version '{version}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"FindVersion(): Package with name '{packageName}', version '{version}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); } @@ -722,9 +722,9 @@ private Stream InstallHelper(string packageName, NuGetVersion version, out Error if (versionedResponses.Length == 0) { errRecord = new ErrorRecord( - new Exception($"Package with name '{packageName}' and version '{version}' could not be found in repository '{Repository.Name}'"), - "InstallFailure", - ErrorCategory.InvalidResult, + new Exception($"Package with name '{packageName}' and version '{version}' could not be found in repository '{Repository.Name}'"), + "InstallFailure", + ErrorCategory.InvalidResult, this); return null; @@ -753,9 +753,9 @@ private Stream InstallHelper(string packageName, NuGetVersion version, out Error if (String.IsNullOrEmpty(pkgContentUrl)) { errRecord = new ErrorRecord( - new Exception($"Package with name '{packageName}' and version '{version}' could not be found in repository '{Repository.Name}'"), - "InstallFailure", - ErrorCategory.InvalidResult, + new Exception($"Package with name '{packageName}' and version '{version}' could not be found in repository '{Repository.Name}'"), + "InstallFailure", + ErrorCategory.InvalidResult, this); return null; @@ -767,9 +767,18 @@ private Stream InstallHelper(string packageName, NuGetVersion version, out Error return null; } - pkgStream = content.ReadAsStreamAsync().Result; + if (content is null) + { + errRecord = new ErrorRecord( + new Exception($"No content was returned by repository '{Repository.Name}'"), + "InstallFailureContentNullv3", + ErrorCategory.InvalidResult, + this); - return pkgStream; + return null; + } + + return content.ReadAsStreamAsync().Result; } /// @@ -865,8 +874,8 @@ private Dictionary GetResourcesFromServiceIndex(out ErrorRecord if (!resource.TryGetProperty("@type", out JsonElement typeElement)) { errRecord = new ErrorRecord( - new JsonParsingException($"@type element not found for resource in service index for repository '{Repository.Name}'"), "GetResourcesFromServiceIndexFailure", - ErrorCategory.InvalidResult, + new JsonParsingException($"@type element not found for resource in service index for repository '{Repository.Name}'"), "GetResourcesFromServiceIndexFailure", + ErrorCategory.InvalidResult, this); return new Dictionary(); @@ -875,9 +884,9 @@ private Dictionary GetResourcesFromServiceIndex(out ErrorRecord if (!resource.TryGetProperty("@id", out JsonElement idElement)) { errRecord = new ErrorRecord( - new JsonParsingException($"@id element not found for resource in service index for repository '{Repository.Name}'"), - "GetResourcesFromServiceIndexFailure", - ErrorCategory.InvalidResult, + new JsonParsingException($"@id element not found for resource in service index for repository '{Repository.Name}'"), + "GetResourcesFromServiceIndexFailure", + ErrorCategory.InvalidResult, this); return new Dictionary(); @@ -892,9 +901,9 @@ private Dictionary GetResourcesFromServiceIndex(out ErrorRecord catch (Exception e) { errRecord = new ErrorRecord( - new Exception($"Exception parsing service index JSON for respository '{Repository.Name}' with error: {e.Message}"), - "GetResourcesFromServiceIndexFailure", - ErrorCategory.InvalidResult, + new Exception($"Exception parsing service index JSON for respository '{Repository.Name}' with error: {e.Message}"), + "GetResourcesFromServiceIndexFailure", + ErrorCategory.InvalidResult, this); return new Dictionary(); @@ -946,9 +955,9 @@ private string FindRegistrationsBaseUrl(Dictionary resources, ou else { errRecord = new ErrorRecord( - new ResourceNotFoundException($"RegistrationBaseUrl resource could not be found for repository '{Repository.Name}'"), - "FindRegistrationsBaseUrlFailure", - ErrorCategory.InvalidResult, + new ResourceNotFoundException($"RegistrationBaseUrl resource could not be found for repository '{Repository.Name}'"), + "FindRegistrationsBaseUrlFailure", + ErrorCategory.InvalidResult, this); } @@ -984,9 +993,9 @@ private string FindSearchQueryService(Dictionary resources, out else { errRecord = new ErrorRecord( - new ResourceNotFoundException($"SearchQueryService resource could not be found for Repository '{Repository.Name}'"), - "FindSearchQueryServiceFailure", - ErrorCategory.InvalidResult, + new ResourceNotFoundException($"SearchQueryService resource could not be found for Repository '{Repository.Name}'"), + "FindSearchQueryServiceFailure", + ErrorCategory.InvalidResult, this); } @@ -1010,9 +1019,9 @@ private JsonElement[] GetMetadataElementFromIdLinkElement(JsonElement idLinkElem { if (errRecord.Exception is ResourceNotFoundException) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'.", errRecord.Exception), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'.", errRecord.Exception), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); } @@ -1027,9 +1036,9 @@ private JsonElement[] GetMetadataElementFromIdLinkElement(JsonElement idLinkElem if (!rootDom.TryGetProperty(itemsName, out JsonElement innerItemsElement)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"'{itemsName}' element for package with name '{packageName}' could not be found in JFrog repository '{Repository.Name}'"), - "GetElementForJFrogRepoFailure", - ErrorCategory.InvalidResult, + new ResourceNotFoundException($"'{itemsName}' element for package with name '{packageName}' could not be found in JFrog repository '{Repository.Name}'"), + "GetElementForJFrogRepoFailure", + ErrorCategory.InvalidResult, this); return innerItems; @@ -1054,9 +1063,9 @@ private JsonElement[] GetMetadataElementFromIdLinkElement(JsonElement idLinkElem catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "MetadataElementForIdElementRetrievalFailure", - ErrorCategory.InvalidResult, + exception: e, + "MetadataElementForIdElementRetrievalFailure", + ErrorCategory.InvalidResult, this); } @@ -1221,7 +1230,7 @@ private string[] GetMetadataElementsFromResponse(string response, string propert return versionedPkgResponses.ToArray(); } - + /// /// Helper method iterates through the entries in the registrationsUrl for a specific package and all its versions. /// This contains an inner items element (containing the package metadata) and the packageContent element (containing URI through which the .nupkg can be downloaded) @@ -1242,9 +1251,9 @@ private string[] GetVersionedResponsesFromRegistrationsResource(string registrat if (errRecord.Exception is ResourceNotFoundException) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'.", errRecord.Exception), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'.", errRecord.Exception), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); } @@ -1320,7 +1329,7 @@ private bool IsLatestVersionFirstForSearch(string[] versionedResponses, out Erro return latestVersionFirst; } - + string firstVersion = firstVersionElement.ToString(); if (!NuGetVersion.TryParse(firstVersion, out firstPkgVersion)) @@ -1348,7 +1357,7 @@ private bool IsLatestVersionFirstForSearch(string[] versionedResponses, out Erro return latestVersionFirst; } - + string lastVersion = lastVersionElement.ToString(); if (!NuGetVersion.TryParse(lastVersion, out lastPkgVersion)) @@ -1371,9 +1380,9 @@ private bool IsLatestVersionFirstForSearch(string[] versionedResponses, out Erro catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "LatestVersionFirstSearchFailure", - ErrorCategory.InvalidResult, + exception: e, + "LatestVersionFirstSearchFailure", + ErrorCategory.InvalidResult, this); return true; @@ -1440,11 +1449,11 @@ private bool IsRequiredTagSatisfied(JsonElement tagsElement, string[] tags, out catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "GetResponsesFromRegistrationsResourceFailure", - ErrorCategory.InvalidResult, + exception: e, + "GetResponsesFromRegistrationsResourceFailure", + ErrorCategory.InvalidResult, this); - + return false; } @@ -1503,17 +1512,17 @@ private JsonElement[] GetJsonElementArr(string request, string propertyName, out { // scenario where the feed is not active anymore, i.e confirmed for JFrogArtifactory. The default error message is not intuitive. errRecord = new ErrorRecord( - exception: new Exception($"JSON response from repository {Repository.Name} could not be parsed, likely due to the feed being inactive or invalid, with inner exception: {e.Message}"), + exception: new Exception($"JSON response from repository {Repository.Name} could not be parsed, likely due to the feed being inactive or invalid, with inner exception: {e.Message}"), "FindVersionGlobbingFailure", - ErrorCategory.InvalidResult, + ErrorCategory.InvalidResult, this); } catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "GetResponsesFromRegistrationsResourceFailure", - ErrorCategory.InvalidResult, + exception: e, + "GetResponsesFromRegistrationsResourceFailure", + ErrorCategory.InvalidResult, this); } @@ -1539,33 +1548,33 @@ private string HttpRequestCall(string requestUrlV3, out ErrorRecord errRecord) catch (ResourceNotFoundException e) { errRecord = new ErrorRecord( - exception: e, - "ResourceNotFound", - ErrorCategory.InvalidResult, + exception: e, + "ResourceNotFound", + ErrorCategory.InvalidResult, this); } catch (UnauthorizedException e) { errRecord = new ErrorRecord( - exception: e, - "UnauthorizedRequest", - ErrorCategory.InvalidResult, + exception: e, + "UnauthorizedRequest", + ErrorCategory.InvalidResult, this); } catch (HttpRequestException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestCallFailure", - ErrorCategory.InvalidResult, + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, this); } catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestCallFailure", - ErrorCategory.InvalidResult, + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, this); } @@ -1590,9 +1599,9 @@ private HttpContent HttpRequestCallForContent(string requestUrlV3, out ErrorReco catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestCallForContentFailure", - ErrorCategory.InvalidResult, + exception: e, + "HttpRequestCallForContentFailure", + ErrorCategory.InvalidResult, this); } From 132cf6562d67ef9808d7c71df300c740851932d8 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:04:13 -0800 Subject: [PATCH 021/160] Bug fix for non-PSGallery repos adding script endpoint (#1526) --- src/code/V2ServerAPICalls.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index 1cf0d123a..e762bf89f 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -41,6 +41,7 @@ internal class V2ServerAPICalls : ServerApiCall public FindResponseType v2FindResponseType = FindResponseType.ResponseString; private bool _isADORepo; private bool _isJFrogRepo; + private bool _isPSGalleryRepo; #endregion @@ -60,6 +61,7 @@ public V2ServerAPICalls (PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, N var repoURL = repository.Uri.ToString().ToLower(); _isADORepo = repoURL.Contains("pkgs.dev.azure.com") || repoURL.Contains("pkgs.visualstudio.com"); _isJFrogRepo = repoURL.Contains("jfrog"); + _isPSGalleryRepo = repoURL.Contains("powershellgallery.com/api/v2"); } #endregion @@ -806,7 +808,7 @@ private HttpContent HttpRequestCallForContent(string requestUrlV2, out ErrorReco private string FindAllFromTypeEndPoint(bool includePrerelease, bool isSearchingModule, int skip, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindAllFromTypeEndPoint()"); - string typeEndpoint = isSearchingModule ? String.Empty : "/items/psscript"; + string typeEndpoint = _isPSGalleryRepo && !isSearchingModule ? "/items/psscript" : String.Empty; string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; var prereleaseFilter = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; @@ -826,7 +828,7 @@ private string FindTagFromEndpoint(string[] tags, bool includePrerelease, bool i // type: S -> just search Scripts end point // type: DSCResource -> just search Modules // type: Command -> just search Modules - string typeEndpoint = isSearchingModule ? String.Empty : "/items/psscript"; + string typeEndpoint = _isPSGalleryRepo && !isSearchingModule ? "/items/psscript" : String.Empty; string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; var prereleaseFilter = includePrerelease ? "includePrerelease=true&$filter=IsAbsoluteLatestVersion" : "$filter=IsLatestVersion"; string typeFilterPart = isSearchingModule ? $" and substringof('PSModule', Tags) eq true" : $" and substringof('PSScript', Tags) eq true"; From 00bf0dabf96965a560afc1b43d6634071cf71cdd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:12:37 -0800 Subject: [PATCH 022/160] Bump Microsoft.PowerShell.SDK in /test/perf/benchmarks (#1509) --- test/perf/benchmarks/benchmarks.csproj | 50 +++++++++++++------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/test/perf/benchmarks/benchmarks.csproj b/test/perf/benchmarks/benchmarks.csproj index 2acd07179..18c0281f8 100644 --- a/test/perf/benchmarks/benchmarks.csproj +++ b/test/perf/benchmarks/benchmarks.csproj @@ -1,25 +1,25 @@ - - - net6.0 - Exe - - - AnyCPU - pdbonly - true - true - true - Release - false - - - - - - - - - - - - + + + net6.0 + Exe + + + AnyCPU + pdbonly + true + true + true + Release + false + + + + + + + + + + + + From 90d3e7bf494d572f99b058b9b664bc944f995737 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:13:50 -0800 Subject: [PATCH 023/160] Bump System.Text.Json from 6.0.0 to 8.0.0 in /src/code (#1475) --- src/code/Microsoft.PowerShell.PSResourceGet.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index f98880c3f..a092b6ff4 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -22,7 +22,7 @@ - + From 3ed566bbf0502814db37e7f635cdb8f6a7a0af09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:15:17 -0800 Subject: [PATCH 024/160] Bump NuGet.Protocol from 6.7.0 to 6.8.0 in /src/code (#1480) --- src/code/Microsoft.PowerShell.PSResourceGet.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index a092b6ff4..369b4c8ea 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -19,7 +19,7 @@ - + From 67f0cc9ccb00fca678ee0d9b24ff28ad6fd9d6f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:16:31 -0800 Subject: [PATCH 025/160] Bump NuGet.Packaging from 6.7.0 to 6.8.0 in /src/code (#1481) --- src/code/Microsoft.PowerShell.PSResourceGet.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index 369b4c8ea..6da0cb5f5 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -17,7 +17,7 @@ - + From f7ff91ed7292388422cd8f28ae99aff1de28dde7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:16:58 -0800 Subject: [PATCH 026/160] Bump NuGet.Common from 6.7.0 to 6.8.0 in /src/code (#1482) --- src/code/Microsoft.PowerShell.PSResourceGet.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index 6da0cb5f5..327668107 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -15,7 +15,7 @@ - + From 936c0498b2be6af477f7ce650bc9f3d139acafd5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:17:30 -0800 Subject: [PATCH 027/160] Bump BenchmarkDotNet from 0.13.9 to 0.13.12 in /test/perf/benchmarks (#1508) --- test/perf/benchmarks/benchmarks.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/perf/benchmarks/benchmarks.csproj b/test/perf/benchmarks/benchmarks.csproj index 18c0281f8..2517ece2e 100644 --- a/test/perf/benchmarks/benchmarks.csproj +++ b/test/perf/benchmarks/benchmarks.csproj @@ -18,7 +18,7 @@ - + From 45bebb73ee5cde66105ba217433b9b5cd113a504 Mon Sep 17 00:00:00 2001 From: Alex Hoosyny <156423644+NextGData@users.noreply.github.com> Date: Mon, 22 Jan 2024 20:27:46 +0200 Subject: [PATCH 028/160] Update InstallHelper.cs (#1510) --- src/code/InstallHelper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 1459971a4..46b02e365 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -1340,8 +1340,8 @@ private bool CallAcceptLicense(PSResourceInfo p, string moduleManifest, string t // Otherwise read LicenseFile string licenseText = System.IO.File.ReadAllText(LicenseFilePath); - var acceptanceLicenseQuery = $"Do you accept the license terms for module '{p.Name}'."; - var message = licenseText + "`r`n" + acceptanceLicenseQuery; + var acceptanceLicenseQuery = $"Do you accept the license terms for module '{p.Name}'?"; + var message = licenseText + "\r\n" + acceptanceLicenseQuery; var title = "License Acceptance"; var yesToAll = false; From fdb7de54d0517f955ce40dd1c510c428ce8f9316 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:50:03 -0800 Subject: [PATCH 029/160] Bump BenchmarkDotNet.Diagnostics.Windows in /test/perf/benchmarks (#1528) --- test/perf/benchmarks/benchmarks.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/perf/benchmarks/benchmarks.csproj b/test/perf/benchmarks/benchmarks.csproj index 2517ece2e..2a6f727cb 100644 --- a/test/perf/benchmarks/benchmarks.csproj +++ b/test/perf/benchmarks/benchmarks.csproj @@ -19,7 +19,7 @@ - + From fd64ce5e523fcfee712e95b477682fe5c159a4ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 11:01:56 -0800 Subject: [PATCH 030/160] Bump NuGet.Commands from 6.7.0 to 6.8.0 in /src/code (#1483) --- src/code/Microsoft.PowerShell.PSResourceGet.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index 327668107..ca3a7d8e2 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -14,11 +14,11 @@ - + - + - + From 79665491c6ebdbdcd315f8b76a3fd386194d5efb Mon Sep 17 00:00:00 2001 From: Sean Williams <72675818+sean-r-williams@users.noreply.github.com> Date: Mon, 22 Jan 2024 21:12:23 -0800 Subject: [PATCH 031/160] enable isJFrogRepo flag for domains containing `artifactory` (#1532) --- src/code/V2ServerAPICalls.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index e762bf89f..af6be1cc4 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -60,7 +60,7 @@ public V2ServerAPICalls (PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, N _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); var repoURL = repository.Uri.ToString().ToLower(); _isADORepo = repoURL.Contains("pkgs.dev.azure.com") || repoURL.Contains("pkgs.visualstudio.com"); - _isJFrogRepo = repoURL.Contains("jfrog"); + _isJFrogRepo = repoURL.Contains("jfrog") || repoURL.Contains("artifactory"); _isPSGalleryRepo = repoURL.Contains("powershellgallery.com/api/v2"); } From f6a4ab905d89e48f814a83e8a10322a06b8a094a Mon Sep 17 00:00:00 2001 From: Sean Williams <72675818+sean-r-williams@users.noreply.github.com> Date: Mon, 22 Jan 2024 21:12:35 -0800 Subject: [PATCH 032/160] Add empty `searchTerm` param for JFrog Artifactory calls (#1533) --- src/code/V2ServerAPICalls.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index af6be1cc4..48f143aa5 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -810,9 +810,11 @@ private string FindAllFromTypeEndPoint(bool includePrerelease, bool isSearchingM _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindAllFromTypeEndPoint()"); string typeEndpoint = _isPSGalleryRepo && !isSearchingModule ? "/items/psscript" : String.Empty; string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; + // JFrog/Artifactory requires an empty search term to enumerate all packages in the feed + string searchTerm = _isJFrogRepo ? "&searchTerm=''" : ""; var prereleaseFilter = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; - var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?$filter={prereleaseFilter}{paginationParam}"; + var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?$filter={prereleaseFilter}{searchTerm}{paginationParam}"; return HttpRequestCall(requestUrlV2, out errRecord); } @@ -830,6 +832,8 @@ private string FindTagFromEndpoint(string[] tags, bool includePrerelease, bool i // type: Command -> just search Modules string typeEndpoint = _isPSGalleryRepo && !isSearchingModule ? "/items/psscript" : String.Empty; string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; + // JFrog/Artifactory requires an empty search term to enumerate all packages in the feed + string searchTerm = _isJFrogRepo ? "&searchTerm=''" : ""; var prereleaseFilter = includePrerelease ? "includePrerelease=true&$filter=IsAbsoluteLatestVersion" : "$filter=IsLatestVersion"; string typeFilterPart = isSearchingModule ? $" and substringof('PSModule', Tags) eq true" : $" and substringof('PSScript', Tags) eq true"; @@ -839,7 +843,7 @@ private string FindTagFromEndpoint(string[] tags, bool includePrerelease, bool i tagFilterPart += $" and substringof('{tag}', Tags) eq true"; } - var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?{prereleaseFilter}{typeFilterPart}{tagFilterPart}{paginationParam}"; + var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?{prereleaseFilter}{searchTerm}{typeFilterPart}{tagFilterPart}{paginationParam}"; return HttpRequestCall(requestUrlV2: requestUrlV2, out errRecord); } From 17a61782d9751a25a58503ebf93bacd8a4d3da32 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 23 Jan 2024 11:04:53 -0800 Subject: [PATCH 033/160] ACR Integration: Add JSON metadata to package manifest in ACR registries (#1522) --- src/code/ACRServerAPICalls.cs | 116 ++++++++++++++++++++++------------ src/code/PublishPSResource.cs | 3 +- 2 files changed, 79 insertions(+), 40 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 2f2d3114a..6ebf93564 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -603,7 +603,6 @@ private Stream InstallVersion( { return null; } - string digest = GetDigestFromManifest(manifest, out errRecord); if (errRecord != null) { @@ -1018,7 +1017,7 @@ private static Collection> GetDefaultHeaders(string }; } - internal bool PushNupkgACR(string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, out ErrorRecord errRecord) + internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, Hashtable parsedMetadataHash, out ErrorRecord errRecord) { errRecord = null; // Push the nupkg to the appropriate repository @@ -1050,42 +1049,19 @@ internal bool PushNupkgACR(string outputNupkgDir, string pkgName, NuGetVersion p _cmdletPassedIn.WriteVerbose("Getting acr access token"); var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + /* Uploading .nupkg */ _cmdletPassedIn.WriteVerbose("Start uploading blob"); var moduleLocation = GetStartUploadBlobLocation(registry, pkgName, acrAccessToken).Result; _cmdletPassedIn.WriteVerbose("Computing digest for .nupkg file"); - bool digestCreated = CreateDigest(fullNupkgFile, out string digest, out ErrorRecord digestError); - if (!digestCreated) + bool nupkgDigestCreated = CreateDigest(fullNupkgFile, out string nupkgDigest, out ErrorRecord nupkgDigestError); + if (!nupkgDigestCreated) { - _cmdletPassedIn.ThrowTerminatingError(digestError); + _cmdletPassedIn.ThrowTerminatingError(nupkgDigestError); } _cmdletPassedIn.WriteVerbose("Finish uploading blob"); - bool moduleUploadSuccess = EndUploadBlob(registry, moduleLocation, fullNupkgFile, digest, false, acrAccessToken).Result; - - _cmdletPassedIn.WriteVerbose("Create an empty file"); - string emptyFileName = "empty.txt"; - var emptyFilePath = System.IO.Path.Combine(outputNupkgDir, emptyFileName); - // Rename the empty file in case such a file already exists in the temp folder (although highly unlikely) - while (File.Exists(emptyFilePath)) - { - emptyFilePath = Guid.NewGuid().ToString() + ".txt"; - } - FileStream emptyStream = File.Create(emptyFilePath); - emptyStream.Close(); - - _cmdletPassedIn.WriteVerbose("Start uploading an empty file"); - var emptyLocation = GetStartUploadBlobLocation(registry, pkgName, acrAccessToken).Result; - - _cmdletPassedIn.WriteVerbose("Computing digest for empty file"); - bool emptyDigestCreated = CreateDigest(emptyFilePath, out string emptyDigest, out ErrorRecord emptyDigestError); - if (!emptyDigestCreated) - { - _cmdletPassedIn.ThrowTerminatingError(emptyDigestError); - } - - _cmdletPassedIn.WriteVerbose("Finish uploading empty file"); - bool emptyFileUploadSuccess = EndUploadBlob(registry, emptyLocation, emptyFilePath, emptyDigest, false, acrAccessToken).Result; + bool moduleUploadSuccess = EndUploadBlob(registry, moduleLocation, fullNupkgFile, nupkgDigest, false, acrAccessToken).Result; _cmdletPassedIn.WriteVerbose("Create the config file"); string configFileName = "config.json"; @@ -1096,11 +1072,25 @@ internal bool PushNupkgACR(string outputNupkgDir, string pkgName, NuGetVersion p } FileStream configStream = File.Create(configFilePath); configStream.Close(); + _cmdletPassedIn.WriteVerbose("Computing digest for config"); + bool configDigestCreated = CreateDigest(configFilePath, out string configDigest, out ErrorRecord configDigestError); + if (!configDigestCreated) + { + _cmdletPassedIn.ThrowTerminatingError(configDigestError); + } + + /* Create manifest layer */ + _cmdletPassedIn.WriteVerbose("Create package version metadata as JSON string"); + string jsonString = CreateMetadataContent(psd1OrPs1File, parsedMetadataHash, out ErrorRecord metadataCreationError); + if (metadataCreationError != null) + { + _cmdletPassedIn.ThrowTerminatingError(metadataCreationError); + } FileInfo nupkgFile = new FileInfo(fullNupkgFile); var fileSize = nupkgFile.Length; var fileName = System.IO.Path.GetFileName(fullNupkgFile); - string fileContent = CreateJsonContent(digest, emptyDigest, fileSize, fileName); + string fileContent = CreateJsonContent(nupkgDigest, configDigest, fileSize, fileName, jsonString); File.WriteAllText(configFilePath, fileContent); _cmdletPassedIn.WriteVerbose("Create the manifest layer"); @@ -1113,7 +1103,12 @@ internal bool PushNupkgACR(string outputNupkgDir, string pkgName, NuGetVersion p return false; } - private string CreateJsonContent(string digest, string emptyDigest, long fileSize, string fileName) + private string CreateJsonContent( + string nupkgDigest, + string configDigest, + long nupkgFileSize, + string fileName, + string jsonString) { StringBuilder stringBuilder = new StringBuilder(); StringWriter stringWriter = new StringWriter(stringBuilder); @@ -1131,31 +1126,31 @@ private string CreateJsonContent(string digest, string emptyDigest, long fileSiz jsonWriter.WritePropertyName("mediaType"); jsonWriter.WriteValue("application/vnd.unknown.config.v1+json"); jsonWriter.WritePropertyName("digest"); - jsonWriter.WriteValue($"sha256:{emptyDigest}"); + jsonWriter.WriteValue($"sha256:{configDigest}"); jsonWriter.WritePropertyName("size"); jsonWriter.WriteValue(0); jsonWriter.WriteEndObject(); jsonWriter.WritePropertyName("layers"); jsonWriter.WriteStartArray(); - jsonWriter.WriteStartObject(); + jsonWriter.WriteStartObject(); jsonWriter.WritePropertyName("mediaType"); jsonWriter.WriteValue("application/vnd.oci.image.layer.nondistributable.v1.tar+gzip'"); jsonWriter.WritePropertyName("digest"); - jsonWriter.WriteValue($"sha256:{digest}"); + jsonWriter.WriteValue($"sha256:{nupkgDigest}"); jsonWriter.WritePropertyName("size"); - jsonWriter.WriteValue(fileSize); + jsonWriter.WriteValue(nupkgFileSize); jsonWriter.WritePropertyName("annotations"); - jsonWriter.WriteStartObject(); jsonWriter.WritePropertyName("org.opencontainers.image.title"); jsonWriter.WriteValue(fileName); + jsonWriter.WritePropertyName("metadata"); + jsonWriter.WriteValue(jsonString); jsonWriter.WriteEndObject(); - jsonWriter.WriteEndObject(); - jsonWriter.WriteEndArray(); + jsonWriter.WriteEndArray(); jsonWriter.WriteEndObject(); return stringWriter.ToString(); @@ -1205,6 +1200,49 @@ private bool CreateDigest(string fileName, out string digest, out ErrorRecord er return true; } + private string CreateMetadataContent(string manifestFilePath, Hashtable parsedMetadata, out ErrorRecord metadataCreationError) + { + metadataCreationError = null; + Hashtable parsedMetadataHash = null; + string jsonString = string.Empty; + + // A script will already have the metadata parsed into the parsedMetadatahash, + // a module will still need the module manifest to be parsed. + if (parsedMetadata == null || parsedMetadata.Count == 0) + { + // Use the parsed module manifest data as 'parsedMetadataHash' instead of the passed-in data. + if (!Utils.TryReadManifestFile( + manifestFilePath: manifestFilePath, + manifestInfo: out parsedMetadataHash, + error: out Exception manifestReadError)) + { + metadataCreationError = new ErrorRecord( + manifestReadError, + "ManifestFileReadParseForACRPublishError", + ErrorCategory.ReadError, + _cmdletPassedIn); + + return jsonString; + } + } + + if (parsedMetadataHash == null) + { + metadataCreationError = new ErrorRecord( + new InvalidOperationException("Error parsing package metadata into hashtable."), + "PackageMetadataHashEmptyError", + ErrorCategory.InvalidData, + _cmdletPassedIn); + + return jsonString; + } + + _cmdletPassedIn.WriteVerbose("Serialize JSON into string."); + jsonString = System.Text.Json.JsonSerializer.Serialize(parsedMetadataHash); + + return jsonString; + } + #endregion } } diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index 53c12f2b7..afc012653 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -486,7 +486,8 @@ out string[] _ { ACRServerAPICalls acrServer = new ACRServerAPICalls(repository, this, _networkCredential, userAgentString); - if (!acrServer.PushNupkgACR(outputNupkgDir, _pkgName.ToLower(), _pkgVersion, repository, out ErrorRecord pushNupkgACRError)) + var pkgMetadataFile = (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish; + if (!acrServer.PushNupkgACR(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, repository, parsedMetadata, out ErrorRecord pushNupkgACRError)) { WriteError(pushNupkgACRError); // exit out of processing From 0fc7827ac3426ed96fab9eece0bf3bce34c84341 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Wed, 24 Jan 2024 13:20:34 -0800 Subject: [PATCH 034/160] Fix 'name' bug with v2 JFrog Artifactory (#1535) --- src/code/PSResourceInfo.cs | 123 +++++++++++++++++++------------------ src/code/V2ResponseUtil.cs | 10 +-- 2 files changed, 68 insertions(+), 65 deletions(-) diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 50bcb6360..63bad8eaa 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -490,76 +490,79 @@ public static bool TryConvertFromXml( { Hashtable metadata = new Hashtable(StringComparer.InvariantCultureIgnoreCase); - var childNodes = entry.ChildNodes; - foreach (XmlElement child in childNodes) + var entryChildNodes = entry.ChildNodes; + foreach (XmlElement entryChild in entryChildNodes) { - var key = child.LocalName; - var value = child.InnerText; + var entryKey = entryChild.LocalName; - if (key.Equals("Title")) + // For repositories such as JFrog's Artifactory, there is no 'Id' property, just 'title' (which contains the name of the pkg). + // However, other repos, like PSGallery include the name of the pkg in the 'Id' property and leave 'title' empty. + // In JFrog's Artifactory, 'title' exists both as a child of the 'entry' node and as a child of the 'properties' node, + // though sometimes 'title' under the 'properties' node can be empty (so default to using the former). + if (entryKey.Equals("title")) { - // For repositories such as JFrog's Artifactory, there is no 'Id' property, just 'Title' (which contains the name of the pkg). - // However, other repos, like PSGallery include the name of the pkg in the 'Id' property and leave 'Title' empty. - - // First check to see that both 'Title' and 'Id' exist in the child nodes. - // If both exist, take 'Id', otherwise just take 'Title'. - bool containsID = false; - foreach (XmlElement childNode in childNodes) + metadata["Id"] = entryChild.InnerText; + } + else if (entryKey.Equals("properties")) + { + var propertyChildNodes = entryChild.ChildNodes; + foreach (XmlElement propertyChild in propertyChildNodes) { - if (childNode.LocalName == "Id") + var propertyKey = propertyChild.LocalName; + var propertyValue = propertyChild.InnerText; + + if (propertyKey.Equals("Title")) { - containsID = true; + if (!metadata.ContainsKey("Id")) + { + metadata["Id"] = propertyValue; + } } - } + if (propertyKey.Equals("Version")) + { + metadata[propertyKey] = ParseHttpVersion(propertyValue, out string prereleaseLabel); + metadata["Prerelease"] = prereleaseLabel; + } + else if (propertyKey.EndsWith("Url")) + { + metadata[propertyKey] = ParseHttpUrl(propertyValue) as Uri; + } + else if (propertyKey.Equals("Tags")) + { + metadata[propertyKey] = propertyValue.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + } + else if (propertyKey.Equals("Published")) + { + metadata[propertyKey] = ParseHttpDateTime(propertyValue); + } + else if (propertyKey.Equals("Dependencies")) + { + metadata[propertyKey] = ParseHttpDependencies(propertyValue); + } + else if (propertyKey.Equals("IsPrerelease")) + { + bool.TryParse(propertyValue, out bool isPrerelease); - if (!containsID) - { - metadata["Id"] = value; - } - } - if (key.Equals("Version")) - { - metadata[key] = ParseHttpVersion(value, out string prereleaseLabel); - metadata["Prerelease"] = prereleaseLabel; - } - else if (key.EndsWith("Url")) - { - metadata[key] = ParseHttpUrl(value) as Uri; - } - else if (key.Equals("Tags")) - { - metadata[key] = value.Split(new char[]{' '}, StringSplitOptions.RemoveEmptyEntries); - } - else if (key.Equals("Published")) - { - metadata[key] = ParseHttpDateTime(value); - } - else if (key.Equals("Dependencies")) - { - metadata[key] = ParseHttpDependencies(value); - } - else if (key.Equals("IsPrerelease")) - { - bool.TryParse(value, out bool isPrerelease); + metadata[propertyKey] = isPrerelease; + } + else if (propertyKey.Equals("NormalizedVersion")) + { + if (!NuGetVersion.TryParse(propertyValue, out NuGetVersion parsedNormalizedVersion)) + { + errorMsg = string.Format( + CultureInfo.InvariantCulture, + @"TryReadPSGetInfo: Cannot parse NormalizedVersion"); - metadata[key] = isPrerelease; - } - else if (key.Equals("NormalizedVersion")) - { - if (!NuGetVersion.TryParse(value, out NuGetVersion parsedNormalizedVersion)) - { - errorMsg = string.Format( - CultureInfo.InvariantCulture, - @"TryReadPSGetInfo: Cannot parse NormalizedVersion"); + parsedNormalizedVersion = new NuGetVersion("1.0.0.0"); + } - parsedNormalizedVersion = new NuGetVersion("1.0.0.0"); + metadata[propertyKey] = parsedNormalizedVersion; + } + else + { + metadata[propertyKey] = propertyValue; + } } - - metadata[key] = parsedNormalizedVersion; - } - else - { - metadata[key] = value; } } diff --git a/src/code/V2ResponseUtil.cs b/src/code/V2ResponseUtil.cs index 1584461f5..68dd1c9c2 100644 --- a/src/code/V2ResponseUtil.cs +++ b/src/code/V2ResponseUtil.cs @@ -75,12 +75,12 @@ public XmlNode[] ConvertResponseToXML(string httpResponse) { XmlDocument doc = new XmlDocument(); doc.LoadXml(httpResponse); - XmlNodeList elemList = doc.GetElementsByTagName("m:properties"); - - XmlNode[] nodes = new XmlNode[elemList.Count]; - for (int i = 0; i < elemList.Count; i++) + XmlNodeList entryNode = doc.GetElementsByTagName("entry"); + + XmlNode[] nodes = new XmlNode[entryNode.Count]; + for (int i = 0; i < entryNode.Count; i++) { - nodes[i] = elemList[i]; + nodes[i] = entryNode[i]; } return nodes; From 8695ac9fe9ab94c1c59edacde78d694f1494f55a Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Thu, 25 Jan 2024 11:15:02 -0500 Subject: [PATCH 035/160] Bugfix - Test if InstalledScriptInfos folder exists and create if needed (#1542) * Test InstalledScriptInfos folder and create if needed * Update src/code/InstallHelper.cs Co-authored-by: Aditya Patwardhan * Update src/code/InstallHelper.cs Co-authored-by: Aditya Patwardhan --------- Co-authored-by: Aditya Patwardhan --- src/code/InstallHelper.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 46b02e365..d7c3e9cd4 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -488,15 +488,26 @@ private void MoveFilesIntoInstallPath( } else { - var scriptXML = pkgInfo.Name + "_InstalledScriptInfo.xml"; + string scriptInfoFolderPath = Path.Combine(installPath, "InstalledScriptInfos"); + string scriptXML = pkgInfo.Name + "_InstalledScriptInfo.xml"; + string scriptXmlFilePath = Path.Combine(scriptInfoFolderPath, scriptXML); if (!_savePkg) { + // Need to ensure "InstalledScriptInfos directory exists + if (!Directory.Exists(scriptInfoFolderPath)) + + { + _cmdletPassedIn.WriteVerbose($"Created '{scriptInfoFolderPath}' path for scripts"); + Directory.CreateDirectory(scriptInfoFolderPath); + } + // Need to delete old xml files because there can only be 1 per script - _cmdletPassedIn.WriteVerbose(string.Format("Checking if path '{0}' exists: ", File.Exists(Path.Combine(installPath, "InstalledScriptInfos", scriptXML)))); - if (File.Exists(Path.Combine(installPath, "InstalledScriptInfos", scriptXML))) + _cmdletPassedIn.WriteVerbose(string.Format("Checking if path '{0}' exists: '{1}'", scriptXmlFilePath, File.Exists(scriptXmlFilePath))); + if (File.Exists(scriptXmlFilePath)) { _cmdletPassedIn.WriteVerbose("Deleting script metadata XML"); - File.Delete(Path.Combine(installPath, "InstalledScriptInfos", scriptXML)); + File.Delete(Path.Combine(scriptInfoFolderPath, scriptXML)); + } _cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, "InstalledScriptInfos", scriptXML))); From 11b9d5d324ca770959b5ae61cc2e5f95e5708ac5 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Fri, 26 Jan 2024 08:03:15 -0800 Subject: [PATCH 036/160] Publish fixes (#1545) --- src/code/ACRServerAPICalls.cs | 109 ++++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 6ebf93564..cc91daf69 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -986,21 +986,23 @@ private static async Task PutRequestAsync(string url, string filePath, boo SetDefaultHeaders(contentHeaders); FileInfo fileInfo = new FileInfo(filePath); - FileStream fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read); - HttpContent httpContent = new StreamContent(fileStream); - if (isManifest) + using (FileStream fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read)) { - httpContent.Headers.Add("Content-Type", "application/vnd.oci.image.manifest.v1+json"); - } - else - { - httpContent.Headers.Add("Content-Type", "application/octet-stream"); - } + HttpContent httpContent = new StreamContent(fileStream); + if (isManifest) + { + httpContent.Headers.Add("Content-Type", "application/vnd.oci.image.manifest.v1+json"); + } + else + { + httpContent.Headers.Add("Content-Type", "application/octet-stream"); + } - HttpResponseMessage response = await s_client.PutAsync(url, httpContent); - response.EnsureSuccessStatusCode(); - fileStream.Close(); - return response.IsSuccessStatusCode; + HttpResponseMessage response = await s_client.PutAsync(url, httpContent); + response.EnsureSuccessStatusCode(); + + return response.IsSuccessStatusCode; + } } catch (HttpRequestException e) { @@ -1063,6 +1065,28 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p _cmdletPassedIn.WriteVerbose("Finish uploading blob"); bool moduleUploadSuccess = EndUploadBlob(registry, moduleLocation, fullNupkgFile, nupkgDigest, false, acrAccessToken).Result; + /* Upload an empty file-- needed by ACR server */ + _cmdletPassedIn.WriteVerbose("Create an empty file"); + string emptyFileName = "empty.txt"; + var emptyFilePath = System.IO.Path.Combine(outputNupkgDir, emptyFileName); + // Rename the empty file in case such a file already exists in the temp folder (although highly unlikely) + while (File.Exists(emptyFilePath)) + { + emptyFilePath = Guid.NewGuid().ToString() + ".txt"; + } + using (FileStream configStream = new FileStream(emptyFilePath, FileMode.Create)){ } + _cmdletPassedIn.WriteVerbose("Start uploading an empty file"); + var emptyLocation = GetStartUploadBlobLocation(registry, pkgName, acrAccessToken).Result; + _cmdletPassedIn.WriteVerbose("Computing digest for empty file"); + bool emptyDigestCreated = CreateDigest(emptyFilePath, out string emptyDigest, out ErrorRecord emptyDigestError); + if (!emptyDigestCreated) + { + _cmdletPassedIn.ThrowTerminatingError(emptyDigestError); + } + _cmdletPassedIn.WriteVerbose("Finish uploading empty file"); + bool emptyFileUploadSuccess = EndUploadBlob(registry, emptyLocation, emptyFilePath, emptyDigest, false, acrAccessToken).Result; + + /* Create config layer */ _cmdletPassedIn.WriteVerbose("Create the config file"); string configFileName = "config.json"; var configFilePath = System.IO.Path.Combine(outputNupkgDir, configFileName); @@ -1070,8 +1094,7 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p { configFilePath = Guid.NewGuid().ToString() + ".json"; } - FileStream configStream = File.Create(configFilePath); - configStream.Close(); + using (FileStream configStream = new FileStream(configFilePath, FileMode.Create)){ } _cmdletPassedIn.WriteVerbose("Computing digest for config"); bool configDigestCreated = CreateDigest(configFilePath, out string configDigest, out ErrorRecord configDigestError); if (!configDigestCreated) @@ -1163,36 +1186,36 @@ private bool CreateDigest(string fileName, out string digest, out ErrorRecord er { FileInfo fileInfo = new FileInfo(fileName); SHA256 mySHA256 = SHA256.Create(); - FileStream fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read); - digest = string.Empty; - - try - { - // Create a fileStream for the file. - // Be sure it's positioned to the beginning of the stream. - fileStream.Position = 0; - // Compute the hash of the fileStream. - byte[] hashValue = mySHA256.ComputeHash(fileStream); - StringBuilder stringBuilder = new StringBuilder(); - foreach (byte b in hashValue) - stringBuilder.AppendFormat("{0:x2}", b); - digest = stringBuilder.ToString(); - // Write the name and hash value of the file to the console. - _cmdletPassedIn.WriteVerbose($"{fileInfo.Name}: {hashValue}"); - error = null; - } - catch (IOException ex) - { - var IOError = new ErrorRecord(ex, $"IOException for .nupkg file: {ex.Message}", ErrorCategory.InvalidOperation, null); - error = IOError; - } - catch (UnauthorizedAccessException ex) + using (FileStream fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read)) { - var AuthorizationError = new ErrorRecord(ex, $"UnauthorizedAccessException for .nupkg file: {ex.Message}", ErrorCategory.PermissionDenied, null); - error = AuthorizationError; - } + digest = string.Empty; - fileStream.Close(); + try + { + // Create a fileStream for the file. + // Be sure it's positioned to the beginning of the stream. + fileStream.Position = 0; + // Compute the hash of the fileStream. + byte[] hashValue = mySHA256.ComputeHash(fileStream); + StringBuilder stringBuilder = new StringBuilder(); + foreach (byte b in hashValue) + stringBuilder.AppendFormat("{0:x2}", b); + digest = stringBuilder.ToString(); + // Write the name and hash value of the file to the console. + _cmdletPassedIn.WriteVerbose($"{fileInfo.Name}: {hashValue}"); + error = null; + } + catch (IOException ex) + { + var IOError = new ErrorRecord(ex, $"IOException for .nupkg file: {ex.Message}", ErrorCategory.InvalidOperation, null); + error = IOError; + } + catch (UnauthorizedAccessException ex) + { + var AuthorizationError = new ErrorRecord(ex, $"UnauthorizedAccessException for .nupkg file: {ex.Message}", ErrorCategory.PermissionDenied, null); + error = AuthorizationError; + } + } if (error != null) { return false; From 0a6c59633c367ea46c201bf6572ec9f606876f1e Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Fri, 26 Jan 2024 08:38:58 -0800 Subject: [PATCH 037/160] Remove redeclaration of s_tempHome (#1544) --- src/code/Utils.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index c3d2b178c..815b1182f 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1067,7 +1067,7 @@ private static string GetHomeOrCreateTempHome() try { - var s_tempHome = Path.Combine(Path.GetTempPath(), string.Format(CultureInfo.CurrentCulture, tempHomeFolderName, Environment.UserName)); + s_tempHome = Path.Combine(Path.GetTempPath(), string.Format(CultureInfo.CurrentCulture, tempHomeFolderName, Environment.UserName)); Directory.CreateDirectory(s_tempHome); } catch (UnauthorizedAccessException) From 4b41e6240072536da6f663aca34ef44e31ff68b8 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Fri, 26 Jan 2024 11:35:39 -0800 Subject: [PATCH 038/160] Move owners.txt to the root of the repository (#1546) --- tool/owners.txt => owners.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tool/owners.txt => owners.txt (100%) diff --git a/tool/owners.txt b/owners.txt similarity index 100% rename from tool/owners.txt rename to owners.txt From 109b902ab8445a1b1c4a1a8fa46f9349355ec3cc Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Mon, 29 Jan 2024 10:32:47 -0800 Subject: [PATCH 039/160] Add tests for ADO v2 server (#1539) --- src/code/V2ServerAPICalls.cs | 30 +- .../FindPSResourceADOV2Server.Tests.ps1 | 269 ++++++++++++++++++ 2 files changed, 294 insertions(+), 5 deletions(-) create mode 100644 test/FindPSResourceTests/FindPSResourceADOV2Server.Tests.ps1 diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index 48f143aa5..0c6311e1c 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -809,7 +809,7 @@ private string FindAllFromTypeEndPoint(bool includePrerelease, bool isSearchingM { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindAllFromTypeEndPoint()"); string typeEndpoint = _isPSGalleryRepo && !isSearchingModule ? "/items/psscript" : String.Empty; - string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; + string paginationParam = _isPSGalleryRepo ? $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000" : $"&$inlinecount=allpages&$skip={skip}&$top=6000"; // JFrog/Artifactory requires an empty search term to enumerate all packages in the feed string searchTerm = _isJFrogRepo ? "&searchTerm=''" : ""; var prereleaseFilter = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; @@ -831,7 +831,7 @@ private string FindTagFromEndpoint(string[] tags, bool includePrerelease, bool i // type: DSCResource -> just search Modules // type: Command -> just search Modules string typeEndpoint = _isPSGalleryRepo && !isSearchingModule ? "/items/psscript" : String.Empty; - string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; + string paginationParam = _isPSGalleryRepo ? $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000" : $"&$inlinecount=allpages&$skip={skip}&$top=6000"; // JFrog/Artifactory requires an empty search term to enumerate all packages in the feed string searchTerm = _isJFrogRepo ? "&searchTerm=''" : ""; var prereleaseFilter = includePrerelease ? "includePrerelease=true&$filter=IsAbsoluteLatestVersion" : "$filter=IsLatestVersion"; @@ -855,7 +855,7 @@ private string FindCommandOrDscResource(string[] tags, bool includePrerelease, b { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindCommandOrDscResource()"); // can only find from Modules endpoint - string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; + string paginationParam = _isPSGalleryRepo ? $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000" : $"&$inlinecount=allpages&$skip={skip}&$top=6000"; var prereleaseFilter = includePrerelease ? "$filter=IsAbsoluteLatestVersion&includePrerelease=true" : "$filter=IsLatestVersion"; var tagPrefix = isSearchingForCommands ? "PSCommand_" : "PSDscResource_"; @@ -884,7 +884,7 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and startswith(Id, 'PowerShell') and IsLatestVersion (stable) // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and IsAbsoluteLatestVersion&includePrerelease=true - string extraParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100"; + string extraParam = _isPSGalleryRepo ? $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100" : $"&$inlinecount=allpages&$skip={skip}&$top=100"; var prerelease = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; string nameFilter; @@ -937,6 +937,16 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl return string.Empty; } + if (!_isPSGalleryRepo && type != ResourceType.None) + { + errRecord = new ErrorRecord( + new ArgumentException("-Name with wildcards with -Type is not supported for this repository."), + "FindNameGlobbingNotSupportedForRepo", + ErrorCategory.InvalidArgument, + this); + + return string.Empty; + } string typeFilterPart = GetTypeFilterForRequest(type); var requestUrlV2 = $"{Repository.Uri}/Search()?$filter={nameFilter}{typeFilterPart} and {prerelease}{extraParam}"; @@ -952,12 +962,22 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and startswith(Id, 'PowerShell') and IsLatestVersion (stable) // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and IsAbsoluteLatestVersion&includePrerelease=true - string extraParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100"; + string extraParam = _isPSGalleryRepo ? $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100" : $"&$inlinecount=allpages&$skip={skip}&$top=100"; var prerelease = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; string nameFilter; var names = packageName.Split(new char[] {'*'}, StringSplitOptions.RemoveEmptyEntries); + if (!_isPSGalleryRepo) + { + errRecord = new ErrorRecord( + new ArgumentException("Name globbing with tags is not supported for V2 server protocol repositories."), + "FindNameGlobbingAndTagFailure", + ErrorCategory.InvalidArgument, + this); + + return string.Empty; + } if (names.Length == 0) { errRecord = new ErrorRecord( diff --git a/test/FindPSResourceTests/FindPSResourceADOV2Server.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceADOV2Server.Tests.ps1 new file mode 100644 index 000000000..b2bb18234 --- /dev/null +++ b/test/FindPSResourceTests/FindPSResourceADOV2Server.Tests.ps1 @@ -0,0 +1,269 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$modPath = "$psscriptroot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +$psmodulePaths = $env:PSModulePath -split ';' +Write-Verbose -Verbose "Current module search paths: $psmodulePaths" + +Describe 'Test HTTP Find-PSResource for ADO V2 Server Protocol' -tags 'CI' { + + BeforeAll{ + $testModuleName = "test_local_mod" + $ADOV2RepoName = "PSGetTestingPublicFeed" + $ADOV2RepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/powershell-public-test/nuget/v2" + Get-NewPSResourceRepositoryFile + Register-PSResourceRepository -Name $ADOV2RepoName -Uri $ADOV2RepoUri + } + + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + It "Find resource given specific Name, Version null" { + $res = Find-PSResource -Name $testModuleName -Repository $ADOV2RepoName + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0" + } + + It "Should not find resource given nonexistant Name" { + $res = Find-PSResource -Name NonExistantModule -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $res | Should -BeNullOrEmpty + } + + It "Find resource(s) given wildcard Name" { + # FindNameGlobbing + $foundScript = $False + $res = Find-PSResource -Name "test_*" -Repository $ADOV2RepoName + $res.Count | Should -BeGreaterThan 1 + } + + $testCases2 = @{Version="[5.0.0]"; ExpectedVersions=@("5.0.0"); Reason="validate version, exact match"}, + @{Version="5.0.0"; ExpectedVersions=@("5.0.0"); Reason="validate version, exact match without bracket syntax"}, + @{Version="[1.0.0, 5.0.0]"; ExpectedVersions=@("1.0.0", "3.0.0", "5.0.0"); Reason="validate version, exact range inclusive"}, + @{Version="(1.0.0, 5.0.0)"; ExpectedVersions=@("3.0.0"); Reason="validate version, exact range exclusive"}, + @{Version="(1.0.0,)"; ExpectedVersions=@("3.0.0", "5.0.0"); Reason="validate version, minimum version exclusive"}, + @{Version="[1.0.0,)"; ExpectedVersions=@("1.0.0", "3.0.0", "5.0.0"); Reason="validate version, minimum version inclusive"}, + @{Version="(,3.0.0)"; ExpectedVersions=@("1.0.0"); Reason="validate version, maximum version exclusive"}, + @{Version="(,3.0.0]"; ExpectedVersions=@("1.0.0", "3.0.0"); Reason="validate version, maximum version inclusive"}, + @{Version="[1.0.0, 5.0.0)"; ExpectedVersions=@("1.0.0", "3.0.0"); Reason="validate version, mixed inclusive minimum and exclusive maximum version"} + @{Version="(1.0.0, 5.0.0]"; ExpectedVersions=@("3.0.0", "5.0.0"); Reason="validate version, mixed exclusive minimum and inclusive maximum version"} + + It "Find resource when given Name to " -TestCases $testCases2{ + # FindVersionGlobbing() + param($Version, $ExpectedVersions) + $res = Find-PSResource -Name $testModuleName -Version $Version -Repository $ADOV2RepoName + $res | Should -Not -BeNullOrEmpty + foreach ($item in $res) { + $item.Name | Should -Be $testModuleName + $ExpectedVersions | Should -Contain $item.Version + } + } + + It "Find all versions of resource when given specific Name, Version not null --> '*'" { + # FindVersionGlobbing() + $res = Find-PSResource -Name $testModuleName -Version "*" -Repository $ADOV2RepoName + $res | ForEach-Object { + $_.Name | Should -Be $testModuleName + } + + $res.Count | Should -BeGreaterOrEqual 1 + } + + It "Find resource with latest (including prerelease) version given Prerelease parameter" { + # FindName() + # test_module resource's latest version is a prerelease version, before that it has a non-prerelease version + $res = Find-PSResource -Name $testModuleName -Repository $ADOV2RepoName + $res.Version | Should -Be "5.0.0" + + $resPrerelease = Find-PSResource -Name $testModuleName -Prerelease -Repository $ADOV2RepoName + $resPrerelease.Version | Should -Be "5.2.5" + $resPrerelease.Prerelease | Should -Be "alpha001" + } + + It "Find resources, including Prerelease version resources, when given Prerelease parameter" { + # FindVersionGlobbing() + $resWithoutPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $ADOV2RepoName + $resWithPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $ADOV2RepoName + $resWithPrerelease.Count | Should -BeGreaterOrEqual $resWithoutPrerelease.Count + } + +<# LATER + It "Find resource and its dependency resources with IncludeDependencies parameter" { + # FindName() with deps + $resWithoutDependencies = Find-PSResource -Name "TestModuleWithDependencyE" -Repository $ADOV2RepoName + $resWithoutDependencies.Name | Should -Be "TestModuleWithDependencyE" + $resWithoutDependencies | Should -HaveCount 1 + + # TestModuleWithDependencyE has the following dependencies: + # TestModuleWithDependencyC <= 1.0.0.0 + # TestModuleWithDependencyB >= 1.0.0.0 + # TestModuleWithDependencyD <= 1.0.0.0 + + $resWithDependencies = Find-PSResource -Name "TestModuleWithDependencyE" -IncludeDependencies -Repository $ADOV2RepoName + $resWithDependencies | Should -HaveCount 4 + + $foundParentPkgE = $false + $foundDepB = $false + $foundDepBCorrectVersion = $false + $foundDepC = $false + $foundDepCCorrectVersion = $false + $foundDepD = $false + $foundDepDCorrectVersion = $false + foreach ($pkg in $resWithDependencies) + { + if ($pkg.Name -eq "TestModuleWithDependencyE") + { + $foundParentPkgE = $true + } + elseif ($pkg.Name -eq "TestModuleWithDependencyC") + { + $foundDepC = $true + $foundDepCCorrectVersion = [System.Version]$pkg.Version -le [System.Version]"1.0" + } + elseif ($pkg.Name -eq "TestModuleWithDependencyB") + { + $foundDepB = $true + $foundDepBCorrectVersion = [System.Version]$pkg.Version -ge [System.Version]"1.0" + } + elseif ($pkg.Name -eq "TestModuleWithDependencyD") + { + $foundDepD = $true + $foundDepDCorrectVersion = [System.Version]$pkg.Version -le [System.Version]"1.0" + } + } + + $foundParentPkgE | Should -Be $true + $foundDepC | Should -Be $true + $foundDepCCorrectVersion | Should -Be $true + $foundDepB | Should -Be $true + $foundDepBCorrectVersion | Should -Be $true + $foundDepD | Should -Be $true + $foundDepDCorrectVersion | Should -Be $true + } + + It "find resource of Type script or module from PSGallery, when no Type parameter provided" { + # FindName() script + $resScript = Find-PSResource -Name $testScriptName -Repository $ADOV2RepoName + $resScript.Name | Should -Be $testScriptName + $resScriptType = Out-String -InputObject $resScript.Type + $resScriptType.Replace(",", " ").Split() | Should -Contain "Script" + + $resModule = Find-PSResource -Name $testModuleName -Repository $ADOV2RepoName + $resModule.Name | Should -Be $testModuleName + $resModuleType = Out-String -InputObject $resModule.Type + $resModuleType.Replace(",", " ").Split() | Should -Contain "Module" + } + + It "find resource of Type Script from PSGallery, when Type Script specified" { + # FindName() Type script + $resScript = Find-PSResource -Name $testScriptName -Repository $ADOV2RepoName -Type "Script" + $resScript.Name | Should -Be $testScriptName + $resScriptType = Out-String -InputObject $resScript.Type + $resScriptType.Replace(",", " ").Split() | Should -Contain "Script" + } +#> + + + It "Find all resources of Type Module when Type parameter set is used" { + $res = Find-PSResource -Name "test*" -Type Module -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindNameGlobbingNotSupportedForRepo,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Find resource that satisfies given Name and Tag property (single tag)" { + # FindNameWithTag() + $requiredTag = "Test" + $res = Find-PSResource -Name $testModuleName -Tag $requiredTag -Repository $ADOV2RepoName + $res.Name | Should -Be $testModuleName + $res.Tags | Should -Contain $requiredTag + } + + It "Should not find resource if Name and Tag are not both satisfied (single tag)" { + # FindNameWithTag + $requiredTag = "Windows" # tag "windows" is not present for test_module package + $res = Find-PSResource -Name $testModuleName -Tag $requiredTag -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Find resource that satisfies given Name and Tag property (multiple tags)" { + # FindNameWithTag() + $requiredTags = @("Test", "Tag2") + $res = Find-PSResource -Name $testModuleName -Tag $requiredTags -Repository $ADOV2RepoName + $res.Name | Should -Be $testModuleName + $res.Tags | Should -Contain $requiredTags[0] + $res.Tags | Should -Contain $requiredTags[1] + } + + It "Should not find resource if Name and Tag are not both satisfied (multiple tag)" { + # FindNameWithTag + $requiredTags = @("test", "Windows") # tag "windows" is not present for test_module package + $res = Find-PSResource -Name $testModuleName -Tag $requiredTags -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Find all resources that satisfy Name pattern and have specified Tag (single tag)" { + # FindNameGlobbingWithTag() + $requiredTag = "test" + $nameWithWildcard = "test_module*" + $res = Find-PSResource -Name $nameWithWildcard -Tag $requiredTag -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindNameGlobbingAndTagFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + + } + + It "Should not find resources if both Name pattern and Tags are not satisfied (multiple tags)" { + # FindNameGlobbingWithTag() # tag "windows" is not present for test_module package + $requiredTags = @("Test", "windows") + $res = Find-PSResource -Name $testModuleName -Tag $requiredTags -Repository $ADOV2RepoName -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + } + + It "Find resource that satisfies given Name, Version and Tag property (single tag)" { + # FindVersionWithTag() + $requiredTag = "Test" + $res = Find-PSResource -Name $testModuleName -Version "5.0.0" -Tag $requiredTag -Repository $ADOV2RepoName + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0" + $res.Tags | Should -Contain $requiredTag + } + + It "Should not find resource if Name, Version and Tag property are not all satisfied (single tag)" { + # FindVersionWithTag() + $requiredTag = "windows" # tag "windows" is not present for test_module package + $res = Find-PSResource -Name $testModuleName -Version "5.0.0" -Tag $requiredTag -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Find resource that satisfies given Name, Version and Tag property (multiple tags)" { + # FindVersionWithTag() + $requiredTags = @("Test", "Tag2") + $res = Find-PSResource -Name $testModuleName -Version "5.0.0" -Tag $requiredTags -Repository $ADOV2RepoName + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0" + $res.Tags | Should -Contain $requiredTags[0] + $res.Tags | Should -Contain $requiredTags[1] + + } + + It "Should not find resource if Name, Version and Tag property are not all satisfied (multiple tags)" { + # FindVersionWithTag() + $requiredTags = @("test", "windows") + $res = Find-PSResource -Name $testModuleName -Version "5.0.0" -Tag $requiredTags -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } +} From f82d340743ea28b88c0dd17e3cd11d1592fd621c Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 30 Jan 2024 11:45:36 -0800 Subject: [PATCH 040/160] Bug fix for Update-PSResource not updating from correct repository (#1549) --- src/code/UpdatePSResource.cs | 9 ++++++ .../UpdatePSResourceLocalTests.ps1 | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/code/UpdatePSResource.cs b/src/code/UpdatePSResource.cs index fc4401f97..86e2cf1ae 100644 --- a/src/code/UpdatePSResource.cs +++ b/src/code/UpdatePSResource.cs @@ -321,6 +321,15 @@ private string[] ProcessPackageNames( latestInstalledIsPrerelease = true; } + // Update from the repository where the package was previously installed from. + // If user explicitly specifies a repository to update from, use that instead. + if (Repository == null) + { + Repository = new String[] { installedPackages.First().Value.Repository }; + + WriteDebug($"Updating from repository '{string.Join(", ", Repository)}"); + } + // Find all packages selected for updating in provided repositories. var repositoryPackages = new Dictionary(StringComparer.InvariantCultureIgnoreCase); foreach (var foundResource in _findHelper.FindByResourceName( diff --git a/test/UpdatePSResourceTests/UpdatePSResourceLocalTests.ps1 b/test/UpdatePSResourceTests/UpdatePSResourceLocalTests.ps1 index da417d267..f5334be83 100644 --- a/test/UpdatePSResourceTests/UpdatePSResourceLocalTests.ps1 +++ b/test/UpdatePSResourceTests/UpdatePSResourceLocalTests.ps1 @@ -10,6 +10,7 @@ Describe 'Test Update-PSResource for local repositories' -tags 'CI' { BeforeAll { $localRepo = "psgettestlocal" + $localRepo2 = "psgettestlocal2" $moduleName = "test_local_mod" $moduleName2 = "test_local_mod2" Get-NewPSResourceRepositoryFile @@ -20,6 +21,9 @@ Describe 'Test Update-PSResource for local repositories' -tags 'CI' { Get-ModuleResourcePublishedToLocalRepoTestDrive $moduleName $localRepo "5.0.0" Get-ModuleResourcePublishedToLocalRepoTestDrive $moduleName2 $localRepo "1.0.0" Get-ModuleResourcePublishedToLocalRepoTestDrive $moduleName2 $localRepo "5.0.0" + + Get-ModuleResourcePublishedToLocalRepoTestDrive $moduleName $localRepo2 "1.0.0" + Get-ModuleResourcePublishedToLocalRepoTestDrive $moduleName $localRepo2 "5.0.0" } AfterEach { @@ -48,6 +52,31 @@ Describe 'Test Update-PSResource for local repositories' -tags 'CI' { $isPkgUpdated | Should -Be $true } + It "Update resource from the repository which package was previously from" { + Install-PSResource -Name $moduleName -Version "1.0.0" -Repository $localRepo2 -TrustRepository + + Update-PSResource -Name $moduleName -TrustRepository + $res = Get-InstalledPSResource -Name $moduleName + + $isPkgUpdated = $false + $isCorrectRepo = $false + foreach ($pkg in $res) + { + if ([System.Version]$pkg.Version -gt [System.Version]"1.0.0") + { + $isPkgUpdated = $true + + if ($pkg.Repository -eq $localRepo2) + { + $isCorrectRepo = $true + } + } + } + + $isPkgUpdated | Should -Be $true + $isCorrectRepo | Should -Be $true + } + It "Update resources installed given Name (with wildcard) parameter" { Install-PSResource -Name $moduleName -Version "1.0.0" -Repository $localRepo -TrustRepository Install-PSResource -Name $moduleName2 -Version "1.0.0" -Repository $localRepo -TrustRepository From 9738f152c4e982abafcd0443267434e10cb479c8 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Mon, 5 Feb 2024 13:17:34 -0500 Subject: [PATCH 041/160] Bugfix Update-ModuleManifest throws null pointer exception (#1538) --- src/code/UpdateModuleManifest.cs | 616 +++++++++++++++++- src/code/Utils.cs | 5 + .../GetPSScriptFileInfo.Tests.ps1 | 5 - .../UpdateModuleManifest.Tests.ps1 | 12 +- 4 files changed, 596 insertions(+), 42 deletions(-) diff --git a/src/code/UpdateModuleManifest.cs b/src/code/UpdateModuleManifest.cs index 7c2eeab05..7778875b9 100644 --- a/src/code/UpdateModuleManifest.cs +++ b/src/code/UpdateModuleManifest.cs @@ -4,6 +4,7 @@ using Microsoft.PowerShell.PSResourceGet.UtilClasses; using System; using System.Collections; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Management.Automation; @@ -307,6 +308,30 @@ protected override void EndProcessing() this)); } + // Due to a PowerShell New-ModuleManifest bug with the PrivateData entry when it's a nested hashtable (https://github.com/PowerShell/PowerShell/issues/5922) + // we have to handle PrivateData entry, and thus module manifest creation, differently on PSCore than on WindowsPowerShell. + ErrorRecord errorRecord = null; + if (Utils.GetIsWindowsPowerShell(this)) + { + CreateModuleManifestForWinPSHelper(parsedMetadata, resolvedManifestPath, out errorRecord); + } + else + { + CreateModuleManifestHelper(parsedMetadata, resolvedManifestPath, out errorRecord); + } + + if (errorRecord != null) + { + ThrowTerminatingError(errorRecord); + } + } + + /// + /// Handles module manifest creation for non-WindowsPowerShell platforms. + /// + private void CreateModuleManifestHelper(Hashtable parsedMetadata, string resolvedManifestPath, out ErrorRecord errorRecord) + { + errorRecord = null; // Prerelease, ReleaseNotes, Tags, ProjectUri, LicenseUri, IconUri, RequireLicenseAcceptance, // and ExternalModuleDependencies are all properties within a hashtable property called 'PSData' // which is within another hashtable property called 'PrivateData' @@ -343,60 +368,67 @@ protected override void EndProcessing() // } # End of PSData hashtable // // } # End of PrivateData hashtable - var PrivateData = parsedMetadata["PrivateData"] as Hashtable; - var PSData = PrivateData["PSData"] as Hashtable; - if (PSData.ContainsKey("Prerelease")) + Hashtable privateData = new Hashtable(); + + if (PrivateData != null && PrivateData.Count != 0) + { + privateData = PrivateData; + } + else { - parsedMetadata["Prerelease"] = PSData["Prerelease"]; + privateData = parsedMetadata["PrivateData"] as Hashtable; } - if (PSData.ContainsKey("ReleaseNotes")) + var psData = privateData["PSData"] as Hashtable; + + if (psData.ContainsKey("Prerelease")) { - parsedMetadata["ReleaseNotes"] = PSData["ReleaseNotes"]; + parsedMetadata["Prerelease"] = psData["Prerelease"]; } - if (PSData.ContainsKey("Tags")) + if (psData.ContainsKey("ReleaseNotes")) { - parsedMetadata["Tags"] = PSData["Tags"]; + parsedMetadata["ReleaseNotes"] = psData["ReleaseNotes"]; } - if (PSData.ContainsKey("ProjectUri")) + if (psData.ContainsKey("Tags")) { - parsedMetadata["ProjectUri"] = PSData["ProjectUri"]; + parsedMetadata["Tags"] = psData["Tags"]; } - if (PSData.ContainsKey("LicenseUri")) + if (psData.ContainsKey("ProjectUri")) { - parsedMetadata["LicenseUri"] = PSData["LicenseUri"]; + parsedMetadata["ProjectUri"] = psData["ProjectUri"]; } - if (PSData.ContainsKey("IconUri")) + if (psData.ContainsKey("LicenseUri")) { - parsedMetadata["IconUri"] = PSData["IconUri"]; + parsedMetadata["LicenseUri"] = psData["LicenseUri"]; } - if (PSData.ContainsKey("RequireLicenseAcceptance")) + if (psData.ContainsKey("IconUri")) { - parsedMetadata["RequireLicenseAcceptance"] = PSData["RequireLicenseAcceptance"]; + parsedMetadata["IconUri"] = psData["IconUri"]; } - if (PSData.ContainsKey("ExternalModuleDependencies")) + if (psData.ContainsKey("RequireLicenseAcceptance")) { - parsedMetadata["ExternalModuleDependencies"] = PSData["ExternalModuleDependencies"]; + parsedMetadata["RequireLicenseAcceptance"] = psData["RequireLicenseAcceptance"]; + } + + if (psData.ContainsKey("ExternalModuleDependencies")) + { + parsedMetadata["ExternalModuleDependencies"] = psData["ExternalModuleDependencies"]; } // Now we need to remove 'PSData' becaues if we leave this value in the hashtable, // New-ModuleManifest will keep this value and also attempt to create a new value for 'PSData' // and then complain that there's two keys within the PrivateData hashtable. - PrivateData.Remove("PSData"); - - // After getting the original module manifest contents, migrate all the fields to the new module manifest, - - // adding in any new values specified via cmdlet parameters. - // Set up params to pass to New-ModuleManifest module - // For now this will be parsedMetadata hashtable and we will just add to it as needed + // This is due to the issue of New-ModuleManifest when the PrivateData entry is a nested hashtable (https://github.com/PowerShell/PowerShell/issues/5922). + privateData.Remove("PSData"); + // After getting the original module manifest contents, migrate all the fields to the parsedMetadata hashtable which will be provided as params for New-ModuleManifest. if (NestedModules != null) { parsedMetadata["NestedModules"] = NestedModules; @@ -572,7 +604,7 @@ protected override void EndProcessing() parsedMetadata["Prerelease"] = Prerelease; } - if (RequireLicenseAcceptance != null) + if (RequireLicenseAcceptance != null && RequireLicenseAcceptance.IsPresent) { parsedMetadata["RequireLicenseAcceptance"] = RequireLicenseAcceptance; } @@ -590,11 +622,15 @@ protected override void EndProcessing() } catch (Exception e) { - ThrowTerminatingError(new ErrorRecord( + Utils.DeleteDirectory(tmpParentPath); + + errorRecord = new ErrorRecord( new ArgumentException(e.Message), "ErrorCreatingTempDir", ErrorCategory.InvalidData, - this)); + this); + + return; } string tmpModuleManifestPath = System.IO.Path.Combine(tmpParentPath, System.IO.Path.GetFileName(resolvedManifestPath)); @@ -605,7 +641,7 @@ protected override void EndProcessing() { try { - var results = pwsh.AddCommand("Microsoft.PowerShell.Core\\New-ModuleManifest").AddArgument(new object[] { parsedMetadata }).Invoke(); + var results = pwsh.AddCommand("Microsoft.PowerShell.Core\\New-ModuleManifest").AddParameters(parsedMetadata).Invoke(); if (pwsh.HadErrors || pwsh.Streams.Error.Count > 0) { foreach (var err in pwsh.Streams.Error) @@ -616,11 +652,13 @@ protected override void EndProcessing() } catch (Exception e) { - ThrowTerminatingError(new ErrorRecord( + errorRecord = new ErrorRecord( new ArgumentException($"Error occured while running 'New-ModuleManifest': {e.Message}"), "ErrorExecutingNewModuleManifest", ErrorCategory.InvalidArgument, - this)); + this); + + return; } } @@ -636,7 +674,523 @@ protected override void EndProcessing() { File.Delete(tmpModuleManifestPath); } + + Utils.DeleteDirectory(tmpParentPath); + } + } + + /// + /// Handles module manifest creation for Windows PowerShell platform. + /// Since the code calls New-ModuleManifest and the Windows PowerShell version of the cmdlet did not have Prerelease, ExternalModuleDependencies and RequireLicenseAcceptance parameters, + /// we can't simply call New-ModuleManifest with all parameters. Instead, create the manifest without PrivateData parameter (and the keys usually inside it) and then update the lines for PrivateData later. + /// + private void CreateModuleManifestForWinPSHelper(Hashtable parsedMetadata, string resolvedManifestPath, out ErrorRecord errorRecord) + { + // Note on priority of values: + // If -PrivateData parameter was provided with the cmdlet & .psd1 file PrivateData already had values, the passed in -PrivateData values replace those previosuly there. + // any direct parameters supplied by the user (i.e ProjectUri) [takes priority over but in mix-and-match fashion] over -> -PrivateData parameter [takes priority over but in replacement fashion] over -> original .psd1 file's PrivateData values (complete replacement) + errorRecord = null; + string[] tags = Utils.EmptyStrArray; + Uri licenseUri = null; + Uri iconUri = null; + Uri projectUri = null; + string prerelease = String.Empty; + string releaseNotes = String.Empty; + bool? requireLicenseAcceptance = null; + string[] externalModuleDependencies = Utils.EmptyStrArray; + + Hashtable privateData = new Hashtable(); + if (PrivateData != null && PrivateData.Count != 0) + { + privateData = PrivateData; + } + else + { + privateData = parsedMetadata["PrivateData"] as Hashtable; + } + + var psData = privateData["PSData"] as Hashtable; + + if (psData.ContainsKey("Prerelease")) + { + prerelease = psData["Prerelease"] as string; + } + + if (psData.ContainsKey("ReleaseNotes")) + { + releaseNotes = psData["ReleaseNotes"] as string; + } + + if (psData.ContainsKey("Tags")) + { + tags = psData["Tags"] as string[]; + } + + if (psData.ContainsKey("ProjectUri") && psData["ProjectUri"] is string projectUriString) + { + if (!Uri.TryCreate(projectUriString, UriKind.Absolute, out projectUri)) + { + projectUri = null; + } + } + + if (psData.ContainsKey("LicenseUri") && psData["LicenseUri"] is string licenseUriString) + { + if (!Uri.TryCreate(licenseUriString, UriKind.Absolute, out licenseUri)) + { + licenseUri = null; + } + } + + if (psData.ContainsKey("IconUri") && psData["IconUri"] is string iconUriString) + { + if (!Uri.TryCreate(iconUriString, UriKind.Absolute, out iconUri)) + { + iconUri = null; + } + } + + if (psData.ContainsKey("RequireLicenseAcceptance")) + { + requireLicenseAcceptance = psData["RequireLicenseAcceptance"] as bool?; + } + + if (psData.ContainsKey("ExternalModuleDependencies")) + { + externalModuleDependencies = psData["ExternalModuleDependencies"] as string[]; + } + + // the rest of the parameters can be directly provided to New-ModuleManifest, so add it parsedMetadata hashtable used for cmdlet parameters. + if (NestedModules != null) + { + parsedMetadata["NestedModules"] = NestedModules; + } + + if (Guid != Guid.Empty) + { + parsedMetadata["Guid"] = Guid; + } + + if (!string.IsNullOrWhiteSpace(Author)) + { + parsedMetadata["Author"] = Author; + } + + if (CompanyName != null) + { + parsedMetadata["CompanyName"] = CompanyName; + } + + if (Copyright != null) + { + parsedMetadata["Copyright"] = Copyright; + } + + if (RootModule != null) + { + parsedMetadata["RootModule"] = RootModule; + } + + if (ModuleVersion != null) + { + parsedMetadata["ModuleVersion"] = ModuleVersion; + } + + if (Description != null) + { + parsedMetadata["Description"] = Description; + } + + if (ProcessorArchitecture != ProcessorArchitecture.None) + { + parsedMetadata["ProcessorArchitecture"] = ProcessorArchitecture; + } + + if (PowerShellVersion != null) + { + parsedMetadata["PowerShellVersion"] = PowerShellVersion; + } + + if (ClrVersion != null) + { + parsedMetadata["ClrVersion"] = ClrVersion; + } + + if (DotNetFrameworkVersion != null) + { + parsedMetadata["DotNetFrameworkVersion"] = DotNetFrameworkVersion; + } + + if (PowerShellHostName != null) + { + parsedMetadata["PowerShellHostName"] = PowerShellHostName; + } + + if (PowerShellHostVersion != null) + { + parsedMetadata["PowerShellHostVersion"] = PowerShellHostVersion; + } + + if (RequiredModules != null) + { + parsedMetadata["RequiredModules"] = RequiredModules; + } + + if (TypesToProcess != null) + { + parsedMetadata["TypesToProcess"] = TypesToProcess; + } + + if (FormatsToProcess != null) + { + parsedMetadata["FormatsToProcess"] = FormatsToProcess; + } + + if (ScriptsToProcess != null) + { + parsedMetadata["ScriptsToProcess"] = ScriptsToProcess; + } + + if (RequiredAssemblies != null) + { + parsedMetadata["RequiredAssemblies"] = RequiredAssemblies; + } + + if (FileList != null) + { + parsedMetadata["FileList"] = FileList; + } + + if (ModuleList != null) + { + parsedMetadata["ModuleList"] = ModuleList; + } + + if (FunctionsToExport != null) + { + parsedMetadata["FunctionsToExport"] = FunctionsToExport; + } + + if (AliasesToExport != null) + { + parsedMetadata["AliasesToExport"] = AliasesToExport; + } + + if (VariablesToExport != null) + { + parsedMetadata["VariablesToExport"] = VariablesToExport; + } + + if (CmdletsToExport != null) + { + parsedMetadata["CmdletsToExport"] = CmdletsToExport; + } + + if (DscResourcesToExport != null) + { + parsedMetadata["DscResourcesToExport"] = DscResourcesToExport; + } + + if (CompatiblePSEditions != null) + { + parsedMetadata["CompatiblePSEditions"] = CompatiblePSEditions; + } + + if (HelpInfoUri != null) + { + parsedMetadata["HelpInfoUri"] = HelpInfoUri; + } + + if (DefaultCommandPrefix != null) + { + parsedMetadata["DefaultCommandPrefix"] = DefaultCommandPrefix; + } + + // if values were passed in for these parameters, they will be prioritized over values retrieved from PrivateData + // we need to populate the local variables with their values to use for PrivateData entry creation later. + // and parameters that can be passed to New-ModuleManifest are added to the parsedMetadata hashtable. + if (Tags != null) + { + tags = Tags; + parsedMetadata["Tags"] = tags; + } + + if (LicenseUri != null) + { + licenseUri = LicenseUri; + parsedMetadata["LicenseUri"] = licenseUri; + } + + if (ProjectUri != null) + { + projectUri = ProjectUri; + parsedMetadata["ProjectUri"] = projectUri; + } + + if (IconUri != null) + { + iconUri = IconUri; + parsedMetadata["IconUri"] = iconUri; + } + + if (ReleaseNotes != null) + { + releaseNotes = ReleaseNotes; + parsedMetadata["ReleaseNotes"] = releaseNotes; + } + + // New-ModuleManifest on WinPS doesn't support parameters: Prerelease, RequireLicenseAcceptance, and ExternalModuleDependencies so we don't add those to parsedMetadata hashtable. + if (Prerelease != null) + { + prerelease = Prerelease; + } + + if (RequireLicenseAcceptance != null && RequireLicenseAcceptance.IsPresent) + { + requireLicenseAcceptance = RequireLicenseAcceptance; + } + + if (ExternalModuleDependencies != null) + { + externalModuleDependencies = ExternalModuleDependencies; + } + + // create a tmp path to create the module manifest + string tmpParentPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); + try + { + Directory.CreateDirectory(tmpParentPath); + } + catch (Exception e) + { + Utils.DeleteDirectory(tmpParentPath); + + errorRecord = new ErrorRecord( + new ArgumentException(e.Message), + "ErrorCreatingTempDir", + ErrorCategory.InvalidData, + this); + + return; + } + + string tmpModuleManifestPath = System.IO.Path.Combine(tmpParentPath, System.IO.Path.GetFileName(resolvedManifestPath)); + parsedMetadata["Path"] = tmpModuleManifestPath; + WriteVerbose($"Temp path created for new module manifest is: {tmpModuleManifestPath}"); + + using (System.Management.Automation.PowerShell pwsh = System.Management.Automation.PowerShell.Create()) + { + try + { + var results = pwsh.AddCommand("Microsoft.PowerShell.Core\\New-ModuleManifest").AddParameters(parsedMetadata).Invoke(); + if (pwsh.HadErrors || pwsh.Streams.Error.Count > 0) + { + foreach (var err in pwsh.Streams.Error) + { + WriteError(err); + } + } + } + catch (Exception e) + { + Utils.DeleteDirectory(tmpParentPath); + + errorRecord = new ErrorRecord( + new ArgumentException($"Error occured while running 'New-ModuleManifest': {e.Message}"), + "ErrorExecutingNewModuleManifest", + ErrorCategory.InvalidArgument, + this); + + return; + } + } + + string privateDataString = GetPrivateDataString(tags, licenseUri, projectUri, iconUri, releaseNotes, prerelease, requireLicenseAcceptance, externalModuleDependencies); + + // create new file in tmp path for updated module manifest (i.e updated with PrivateData entry) + string newTmpModuleManifestPath = System.IO.Path.Combine(tmpParentPath, "Updated" + System.IO.Path.GetFileName(resolvedManifestPath)); + if (!TryCreateNewPsd1WithUpdatedPrivateData(privateDataString, tmpModuleManifestPath, newTmpModuleManifestPath, out errorRecord)) + { + return; + } + + try + { + // Move to the new module manifest back to the original location + WriteVerbose($"Moving '{newTmpModuleManifestPath}' to '{resolvedManifestPath}'"); + Utils.MoveFiles(newTmpModuleManifestPath, resolvedManifestPath, overwrite: true); + } + finally { + // Clean up temp file if move fails + if (File.Exists(tmpModuleManifestPath)) + { + File.Delete(tmpModuleManifestPath); + } + + if (File.Exists(newTmpModuleManifestPath)) + { + File.Delete(newTmpModuleManifestPath); + } + + Utils.DeleteDirectory(tmpParentPath); + } + } + + /// + /// Returns string representing PrivateData entry for .psd1 file. This used for WinPS .psd1 creation as these values could not be populated otherwise. + /// + private string GetPrivateDataString(string[] tags, Uri licenseUri, Uri projectUri, Uri iconUri, string releaseNotes, string prerelease, bool? requireLicenseAcceptance, string[] externalModuleDependencies) + { + /** + Example PrivateData + + PrivateData = @{ + PSData = @{ + # Tags applied to this module. These help with module discovery in online galleries. + Tags = @('Tag1', 'Tag2') + + # A URL to the license for this module. + LicenseUri = 'https://www.licenseurl.com/' + + # A URL to the main website for this project. + ProjectUri = 'https://www.projecturi.com/' + + # A URL to an icon representing this module. + IconUri = 'https://iconuri.com/' + + # ReleaseNotes of this module. + ReleaseNotes = 'These are the release notes of this module.' + + # Prerelease string of this module. + Prerelease = 'preview' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save. + RequireLicenseAcceptance = $false + + # External dependent modules of this module + ExternalModuleDependencies = @('ModuleDep1, 'ModuleDep2') + + } # End of PSData hashtable + + } # End of PrivateData hashtable + */ + + string tagsString = string.Join(", ", tags.Select(item => "'" + item + "'")); + string tagLine = tags.Length != 0 ? $"Tags = @({tagsString})" : "# Tags = @()"; + + string licenseUriLine = licenseUri == null ? "# LicenseUri = ''" : $"LicenseUri = '{licenseUri.ToString()}'"; + string projectUriLine = projectUri == null ? "# ProjectUri = ''" : $"ProjectUri = '{projectUri.ToString()}'"; + string iconUriLine = iconUri == null ? "# IconUri = ''" : $"IconUri = '{iconUri.ToString()}'"; + + string releaseNotesLine = String.IsNullOrEmpty(releaseNotes) ? "# ReleaseNotes = ''": $"ReleaseNotes = '{releaseNotes}'"; + string prereleaseLine = String.IsNullOrEmpty(prerelease) ? "# Prerelease = ''" : $"Prerelease = '{prerelease}'"; + + string requireLicenseAcceptanceLine = requireLicenseAcceptance == null? "# RequireLicenseAcceptance = $false" : (requireLicenseAcceptance == false ? "RequireLicenseAcceptance = $false": "RequireLicenseAcceptance = $true"); + + string externalModuleDependenciesString = string.Join(", ", externalModuleDependencies.Select(item => "'" + item + "'")); + string externalModuleDependenciesLine = externalModuleDependencies.Length == 0 ? "# ExternalModuleDependencies = @()" : $"ExternalModuleDependencies = @({externalModuleDependenciesString})"; + + string initialPrivateDataString = "PrivateData = @{" + System.Environment.NewLine + "PSData = @{" + System.Environment.NewLine; + + string privateDataString = $@" + # Tags applied to this module. These help with module discovery in online galleries. + {tagLine} + + # A URL to the license for this module. + {licenseUriLine} + + # A URL to the main website for this project. + {projectUriLine} + + # A URL to an icon representing this module. + {iconUriLine} + + # ReleaseNotes of this module + {releaseNotesLine} + + # Prerelease string of this module + {prereleaseLine} + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + {requireLicenseAcceptanceLine} + + # External dependent modules of this module + {externalModuleDependenciesLine}"; + + string endingPrivateDataString = System.Environment.NewLine + "} # End of PSData hashtable" + System.Environment.NewLine + "} # End of PrivateData hashtable"; + + return initialPrivateDataString + privateDataString + endingPrivateDataString; + } + + /// + /// Replaces the default PrivateData entry in the .psd1 created with the values as parameters (either direct i.e as -Prerelease or via -PrivateData i.e PrivateData.PSData.Prerelease) + /// This used for WinPS .psd1 creation as the correct PrivateData entry could not be populated otherwise. + /// + private bool TryCreateNewPsd1WithUpdatedPrivateData(string privateDataString, string tmpModuleManifestPath, string newTmpModuleManifestPath, out ErrorRecord errorRecord) + { + errorRecord = null; + string[] psd1FileLines = File.ReadAllLines(tmpModuleManifestPath); + + int privateDataStartLine = 0; + int privateDataEndLine = 0; + + // find line that is start of PrivateData entry + for (int i = 0; i < psd1FileLines.Length; i++) + { + if (psd1FileLines[i].Trim().StartsWith("PrivateData =")){ + privateDataStartLine = i; + break; + } + } + + // next find line that is end of the PrivateData entry + int leftBracket = 0; + for (int i = privateDataStartLine; i < psd1FileLines.Length; i++) + { + if (psd1FileLines[i].Contains("{")) + { + leftBracket++; + } + else if(psd1FileLines[i].Contains("}")) + { + if (leftBracket > 0) + { + leftBracket--; + } + + if (leftBracket == 0) + { + privateDataEndLine = i; + break; + } + } + } + + if (privateDataEndLine == 0) + { + errorRecord = new ErrorRecord( + new InvalidOperationException($"Could not locate/parse ending bracket for the PrivateData hashtable entry in module manifest (.psd1 file)."), + "PrivateDataEntryParsingError", + ErrorCategory.InvalidOperation, + this); + + return false; } + + List newPsd1Lines = new List(); + for (int i = 0; i < privateDataStartLine; i++) + { + newPsd1Lines.Add(psd1FileLines[i]); + } + + newPsd1Lines.Add(privateDataString); + for (int i = privateDataEndLine+1; i < psd1FileLines.Length; i++) + { + newPsd1Lines.Add(psd1FileLines[i]); + } + + File.WriteAllLines(newTmpModuleManifestPath, newPsd1Lines); + return true; } #endregion diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 815b1182f..f09110423 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1105,6 +1105,11 @@ private static void GetStandardPlatformPaths( } } + public static bool GetIsWindowsPowerShell(PSCmdlet psCmdlet) + { + return psCmdlet.Host.Version < PSVersion6; + } + /// /// Checks if any of the package versions are already installed and if they are removes them from the list of packages to install. /// diff --git a/test/PSScriptFileInfoTests/GetPSScriptFileInfo.Tests.ps1 b/test/PSScriptFileInfoTests/GetPSScriptFileInfo.Tests.ps1 index b521ba470..9e46e5000 100644 --- a/test/PSScriptFileInfoTests/GetPSScriptFileInfo.Tests.ps1 +++ b/test/PSScriptFileInfoTests/GetPSScriptFileInfo.Tests.ps1 @@ -3,11 +3,6 @@ $modPath = "$psscriptroot/../PSGetTestUtils.psm1" Import-Module $modPath -Force -Verbose -# Explicitly import build module because in CI PowerShell can autoload PSGetv2 -# This ensures the build module is always being tested -$buildModule = "$psscriptroot/../../out/PSResourceGet" -Import-Module $buildModule -Force -Verbose - $testDir = (get-item $psscriptroot).parent.FullName Describe "Test Get-PSScriptFileInfo" -tags 'CI' { diff --git a/test/UpdateModuleManifest/UpdateModuleManifest.Tests.ps1 b/test/UpdateModuleManifest/UpdateModuleManifest.Tests.ps1 index cc1776025..d20d25076 100644 --- a/test/UpdateModuleManifest/UpdateModuleManifest.Tests.ps1 +++ b/test/UpdateModuleManifest/UpdateModuleManifest.Tests.ps1 @@ -3,12 +3,8 @@ $modPath = "$psscriptroot/../PSGetTestUtils.psm1" Import-Module $modPath -Force -Verbose -# Explicitly import build module because in CI PowerShell can autoload PSGetv2 -# This ensures the build module is always being tested -$buildModule = "$psscriptroot/../../out/PSResourceGet" -Import-Module $buildModule -Force -Verbose -Describe 'Test Update-PSModuleManifest' { +Describe 'Test Update-PSModuleManifest' -tags 'CI' { BeforeEach { # Create temp module manifest to be updated @@ -102,7 +98,7 @@ Describe 'Test Update-PSModuleManifest' { $ModuleVersion = "1.0.0" $Prerelease = " " New-ModuleManifest -Path $script:testManifestPath -Description $Description -ModuleVersion $ModuleVersion - {Update-PSModuleManifest -Path $script:testManifestPath -Prerelease $Prerelease} | Should -Throw -ErrorId "PrereleaseValueCannotOrBeWhiteSpace,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + {Update-PSModuleManifest -Path $script:testManifestPath -Prerelease $Prerelease} | Should -Throw -ErrorId "PrereleaseValueCannotBeWhiteSpace,Microsoft.PowerShell.PSResourceGet.Cmdlets.UpdateModuleManifest" } It "Update module manifest given ReleaseNotes parameter" { @@ -112,6 +108,8 @@ Describe 'Test Update-PSModuleManifest' { Update-PSModuleManifest -Path $script:testManifestPath -ReleaseNotes $ReleaseNotes $results = Test-ModuleManifest -Path $script:testManifestPath + Write-Verbose -Verbose "release notes are: $($results.PrivateData.PSData.ReleaseNotes)" + Write-Verbose -Verbose "release notes should be: $ReleaseNotes" $results.PrivateData.PSData.ReleaseNotes | Should -Be $ReleaseNotes } @@ -422,6 +420,8 @@ Describe 'Test Update-PSModuleManifest' { $results = Test-ModuleManifest -Path $script:testManifestPath $results.Author | Should -Be $Author + Write-Verbose -Verbose "Project Uri was: $($results.PrivateData.PSData.ProjectUri)" + Write-Verbose -Verbose "Project Uri should be: $ProjectUri" $results.PrivateData.PSData.ProjectUri | Should -Be $ProjectUri $results.PrivateData.PSData.Prerelease | Should -Be $Prerelease } From 7b0463fc740f450a20ca9b718ddbc22588342286 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:14:47 -0800 Subject: [PATCH 042/160] ACR Integration: Populate metadata into PSResourceInfo object (#1548) --- src/code/ACRServerAPICalls.cs | 270 ++++++++++++++++++++++------------ src/code/PSResourceInfo.cs | 107 ++++++++++++-- 2 files changed, 273 insertions(+), 104 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index cc91daf69..a46cf4545 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -173,32 +173,13 @@ public override FindResults FindName(string packageName, bool includePrerelease, return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } - /* response returned looks something like: - * "registry": "myregistry.azurecr.io" - * "imageName": "hello-world" - * "tags": [ - * { - * ""name"": ""1.0.0"", - * ""digest"": ""sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"", - * ""createdTime"": ""2023-12-23T18:06:48.9975733Z"", - * ""lastUpdateTime"": ""2023-12-23T18:06:48.9975733Z"", - * ""signed"": false, - * ""changeableAttributes"": { - * ""deleteEnabled"": true, - * ""writeEnabled"": true, - * ""readEnabled"": true, - * ""listEnabled"": true - * } - * }] - */ List latestVersionResponse = new List(); List allVersionsList = foundTags["tags"].ToList(); allVersionsList.Reverse(); - foreach (var packageVersion in allVersionsList) + foreach (var pkgVersionTagInfo in allVersionsList) { - var packageVersionStr = packageVersion.ToString(); - using (JsonDocument pkgVersionEntry = JsonDocument.Parse(packageVersionStr)) + using (JsonDocument pkgVersionEntry = JsonDocument.Parse(pkgVersionTagInfo.ToString())) { JsonElement rootDom = pkgVersionEntry.RootElement; if (!rootDom.TryGetProperty("name", out JsonElement pkgVersionElement)) @@ -218,7 +199,11 @@ public override FindResults FindName(string packageName, bool includePrerelease, if (!pkgVersion.IsPrerelease || includePrerelease) { // Versions are always in descending order i.e 5.0.0, 3.0.0, 1.0.0 so grabbing the first match suffices - latestVersionResponse.Add(new Hashtable() { { packageName, packageVersionStr } }); + latestVersionResponse.Add(GetACRMetadata(registry, packageName, pkgVersion, acrAccessToken, out errRecord)); + if (errRecord != null) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: latestVersionResponse.ToArray(), responseType: acrFindResponseType); + } break; } @@ -339,26 +324,9 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } - /* response returned looks something like: - * "registry": "myregistry.azurecr.io" - * "imageName": "hello-world" - * "tags": [ - * { - * ""name"": ""1.0.0"", - * ""digest"": ""sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"", - * ""createdTime"": ""2023-12-23T18:06:48.9975733Z"", - * ""lastUpdateTime"": ""2023-12-23T18:06:48.9975733Z"", - * ""signed"": false, - * ""changeableAttributes"": { - * ""deleteEnabled"": true, - * ""writeEnabled"": true, - * ""readEnabled"": true, - * ""listEnabled"": true - * } - * }] - */ List latestVersionResponse = new List(); List allVersionsList = foundTags["tags"].ToList(); + allVersionsList.Reverse(); foreach (var packageVersion in allVersionsList) { var packageVersionStr = packageVersion.ToString(); @@ -387,7 +355,11 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange continue; } - latestVersionResponse.Add(new Hashtable() { { packageName, packageVersionStr } }); + latestVersionResponse.Add(GetACRMetadata(registry, packageName, pkgVersion, acrAccessToken, out errRecord)); + if (errRecord != null) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: latestVersionResponse.ToArray(), responseType: acrFindResponseType); + } } } } @@ -453,59 +425,17 @@ public override FindResults FindVersion(string packageName, string version, Reso } _cmdletPassedIn.WriteVerbose("Getting tags"); - var foundTags = FindAcrImageTags(registry, packageName, requiredVersion.ToString(), acrAccessToken, out errRecord); - if (errRecord != null || foundTags == null) + List results = new List { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } - - /* response returned looks something like: - * "registry": "myregistry.azurecr.io" - * "imageName": "hello-world" - * "tags": [ - * { - * ""name"": ""1.0.0"", - * ""digest"": ""sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"", - * ""createdTime"": ""2023-12-23T18:06:48.9975733Z"", - * ""lastUpdateTime"": ""2023-12-23T18:06:48.9975733Z"", - * ""signed"": false, - * ""changeableAttributes"": { - * ""deleteEnabled"": true, - * ""writeEnabled"": true, - * ""readEnabled"": true, - * ""listEnabled"": true - * } - * }] - */ - List requiredVersionResponse = new List(); - - var packageVersionStr = foundTags["tag"].ToString(); - using (JsonDocument pkgVersionEntry = JsonDocument.Parse(packageVersionStr)) + GetACRMetadata(registry, packageName, requiredVersion, acrAccessToken, out errRecord) + }; + if (errRecord != null) { - JsonElement rootDom = pkgVersionEntry.RootElement; - if (!rootDom.TryGetProperty("name", out JsonElement pkgVersionElement)) - { - errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain version element ('name') for package '{packageName}' in '{Repository.Name}'."), - "FindNameFailure", - ErrorCategory.InvalidResult, - this); - - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } - - if (NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) - { - _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); - - if (pkgVersion == requiredVersion) - { - requiredVersionResponse.Add(new Hashtable() { { packageName, packageVersionStr } }); - } - } + return new FindResults(stringResponse: new string[] { }, hashtableResponse: results.ToArray(), responseType: acrFindResponseType); } - return new FindResults(stringResponse: new string[] { }, hashtableResponse: requiredVersionResponse.ToArray(), responseType: acrFindResponseType); + + return new FindResults(stringResponse: new string[] { }, hashtableResponse: results.ToArray(), responseType: acrFindResponseType); } /// @@ -709,6 +639,24 @@ internal async Task GetAcrBlobAsync(string registry, string reposit internal JObject FindAcrImageTags(string registry, string repositoryName, string version, string acrAccessToken, out ErrorRecord errRecord) { + /* response returned looks something like: + * "registry": "myregistry.azurecr.io" + * "imageName": "hello-world" + * "tags": [ + * { + * ""name"": ""1.0.0"", + * ""digest"": ""sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"", + * ""createdTime"": ""2023-12-23T18:06:48.9975733Z"", + * ""lastUpdateTime"": ""2023-12-23T18:06:48.9975733Z"", + * ""signed"": false, + * ""changeableAttributes"": { + * ""deleteEnabled"": true, + * ""writeEnabled"": true, + * ""readEnabled"": true, + * ""listEnabled"": true + * } + * }] + */ try { string resolvedVersion = string.Equals(version, "*", StringComparison.OrdinalIgnoreCase) ? null : $"/{version}"; @@ -722,6 +670,141 @@ internal JObject FindAcrImageTags(string registry, string repositoryName, string } } + internal Hashtable GetACRMetadata(string registry, string packageName, NuGetVersion requiredVersion, string acrAccessToken, out ErrorRecord errRecord) + { + Hashtable requiredVersionResponse = new Hashtable(); + + var foundTags = FindAcrManifest(registry, packageName, requiredVersion.ToNormalizedString(), acrAccessToken, out errRecord); + if (errRecord != null || foundTags == null) + { + return requiredVersionResponse; + } + + /* Response returned looks something like: + * { + * "schemaVersion": 2, + * "config": { + * "mediaType": "application/vnd.unknown.config.v1+json", + * "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + * "size": 0 + * }, + * "layers": [ + * { + * "mediaType": "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip'", + * "digest": "sha256:7c55c7b66cb075628660d8249cc4866f16e34741c246a42ed97fb23ccd4ea956", + * "size": 3533, + * "annotations": { + * "org.opencontainers.image.title": "test_module.1.0.0.nupkg", + * "metadata": "{\"GUID\":\"45219bf4-10a4-4242-92d6-9bfcf79878fd\",\"FunctionsToExport\":[],\"CompanyName\":\"Anam\",\"CmdletsToExport\":[],\"VariablesToExport\":\"*\",\"Author\":\"Anam Navied\",\"ModuleVersion\":\"1.0.0\",\"Copyright\":\"(c) Anam Navied. All rights reserved.\",\"PrivateData\":{\"PSData\":{\"Tags\":[\"Test\",\"CommandsAndResource\",\"Tag2\"]}},\"RequiredModules\":[],\"Description\":\"This is a test module, for PSGallery team internal testing. Do not take a dependency on this package. This version contains tags for the package.\",\"AliasesToExport\":[]}" + * } + * } + * ] + * } + */ + + Tuple metadataTuple = GetMetadataProperty(foundTags, packageName, out Exception exception); + if (exception != null) + { + errRecord = new ErrorRecord(exception, "FindNameFailure", ErrorCategory.InvalidResult, this); + + return requiredVersionResponse; + } + + string metadataPkgName = metadataTuple.Item1; + string metadata = metadataTuple.Item2; + using (JsonDocument metadataJSONDoc = JsonDocument.Parse(metadata)) + { + JsonElement rootDom = metadataJSONDoc.RootElement; + if (!rootDom.TryGetProperty("ModuleVersion", out JsonElement pkgVersionElement) && + !rootDom.TryGetProperty("Version", out pkgVersionElement)) + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain 'ModuleVersion' or 'Version' property in metadata for package '{packageName}' in '{Repository.Name}'."), + "FindNameFailure", + ErrorCategory.InvalidResult, + this); + + return requiredVersionResponse; + } + + if (NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) + { + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + + if (pkgVersion == requiredVersion) + { + requiredVersionResponse.Add(metadataPkgName, metadata); + } + } + } + + return requiredVersionResponse; + } + + internal Tuple GetMetadataProperty(JObject foundTags, string packageName, out Exception exception) + { + exception = null; + var emptyTuple = new Tuple(string.Empty, string.Empty); + var layers = foundTags["layers"]; + if (layers == null || layers[0] == null) + { + exception = new InvalidOrEmptyResponse($"Response does not contain 'layers' element in manifest for package '{packageName}' in '{Repository.Name}'."); + + return emptyTuple; + } + + var annotations = layers[0]["annotations"]; + if (annotations == null) + { + exception = new InvalidOrEmptyResponse($"Response does not contain 'annotations' element in manifest for package '{packageName}' in '{Repository.Name}'."); + + return emptyTuple; + } + + if (annotations["metadata"] == null) + { + exception = new InvalidOrEmptyResponse($"Response does not contain 'metadata' element in manifest for package '{packageName}' in '{Repository.Name}'."); + + return emptyTuple; + } + + var metadata = annotations["metadata"].ToString(); + + var metadataPkgNameJToken = annotations["packageName"]; + if (metadataPkgNameJToken == null) + { + exception = new InvalidOrEmptyResponse($"Response does not contain 'packageName' element for package '{packageName}' in '{Repository.Name}'."); + + return emptyTuple; + } + + string metadataPkgName = metadataPkgNameJToken.ToString(); + if (string.IsNullOrWhiteSpace(metadataPkgName)) + { + exception = new InvalidOrEmptyResponse($"Response element 'packageName' is empty for package '{packageName}' in '{Repository.Name}'."); + + return emptyTuple; + } + + return new Tuple(metadataPkgName, metadata); + } + + internal JObject FindAcrManifest(string registry, string packageName, string version, string acrAccessToken, out ErrorRecord errRecord) + { + try + { + var createManifestUrl = string.Format(acrManifestUrlTemplate, registry, packageName, version); + _cmdletPassedIn.WriteDebug($"GET manifest url: {createManifestUrl}"); + + var defaultHeaders = GetDefaultHeaders(acrAccessToken); + return GetHttpResponseJObjectUsingDefaultHeaders(createManifestUrl, HttpMethod.Get, defaultHeaders, out errRecord); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error finding ACR manifest: " + e.Message); + } + } + internal async Task GetStartUploadBlobLocation(string registry, string pkgName, string acrAccessToken) { try @@ -1113,7 +1196,7 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p FileInfo nupkgFile = new FileInfo(fullNupkgFile); var fileSize = nupkgFile.Length; var fileName = System.IO.Path.GetFileName(fullNupkgFile); - string fileContent = CreateJsonContent(nupkgDigest, configDigest, fileSize, fileName, jsonString); + string fileContent = CreateJsonContent(nupkgDigest, configDigest, fileSize, fileName, pkgName, jsonString); File.WriteAllText(configFilePath, fileContent); _cmdletPassedIn.WriteVerbose("Create the manifest layer"); @@ -1130,7 +1213,8 @@ private string CreateJsonContent( string nupkgDigest, string configDigest, long nupkgFileSize, - string fileName, + string fileName, + string packageName, string jsonString) { StringBuilder stringBuilder = new StringBuilder(); @@ -1167,6 +1251,8 @@ private string CreateJsonContent( jsonWriter.WritePropertyName("annotations"); jsonWriter.WriteStartObject(); jsonWriter.WritePropertyName("org.opencontainers.image.title"); + jsonWriter.WriteValue(packageName); + jsonWriter.WritePropertyName("org.opencontainers.image.description"); jsonWriter.WriteValue(fileName); jsonWriter.WritePropertyName("metadata"); jsonWriter.WriteValue(jsonString); diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 63bad8eaa..91e802ece 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -829,7 +829,7 @@ public static bool TryConvertFromACRJson( JsonElement rootDom = packageMetadata.RootElement; // Version - if (rootDom.TryGetProperty("name", out JsonElement versionElement)) + if (rootDom.TryGetProperty("ModuleVersion", out JsonElement versionElement)) { string versionValue = versionElement.ToString(); metadata["Version"] = ParseHttpVersion(versionValue, out string prereleaseLabel); @@ -848,36 +848,119 @@ public static bool TryConvertFromACRJson( metadata["NormalizedVersion"] = parsedNormalizedVersion; } + // License Url + if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement)) + { + metadata["LicenseUrl"] = ParseHttpUrl(licenseUrlElement.ToString()) as Uri; + } + + // Project Url + if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement)) + { + metadata["ProjectUrl"] = ParseHttpUrl(projectUrlElement.ToString()) as Uri; + } + + // Icon Url + if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement)) + { + metadata["IconUrl"] = ParseHttpUrl(iconUrlElement.ToString()) as Uri; + } + + // Tags + if (rootDom.TryGetProperty("Tags", out JsonElement tagsElement)) + { + string[] pkgTags = Utils.EmptyStrArray; + if (tagsElement.ValueKind == JsonValueKind.Array) + { + var arrayLength = tagsElement.GetArrayLength(); + List tags = new List(arrayLength); + foreach (var tag in tagsElement.EnumerateArray()) + { + tags.Add(tag.ToString()); + } + + pkgTags = tags.ToArray(); + } + else if (tagsElement.ValueKind == JsonValueKind.String) + { + string tagStr = tagsElement.ToString(); + pkgTags = tagStr.Split(Utils.WhitespaceSeparator, StringSplitOptions.RemoveEmptyEntries); + } + + metadata["Tags"] = pkgTags; + } + // PublishedDate - if (rootDom.TryGetProperty("lastUpdateTime", out JsonElement publishedElement)) + if (rootDom.TryGetProperty("Published", out JsonElement publishedElement)) { metadata["PublishedDate"] = ParseHttpDateTime(publishedElement.ToString()); } - var additionalMetadataHashtable = new Dictionary { }; + // Dependencies + // TODO + + // IsPrerelease + if (rootDom.TryGetProperty("IsPrerelease", out JsonElement isPrereleaseElement)) + { + metadata["IsPrerelease"] = isPrereleaseElement.GetBoolean(); + } + + // Author + if (rootDom.TryGetProperty("Authors", out JsonElement authorsElement)) + { + metadata["Authors"] = authorsElement.ToString(); + + // CompanyName + // CompanyName is not provided in v3 pkg metadata response, so we've just set it to the author, + // which is often the company + metadata["CompanyName"] = authorsElement.ToString(); + } + + // Copyright + if (rootDom.TryGetProperty("Copyright", out JsonElement copyrightElement)) + { + metadata["Copyright"] = copyrightElement.ToString(); + } + + // Description + if (rootDom.TryGetProperty("Description", out JsonElement descriptiontElement)) + { + metadata["Description"] = descriptiontElement.ToString(); + } + + // ReleaseNotes + if (rootDom.TryGetProperty("ReleaseNotes", out JsonElement releaseNotesElement)) + { + metadata["ReleaseNotes"] = releaseNotesElement.ToString(); + } + + var additionalMetadataHashtable = new Dictionary + { + { "NormalizedVersion", metadata["NormalizedVersion"].ToString() } + }; psGetInfo = new PSResourceInfo( additionalMetadata: additionalMetadataHashtable, - author: string.Empty, - companyName: string.Empty, - copyright: string.Empty, - dependencies: new Dependency[] { }, - description: string.Empty, + author: metadata["Authors"] as String, + companyName: metadata["CompanyName"] as String, + copyright: metadata["Copyright"] as String, + dependencies: metadata["Dependencies"] as Dependency[], + description: metadata["Description"] as String, iconUri: null, includes: null, installedDate: null, installedLocation: null, isPrerelease: (bool)metadata["IsPrerelease"], - licenseUri: null, + licenseUri: metadata["LicenseUrl"] as Uri, name: packageName, powershellGetFormatVersion: null, prerelease: metadata["Prerelease"] as String, - projectUri: null, + projectUri: metadata["ProjectUrl"] as Uri, publishedDate: metadata["PublishedDate"] as DateTime?, - releaseNotes: string.Empty, + releaseNotes: metadata["ReleaseNotes"] as String, repository: repository.Name, repositorySourceLocation: repository.Uri.ToString(), - tags: new string[] { }, + tags: metadata["Tags"] as string[], type: ResourceType.None, updatedDate: null, version: metadata["Version"] as Version); From 6b4cf0a85d4fc90a10d5bb28dee15f3f1d55a59e Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Thu, 8 Feb 2024 14:53:45 -0500 Subject: [PATCH 043/160] update changelog for 1.0.1 and 1.0.2 releases (#1554) --- CHANGELOG.md | 401 +------------------------------------ CHANGELOG/1.0.md | 467 +++++++++++++++++++++++++++++++++++++++++++ CHANGELOG/preview.md | 1 + 3 files changed, 469 insertions(+), 400 deletions(-) create mode 100644 CHANGELOG/1.0.md create mode 100644 CHANGELOG/preview.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a3003387a..08dacccbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,400 +1 @@ -# CHANGELOG -## 1.0.0 - -### New Features -- Add `ApiVersion` parameter for `Register-PSResourceRepository` (#1431) - -### Bug Fixes -- Automatically set the ApiVersion to v2 for repositories imported from PowerShellGet (#1430) -- Bug fix ADO v2 feed installation failures (#1429) -- Bug fix Artifactory v2 endpoint failures (#1428) -- Bug fix Artifactory v3 endpoint failures (#1427) -- Bug fix `-RequiredResource` silent failures (#1426) -- Bug fix for v2 repository returning extra packages for `-Tag` based search with `-Prerelease` (#1405) - -## 0.9.0-rc1 - -### Bug Fixes -- Bug fix for using `Import-PSGetRepository` in Windows PowerShell (#1390) -- Add error handling when searching for unlisted package versions (#1386) -- Bug fix for deduplicating dependencies found from `Find-PSResource` (#1382) -- Added support for non-PowerShell Gallery v2 repositories (#1380) -- Bug fix for setting 'unknown' repository `APIVersion` (#1377) -- Bug fix for saving a script with `-IncludeXML` parameter (#1375) -- Bug fix for v3 server logic to properly parse inner @id element (#1374) -- Bug fix to write warning instead of error when package is already installed (#1367) - -## 0.5.24-beta24 - -### Bug Fixes -- Detect empty V2 server responses at ServerApiCall level instead of ResponseUtil level (#1358) -- Bug fix for finding all versions of a package returning correct results and incorrect "package not found" error (#1356) -- Bug fix for installing or saving a pkg found in lower priority repository (#1350) -- Ensure `-Prerelease` is not empty or whitespace for `Update-PSModuleManifest` (#1348) -- Bug fix for saving `Az` module dependencies (#1343) -- Bug fix for `Find-PSResource` repository looping to to return matches from all repositories (#1342) -- Update error handling for Tags, Commands, and DSCResources when searching across repositories (#1339) -- Update `Find-PSResource` looping and error handling to account for multiple package names (#1338) -- Update error handling for `Find-PSResource` using V2 server endpoint repositories (#1329) -- Bug fix for searching through multiple repositories when some repositories do not contain the specified package (#1328) -- Add parameters to `Install-PSResource` verbose message (#1327) -- Bug fix for parsing required modules when publishing (#1326) -- Bug fix for saving dependency modules in version range format (#1323) -- Bug fix for `Install-PSResource` failing to find prerelease dependencies (#1322) -- Bug fix for updating to a new version of a prerelease module (#1320) -- Fix for error message when DSCResource is not found (#1317) -- Add error handling for local repository pattern based searching (#1316) -- `Set-PSResourceRepository` run without `-ApiVersion` paramater no longer resets the property for the repository (#1310) - -## 0.5.23-beta23 - -### Breaking Changes - - -### New Features -- *-PSResourceRepository -Uri now accepting PSPaths (#1269) -- Add aliases for Install-PSResource, Find-PSResource, Update-PSResource, Publish-PSResource (#1264) -- Add custom user agent string to API calls (#1260) -- Support install for NuGet.Server application hosted feed (#1253) -- Add support for NuGet.Server application hosted feeds (#1236) -- Add Import-PSGetRepository function to import existing v2 PSRepositories into PSResourceRepositories. (#1221) -- Add 'Get-PSResource' alias to 'Get-InstalledPSResource' (#1216) -- Add -ApiVersion parameter to Set-PSResourceRepository (#1207) -- Add support for FindNameGlobbing scenarios (i.e -Name az*) for MyGet server repository (V3) (#1202) - - -### Bug Fixes -- Better error handling for scenario where repo ApiVersion is unknown and allow for PSPaths as URI for registered repositories (#1288) -- Bugfix for Uninstall should be able to remove older versions of a package that are not a dependency (#1287) -- Bugfix for Publish finding prerelease dependency versions. (#1283) -- Fix Pagination for V3 search with globbing scenarios (#1277) -- Update message for -WhatIf in Install-PSResource, Save-PSResource, and Update-PSResource (#1274) -- Bug fix for publishing with ExternalModuleDependencies (#1271) -- Support Credential Persistence for Publish-PSResource (#1268) -- Update Save-PSResource -Path param so it defaults to the current working directory (#1265) -- Update dependency error message in Publish-PSResource (#1263) -- Bug fixes for script metadata (#1259) -- Fix error message for Publish-PSResource for MyGet.org feeds (#1256) -- Bug fix for version ranges with prerelease versions not returning the correct versions (#1255) -- Bug fix for file path version must match psd1 version error when publishing (#1254) -- Bug fix for searching through local repositories with -Type parameter (#1252) -- Allow environment variables in module manifests (#1249 Thanks @ThomasNieto!) -- Updating prerelease version should update to latest prerelease version (#1238) -- Fix InstallHelper call to GetEnvironmentVariable() on Unix (#1237) -- Update build script to resolve module loading error (#1234) -- Enable UNC Paths for local repositories, source directories and destination directories (#1229 Thanks @FriedrichWeinmann!) -- Improve better error handling for -Path in Publish-PSResource (#1227) -- Bug fix for RequireLicenseAcceptance in Publish-PSResource (#1225) -- Provide clearer error handling for V3 Publish support (#1224) -- Fix bug with version parsing in Publish-PSResource (#1223) -- Improve error handling for Find-PSResource (#1222) -- Add error handling to Get-InstalledPSResource and Find-PSResource (#1217) -- Improve error handling in Uninstall-PSResource (#1215) -- Change resolved paths to use GetResolvedProviderPathFromPSPath (#1209) -- Bug fix for Get-InstalledPSResource returning type of scripts as module (#1198) - - -# CHANGELOG -## 0.5.22-beta22 - -### Breaking Changes -- PowerShellGet is now PSResourceGet! (#1164) -- Update-PSScriptFile is now Update-PSScriptFileInfo (#1140) -- New-PSScriptFile is now New-PSScriptFileInfo (#1140) -- Update-ModuleManifest is now Update-PSModuleManifest (#1139) -- -Tags parameter changed to -Tag in New-PSScriptFile, Update-PSScriptFileInfo, and Update-ModuleManifest (#1123) -- Change the type of -InputObject from PSResourceInfo to PSResourceInfo[] for Install-PSResource, Save-PSResource, and Uninstall-PSResource (#1124) - -- PSModulePath is no longer referenced when searching paths (#1154) - -### New Features -- Support for Azure Artifacts, GitHub Packages, and Artifactory (#1167, #1180, #1183) - - -### Bug Fixes -- Filter out unlisted packages (#1172, #1161) -- Add paging for V3 server requests (#1170) -- Support for floating versions (#1117) -- Update, Save, and Install with wildcard gets the latest version within specified range (#1117) -- Add positonal parameter for -Path in Publish-PSResource (#1111) -- Uninstall-PSResource -WhatIf now shows version and path of package being uninstalled (#1116) -- Find returns packages from the highest priority repository only (#1155) -- Bug fix for PSCredentialInfo constructor (#1156) -- Bug fix for Install-PSResource -NoClobber parameter (#1121) -- Save-PSResource now searches through all repos when no repo is specified (#1125) -- Caching for improved performance in Uninstall-PSResource (#1175) -- Bug fix for parsing package tags for packages that only have .nuspec from local repository (#1119) - - -## 3.0.21-beta21 - -### New Features -- Move off of NuGet client APIs for local repositories (#1065) - -### Bug Fixes -- Update properties on PSResourceInfo object (#1077) -- Rename PSScriptFileInfo and Get-PSResource cmdlets (#1071) -- fix ValueFromPipelineByPropertyName on Save, Install (#1070) -- add Help message for mandatory params across cmdlets (#1068) -- fix version range bug for Update-PSResource (#1067) -- Fix attribute bugfixes for Find and Install params (#1066) -- Correct Unexpected spelling of Unexpected (#1059) -- Resolve bug with Find-PSResource -Type Module not returning modules (#1050) -- Inject credentials to ISettings to pass them into PushRunner (#993) - -## 3.0.20-beta20 - -- Move off of NuGet client APIs and use direct REST API calls for remote repositories (#1023) - - -### Bug Fixes -- Updates to dependency installation (#1010) (#996) (#907) -- Update to retrieving all packages installed on machine (#999) -- PSResourceInfo version correctly displays 2 or 3 digit version numbers (#697) -- Using `Find-PSresource` with `-CommandName` or `-DSCResourceName` parameters returns an object with a properly expanded ParentResource member (#754) -- `Find-PSResource` no longer returns duplicate results (#755) -- `Find-PSResource` lists repository 'PSGalleryScripts' which does not exist for `Get-PSResourceRepository` (#1028) - -## 3.0.19-beta19 - -### New Features -- Add `-SkipModuleManifestValidate` parameter to `Publish-PSResource` (#904) - -### Bug Fixes -- Add new parameter sets for `-IncludeXml` and `-AsNupkg` parameters in `Install-PSResource` (#910) -- Change warning to error in `Update-PSResource` when no module is already installed (#909) -- Fix `-NoClobber` bug throwing error in `Install-PSResource` (#908) -- Remove warning when installing dependencies (#907) -- Remove Proxy parameters from `Register-PSResourceRepository` (#906) -- Remove -PassThru parameter from `Update-ModuleManifest` (#900) - -## 3.0.18-beta18 - -### New Features -- Add Get-PSScriptFileInfo cmdlet (#839) -- Allow CredentialInfo parameter to accept a hashtable (#836) - -### Bug Fixes -- Publish-PSResource now preserves folder and file structure (#882) -- Fix verbose message for untrusted repos gaining trust (#841) -- Fix for Update-PSResource attempting to reinstall latest preview version (#834) -- Add SupportsWildcards() attribute to parameters accepting wildcards (#833) -- Perform Repository trust check when installing a package (#831) -- Fix casing of `PSResource` in `Install-PSResource` (#820) -- Update .nuspec 'license' property to 'licenseUrl' (#850) - -## 3.0.17-beta17 - -### New Features -- Add -TemporaryPath parameter to Install-PSResource, Save-PSResource, and Update-PSResource (#763) -- Add String and SecureString as credential types in PSCredentialInfo (#764) -- Add a warning for when the script installation path is not in Path variable (#750) -- Expand acceptable paths for Publish-PSResource (Module root directory, module manifest file, script file)(#704) -- Add -Force parameter to Register-PSResourceRepository cmdlet, to override an existing repository (#717) - -### Bug Fixes -- Change casing of -IncludeXML to -IncludeXml (#739) -- Update priority range for PSResourceRepository to 0-100 (#741) -- Editorial pass on cmdlet reference (#743) -- Fix issue when PSScriptInfo has no empty lines (#744) -- Make ConfirmImpact low for Register-PSResourceRepository and Save-PSResource (#745) -- Fix -PassThru for Set-PSResourceRepository cmdlet to return all properties (#748) -- Rename -FilePath parameter to -Path for PSScriptFileInfo cmdlets (#765) -- Fix RequiredModules description and add Find example to docs (#769) -- Remove unneeded inheritance in InstallHelper.cs (#773) -- Make -Path a required parameter for Save-PSResource cmdlet (#780) -- Improve script validation for publishing and installing (#781) - -## 3.0.16-beta16 - -### Bug Fixes -- Update NuGet dependency packages for security vulnerabilities (#733) - -## 3.0.15-beta15 - -### New Features -- Implementation of New-ScriptFileInfo, Update-ScriptFileInfo, and Test-ScriptFileInfo cmdlets (#708) -- Implementation of Update-ModuleManifest cmdlet (#677) -- Implentation of Authenticode validation via -AuthenticodeCheck for Install-PSResource (#632) - -### Bug Fixes -- Bug fix for installing modules with manifests that contain dynamic script blocks (#681) - -## 3.0.14-beta14 - -### Bug Fixes -- Bug fix for repository store (#661) - -## 3.0.13-beta - -### New Features -- Implementation of -RequiredResourceFile and -RequiredResource parameters for Install-PSResource (#610, #592) -- Scope parameters for Get-PSResource and Uninstall-PSResource (#639) -- Support for credential persistence (#480 Thanks @cansuerdogan!) - -### Bug Fixes -- Bug fix for publishing scripts (#642) -- Bug fix for publishing modules with 'RequiredModules' specified in the module manifest (#640) - -### Changes -- 'SupportsWildcard' attribute added to Find-PSResource, Get-PSResource, Get-PSResourceRepository, Uninstall-PSResource, and Update-PSResource (#658) -- Updated help documentation (#651) -- -Repositories parameter changed to singular -Repository in Register-PSResource and Set-PSResource (#645) -- Better prerelease support for Uninstall-PSResource (#593) -- Rename PSResourceInfo's PrereleaseLabel property to match Prerelease column displayed (#591) -- Renaming of parameters -Url to -Uri (#551 Thanks @fsackur!) - -## 3.0.12-beta - -### Changes -- Support searching for all packages from a repository (i.e 'Find-PSResource -Name '*''). Note, wildcard search is not supported for AzureDevOps feed repositories and will write an error message accordingly). -- Packages found are now unique by Name,Version,Repository. -- Support searching for and returning packages found across multiple repositories when using wildcard with Repository parameter (i.e 'Find-PSResource -Name 'PackageExistingInMultipleRepos' -Repository '*'' will perform an exhaustive search). - - PSResourceInfo objects can be piped into: Install-PSResource, Uninstall-PSResource, Save-PSResource. PSRepositoryInfo objects can be piped into: Unregister-PSResourceRepository -- For more consistent pipeline support, the following cmdlets have pipeline support for the listed parameter(s): - - Find-PSResource (Name param, ValueFromPipeline) - - Get-PSResource (Name param, ValueFromPipeline) - - Install-PSResource (Name param, ValueFromPipeline) - - Publish-PSResource (None) - - Save-PSResource (Name param, ValueFromPipeline) - - Uninstall-PSResource (Name param, ValueFromPipeline) - - Update-PSResource (Name param, ValueFromPipeline) - - Get-PSResourceRepository (Name param, ValueFromPipeline) - - Set-PSResourceRepository (Name param, ValueFromPipeline) - - Register-PSResourceRepository (None) - - Unregister-PSResourceRepository (Name param, ValueFromPipelineByPropertyName) -- Implement '-Tag' parameter set for Find-PSResource (i.e 'Find-PSResource -Tag 'JSON'') -- Implement '-Type' parameter set for Find-PSResource (i.e 'Find-PSResource -Type Module') -- Implement CommandName and DSCResourceName parameter sets for Find-PSResource (i.e Find-PSResource -CommandName "Get-TargetResource"). -- Add consistent pre-release version support for cmdlets, including Uninstall-PSResource and Get-PSResource. For example, running 'Get-PSResource 'MyPackage' -Version '2.0.0-beta'' would only return MyPackage with version "2.0.0" and prerelease "beta", NOT MyPackage with version "2.0.0.0" (i.e a stable version). -- Add progress bar for installation completion for Install-PSResource, Update-PSResource and Save-PSResource. -- Implement '-Quiet' param for Install-PSResource, Save-PSResource and Update-PSResource. This suppresses the progress bar display when passed in. -- Implement '-PassThru' parameter for all appropriate cmdlets. Install-PSResource, Save-PSResource, Update-PSResource and Unregister-PSResourceRepository cmdlets now have '-PassThru' support thus completing this goal. -- Implement '-SkipDependencies' parameter for Install-PSResource, Save-PSResource, and Update-PSResource cmdlets. -- Implement '-AsNupkg' and '-IncludeXML' parameters for Save-PSResource. -- Implement '-DestinationPath' parameter for Publish-PSResource -- Add '-NoClobber' functionality to Install-PSResource. -- Add thorough error handling to Update-PSResource to cover more cases and gracefully write errors when updates can't be performed. -- Add thorough error handling to Install-PSResource to cover more cases and not fail silently when installation could not happen successfully. Also fixes bug where package would install even if it was already installed and '-Reinstall' parameter was not specified. -- Restore package if installation attempt fails when reinstalling a package. -- Fix bug with some Modules installing as Scripts. -- Fix bug with separating '$env:PSModulePath' to now work with path separators across all OS systems including Unix. -- Fix bug to register repositories with local file share paths, ensuring repositories with valid URIs can be registered. -- Revert cmdlet name 'Get-InstalledPSResource' to 'Get-PSResource' -- Remove DSCResources from PowerShellGet. -- Remove unnecessary assemblies. - -## 3.0.11-beta - -### Changes -- Graceful handling of paths that do not exist -- The repository store (PSResourceRepository.xml) is auto-generated if it does not already exist. It also automatically registers the PowerShellGallery with a default priority of 50 and a default trusted value of false. -- Better Linux support, including graceful exits when paths do not exist -- Better pipeline input support all cmdlets -- General wildcard support for all cmdlets -- WhatIf support for all cmdlets -- All cmdlets output concrete return types -- Better help documentation for all cmdlets -- Using an exact prerelease version with Find, Install, or Save no longer requires `-Prerelease` tag -- Support for finding, installing, saving, and updating PowerShell resources from Azure Artifact feeds -- Publish-PSResource now properly dispays 'Tags' in nuspec -- Find-PSResource quickly cancels transactions with 'CTRL + C' -- Register-PSRepository now handles relative paths -- Find-PSResource and Save-PSResource deduplicates dependencies -- Install-PSResource no longer creates version folder with the prerelease tag -- Update-PSResource can now update all resources, and no longer requires name param -- Save-PSResource properly handles saving scripts -- Get-InstalledPSResource uses default PowerShell paths - - -### Notes -In this release, all cmdlets have been reviewed and implementation code refactored as needed. -Cmdlets have most of their functionality, but some parameters are not yet implemented and will be added in future releases. -All tests have been reviewed and rewritten as needed. - - -## 3.0.0-beta10 -Bug Fixes -* Bug fix for -ModuleName (used with -Version) in Find-PSResource returning incorrect resource type -* Make repositories unique by name -* Add tab completion for -Name parameter in Get-PSResource, Set-PSResource, and Unregister-PSResource -* Remove credential argument from Register-PSResourceRepository -* Change returned version type from 'NuGet.Version' to 'System.Version' -* Have Install output verbose message on successful installation (error for unsuccessful installation) -* Ensure that not passing credentials does not throw an error if searching through multiple repositories -* Remove attempt to remove loaded assemblies in psm1 - -## 3.0.0-beta9 -New Features -* Add DSCResources - -Bug Fixes -* Fix bug related to finding dependencies that do not have a specified version in Find-PSResource -* Fix bug related to parsing 'RequiredModules' in .psd1 in Publish-PSResource -* Improve error handling for when repository in Publish-PSResource does not exist -* Fix for unix paths in Get-PSResource, Install-PSResource, and Uninstall-PSResource -* Add debugging statements for Get-PSResource and Install-PSResource -* Fix bug related to paths in Uninstall-PSResource - -## 3.0.0-beta8 -New Features -* Add Type parameter to Install-PSResource -* Add 'sudo' check for admin privileges in Unix in Install-PSResource - -Bug Fixes -* Fix bug with retrieving installed scripts in Get-PSResource -* Fix bug with AllUsers scope in Windows in Install-PSResource -* Fix bug with Uninstall-PSResource sometimes not fully uninstalling -* Change installed file paths to contain original version number instead of normalized version - -## 3.0.0-beta7 -New Features -* Completed functionality for Update-PSResource -* Input-Object parameter for Install-PSResource - -Bug Fixes -* Improved experience when loading module for diffent frameworks -* Bug fix for assembly loading error in Publish-PSResource -* Allow for relative paths when registering psrepository -* Improved error handling for Install-PSResource and Update-PSResource -* Remove prerelease tag from module version directory -* Fix error getting thrown from paths with incorrectly formatted module versions -* Fix module installation paths on Linux and MacOS - -## 3.0.0-beta6 -New Feature -* Implement functionality for Publish-PSResource - -## 3.0.0-beta5 -* Note: 3.0.0-beta5 was skipped due to a packaging error - -## 3.0.0-beta4 -New Feature -* Implement -Repository '*' in Find-PSResource to search through all repositories instead of prioritized repository - -Bug Fix -* Fix poor error handling for when repository is not accessible in Find-PSResource - -## 3.0.0-beta3 -New Features -* -RequiredResource parameter for Install-PSResource -* -RequiredResourceFile parameter for Install-PSResource -* -IncludeXML parameter in Save-PSResource - -Bug Fixes -* Resolved paths in Install-PSRsource and Save-PSResource -* Resolved issues with capitalization (for unix systems) in Install-PSResource and Save-PSResource - -## 3.0.0-beta2 -New Features -* Progress bar and -Quiet parameter for Install-PSResource -* -TrustRepository parameter for Install-PSResource -* -NoClobber parameter for Install-PSResource -* -AcceptLicense for Install-PSResource -* -Force parameter for Install-PSResource -* -Reinstall parameter for Install-PSResource -* Improved error handling - -## 3.0.0-beta1 -BREAKING CHANGE -* Preview version of PowerShellGet. Many features are not fully implemented yet. Please see https://devblogs.microsoft.com/powershell/powershellget-3-0-preview1 for more details. +The change logs have been split by version and moved to [CHANGELOG](./CHANGELOG). \ No newline at end of file diff --git a/CHANGELOG/1.0.md b/CHANGELOG/1.0.md new file mode 100644 index 000000000..8477b723c --- /dev/null +++ b/CHANGELOG/1.0.md @@ -0,0 +1,467 @@ +# 1.0 Changelog + +## [1.0.2](https://github.com/PowerShell/PSResourceGet/compare/v1.0.1...v1.0.2) - 2024-02-06 + +### Bug Fixes + +- Bug fix for `Update-PSResource` not updating from correct repository (#1549) +- Bug fix for creating temp home directory on Unix (#1544) +- Bug fix for creating `InstalledScriptInfos` directory when it does not exist (#1542) +- Bug fix for `Update-ModuleManifest` throwing null pointer exception (#1538) +- Bug fix for `name` property not populating in `PSResourceInfo` object when using `Find-PSResource` with JFrog Artifactory (#1535) +- Bug fix for incorrect configuration of requests to JFrog Artifactory v2 endpoints (#1533 Thanks @sean-r-williams!) +- Bug fix for determining JFrog Artifactory repositories (#1532 Thanks @sean-r-williams!) +- Bug fix for v2 server repositories incorrectly adding script endpoint (1526) +- Bug fixes for null references (#1525) +- Typo fixes in message prompts in `Install-PSResource` (#1510 Thanks @NextGData!) +- Bug fix to add `NormalizedVersion` property to `AdditionalMetadata` only when it exists (#1503 Thanks @sean-r-williams!) +- Bug fix to verify whether `Uri` is a UNC path and set respective `ApiVersion` (#1479 Thanks @kborowinski!) + +## [1.0.1](https://github.com/PowerShell/PSResourceGet/compare/v1.0.0...v1.0.1) - 2023-11-07 + +### Bug Fixes + +- Bugfix to update Unix local user installation paths to be compatible with .NET 7 and .NET 8 (#1464) +- Bugfix for Import-PSGetRepository in Windows PowerShell (#1460) +- Bugfix for `Test-PSScriptFileInfo`` to be less sensitive to whitespace (#1457) +- Bugfix to overwrite rels/rels directory on net472 when extracting nupkg to directory (#1456) +- Bugfix to add pipeline by property name support for Name and Repository properties for Find-PSResource (#1451 Thanks @ThomasNieto!) + +## 1.0.0 - 2023-10-09 + +### New Features + +- Add `ApiVersion` parameter for `Register-PSResourceRepository` (#1431) + +### Bug Fixes + +- Automatically set the ApiVersion to v2 for repositories imported from PowerShellGet (#1430) +- Bug fix ADO v2 feed installation failures (#1429) +- Bug fix Artifactory v2 endpoint failures (#1428) +- Bug fix Artifactory v3 endpoint failures (#1427) +- Bug fix `-RequiredResource` silent failures (#1426) +- Bug fix for v2 repository returning extra packages for `-Tag` based search with `-Prerelease` (#1405) + +## 0.9.0-rc1 + +### Bug Fixes + +- Bug fix for using `Import-PSGetRepository` in Windows PowerShell (#1390) +- Add error handling when searching for unlisted package versions (#1386) +- Bug fix for deduplicating dependencies found from `Find-PSResource` (#1382) +- Added support for non-PowerShell Gallery v2 repositories (#1380) +- Bug fix for setting 'unknown' repository `APIVersion` (#1377) +- Bug fix for saving a script with `-IncludeXML` parameter (#1375) +- Bug fix for v3 server logic to properly parse inner @id element (#1374) +- Bug fix to write warning instead of error when package is already installed (#1367) + +## 0.5.24-beta24 + +### Bug Fixes + +- Detect empty V2 server responses at ServerApiCall level instead of ResponseUtil level (#1358) +- Bug fix for finding all versions of a package returning correct results and incorrect "package not found" error (#1356) +- Bug fix for installing or saving a pkg found in lower priority repository (#1350) +- Ensure `-Prerelease` is not empty or whitespace for `Update-PSModuleManifest` (#1348) +- Bug fix for saving `Az` module dependencies (#1343) +- Bug fix for `Find-PSResource` repository looping to to return matches from all repositories (#1342) +- Update error handling for Tags, Commands, and DSCResources when searching across repositories (#1339) +- Update `Find-PSResource` looping and error handling to account for multiple package names (#1338) +- Update error handling for `Find-PSResource` using V2 server endpoint repositories (#1329) +- Bug fix for searching through multiple repositories when some repositories do not contain the specified package (#1328) +- Add parameters to `Install-PSResource` verbose message (#1327) +- Bug fix for parsing required modules when publishing (#1326) +- Bug fix for saving dependency modules in version range format (#1323) +- Bug fix for `Install-PSResource` failing to find prerelease dependencies (#1322) +- Bug fix for updating to a new version of a prerelease module (#1320) +- Fix for error message when DSCResource is not found (#1317) +- Add error handling for local repository pattern based searching (#1316) +- `Set-PSResourceRepository` run without `-ApiVersion` paramater no longer resets the property for the repository (#1310) + +## 0.5.23-beta23 + +### New Features + +- *-PSResourceRepository `-Uri` now accepting PSPaths (#1269) +- Add aliases for `Install-PSResource`, `Find-PSResource`, `Update-PSResource`, `Publish-PSResource` (#1264) +- Add custom user agent string to API calls (#1260) +- Support install for NuGet.Server application hosted feed (#1253) +- Add support for NuGet.Server application hosted feeds (#1236) +- Add Import-PSGetRepository function to import existing v2 PSRepositories into PSResourceRepositories. (#1221) +- Add `Get-PSResource` alias to `Get-InstalledPSResource` (#1216) +- Add `-ApiVersion` parameter to Set-PSResourceRepository (#1207) +- Add support for FindNameGlobbing scenarios (i.e -Name az*) for MyGet server repository (V3) (#1202) + +### Bug Fixes + +- Better error handling for scenario where repo ApiVersion is unknown and allow for PSPaths as URI for registered repositories (#1288) +- Bugfix for Uninstall should be able to remove older versions of a package that are not a dependency (#1287) +- Bugfix for Publish finding prerelease dependency versions. (#1283) +- Fix Pagination for V3 search with globbing scenarios (#1277) +- Update message for `-WhatIf` in `Install-PSResource`, `Save-PSResource`, and `Update-PSResource` (#1274) +- Bug fix for publishing with ExternalModuleDependencies (#1271) +- Support Credential Persistence for `Publish-PSResource` (#1268) +- Update `Save-PSResource` `-Path` param so it defaults to the current working directory (#1265) +- Update dependency error message in Publish-PSResource (#1263) +- Bug fixes for script metadata (#1259) +- Fix error message for `Publish-PSResource` for MyGet.org feeds (#1256) +- Bug fix for version ranges with prerelease versions not returning the correct versions (#1255) +- Bug fix for file path version must match psd1 version error when publishing (#1254) +- Bug fix for searching through local repositories with `-Type` parameter (#1252) +- Allow environment variables in module manifests (#1249 Thanks @ThomasNieto!) +- Updating prerelease version should update to latest prerelease version (#1238) +- Fix InstallHelper call to GetEnvironmentVariable() on Unix (#1237) +- Update build script to resolve module loading error (#1234) +- Enable UNC Paths for local repositories, source directories and destination directories (#1229 Thanks @FriedrichWeinmann!) +- Improve better error handling for `-Path` in `Publish-PSResource` (#1227) +- Bug fix for `-RequireLicenseAcceptance` in `Publish-PSResource` (#1225) +- Provide clearer error handling for V3 Publish support (#1224) +- Fix bug with version parsing in `Publish-PSResource` (#1223) +- Improve error handling for `Find-PSResource` (#1222) +- Add error handling to `Get-InstalledPSResource` and `Find-PSResource` (#1217) +- Improve error handling in `Uninstall-PSResource` (#1215) +- Change resolved paths to use GetResolvedProviderPathFromPSPath (#1209) +- Bug fix for `Get-InstalledPSResource` returning type of scripts as module (#1198) + +## 0.5.22-beta22 + +### Breaking Changes + +- PowerShellGet is now PSResourceGet! (#1164) +- `Update-PSScriptFile` is now `Update-PSScriptFileInfo` (#1140) +- `New-PSScriptFile` is now `New-PSScriptFileInfo` (#1140) +- `Update-ModuleManifest` is now `Update-PSModuleManifest` (#1139) +- `-Tags` parameter changed to `-Tag` in `New-PSScriptFile`, `Update-PSScriptFileInfo`, and `Update-ModuleManifest` (#1123) +- Change the type of `-InputObject` from PSResourceInfo to PSResourceInfo[] for `Install-PSResource`, `Save-PSResource`, and `Uninstall-PSResource` (#1124) +- PSModulePath is no longer referenced when searching paths (#1154) + +### New Features + +- Support for Azure Artifacts, GitHub Packages, and Artifactory (#1167, #1180, #1183) + +### Bug Fixes + +- Filter out unlisted packages (#1172, #1161) +- Add paging for V3 server requests (#1170) +- Support for floating versions (#1117) +- Update, Save, and Install with wildcard gets the latest version within specified range (#1117) +- Add positonal parameter for `-Path` in `Publish-PSResource` (#1111) +- `Uninstall-PSResource` `-WhatIf` now shows version and path of package being uninstalled (#1116) +- Find returns packages from the highest priority repository only (#1155) +- Bug fix for PSCredentialInfo constructor (#1156) +- Bug fix for `Install-PSResource` `-NoClobber` parameter (#1121) +- `Save-PSResource` now searches through all repos when no repo is specified (#1125) +- Caching for improved performance in `Uninstall-PSResource` (#1175) +- Bug fix for parsing package tags for packages that only have .nuspec from local repository (#1119) + +## 3.0.21-beta21 + +### New Features + +- Move off of NuGet client APIs for local repositories (#1065) + +### Bug Fixes + +- Update properties on PSResourceInfo object (#1077) +- Rename PSScriptFileInfo and `Get-PSResource` cmdlets (#1071) +- fix ValueFromPipelineByPropertyName on Save, Install (#1070) +- add Help message for mandatory params across cmdlets (#1068) +- fix version range bug for `Update-PSResource` (#1067) +- Fix attribute bugfixes for Find and Install params (#1066) +- Correct Unexpected spelling of Unexpected (#1059) +- Resolve bug with `Find-PSResource` `-Type` Module not returning modules (#1050) +- Inject credentials to ISettings to pass them into PushRunner (#993) + +## 3.0.20-beta20 + +- Move off of NuGet client APIs and use direct REST API calls for remote repositories (#1023) + +### Bug Fixes + +- Updates to dependency installation (#1010) (#996) (#907) +- Update to retrieving all packages installed on machine (#999) +- PSResourceInfo version correctly displays 2 or 3 digit version numbers (#697) +- Using `Find-PSresource` with `-CommandName` or `-DSCResourceName` parameters returns an object with a properly expanded ParentResource member (#754) +- `Find-PSResource` no longer returns duplicate results (#755) +- `Find-PSResource` lists repository 'PSGalleryScripts' which does not exist for `Get-PSResourceRepository` (#1028) + +## 3.0.19-beta19 + +### New Features + +- Add `-SkipModuleManifestValidate` parameter to `Publish-PSResource` (#904) + +### Bug Fixes + +- Add new parameter sets for `-IncludeXml` and `-AsNupkg` parameters in `Install-PSResource` (#910) +- Change warning to error in `Update-PSResource` when no module is already installed (#909) +- Fix `-NoClobber` bug throwing error in `Install-PSResource` (#908) +- Remove warning when installing dependencies (#907) +- Remove Proxy parameters from `Register-PSResourceRepository` (#906) +- Remove `-PassThru` parameter from `Update-ModuleManifest` (#900) + +## 3.0.18-beta18 + +### New Features + +- Add `Get-PSScriptFileInfo` cmdlet (#839) +- Allow `-CredentialInfo` parameter to accept a hashtable (#836) + +### Bug Fixes + +- `Publish-PSResource` now preserves folder and file structure (#882) +- Fix verbose message for untrusted repos gaining trust (#841) +- Fix for `Update-PSResource` attempting to reinstall latest preview version (#834) +- Add SupportsWildcards() attribute to parameters accepting wildcards (#833) +- Perform Repository trust check when installing a package (#831) +- Fix casing of `PSResource` in `Install-PSResource` (#820) +- Update .nuspec 'license' property to 'licenseUrl' (#850) + +## 3.0.17-beta17 + +### New Features + +- Add `-TemporaryPath` parameter to `Install-PSResource`, `Save-PSResource`, and `Update-PSResource` (#763) +- Add String and SecureString as credential types in PSCredentialInfo (#764) +- Add a warning for when the script installation path is not in Path variable (#750) +- Expand acceptable paths for `Publish-PSResource` (Module root directory, module manifest file, script file)(#704) +- Add `-Force` parameter to `Register-PSResourceRepository` cmdlet, to override an existing repository (#717) + +### Bug Fixes + +- Change casing of `-IncludeXML` to `-IncludeXml` (#739) +- Update priority range for PSResourceRepository to 0-100 (#741) +- Editorial pass on cmdlet reference (#743) +- Fix issue when PSScriptInfo has no empty lines (#744) +- Make ConfirmImpact low for `Register-PSResourceRepository` and `Save-PSResource` (#745) +- Fix `-PassThru` for `Set-PSResourceRepository` cmdlet to return all properties (#748) +- Rename `-FilePath` parameter to `-Path` for PSScriptFileInfo cmdlets (#765) +- Fix RequiredModules description and add Find example to docs (#769) +- Remove unneeded inheritance in InstallHelper.cs (#773) +- Make `-Path` a required parameter for `Save-PSResource` cmdlet (#780) +- Improve script validation for publishing and installing (#781) + +## 3.0.16-beta16 + +### Bug Fixes + +- Update NuGet dependency packages for security vulnerabilities (#733) + +## 3.0.15-beta15 + +### New Features + +- Implementation of `New-ScriptFileInfo`, `Update-ScriptFileInfo`, and `Test-ScriptFileInfo` cmdlets (#708) +- Implementation of `Update-ModuleManifest` cmdlet (#677) +- Implentation of Authenticode validation via `-AuthenticodeCheck` for `Install-PSResource` (#632) + +### Bug Fixes + +- Bug fix for installing modules with manifests that contain dynamic script blocks (#681) + +## 3.0.14-beta14 + +### Bug Fixes + +- Bug fix for repository store (#661) + +## 3.0.13-beta + +### New Features + +- Implementation of `-RequiredResourceFile` and `-RequiredResource` parameters for `Install-PSResource` (#610, #592) +- Scope parameters for `Get-PSResource` and `Uninstall-PSResource` (#639) +- Support for credential persistence (#480 Thanks @cansuerdogan!) + +### Bug Fixes + +- Bug fix for publishing scripts (#642) +- Bug fix for publishing modules with 'RequiredModules' specified in the module manifest (#640) + +### Changes + +- 'SupportsWildcard' attribute added to `Find-PSResource`, `Get-PSResource`, `Get-PSResourceRepository`, `Uninstall-PSResource`, and `Update-PSResource` (#658) +- Updated help documentation (#651) +- -Repositories parameter changed to singular `-Repository` in `Register-PSResource` and `Set-PSResource` (#645) +- Better prerelease support for `Uninstall-PSResource` (#593) +- Rename PSResourceInfo's PrereleaseLabel property to match Prerelease column displayed (#591) +- Renaming of parameters `-Url` to `-Uri` (#551 Thanks @fsackur!) + +## 3.0.12-beta + +### Changes + +- Support searching for all packages from a repository (i.e `Find-PSResource -Name '*'`). Note, wildcard search is not supported for AzureDevOps feed repositories and will write an error message accordingly. +- Packages found are now unique by Name,Version,Repository. +- Support searching for and returning packages found across multiple repositories when using wildcard with Repository parameter (i.e `Find-PSResource -Name 'PackageExistingInMultipleRepos' -Repository '*'` will perform an exhaustive search). + - PSResourceInfo objects can be piped into: `Install-PSResource`, `Uninstall-PSResource`, `Save-PSResource`. PSRepositoryInfo objects can be piped into: `Unregister-PSResourceRepository` +- For more consistent pipeline support, the following cmdlets have pipeline support for the listed parameter(s): + - `Find-PSResource` (Name param, ValueFromPipeline) + - `Get-PSResource` (Name param, ValueFromPipeline) + - `Install-PSResource` (Name param, ValueFromPipeline) + - `Publish-PSResource` (None) + - `Save-PSResource` (Name param, ValueFromPipeline) + - `Uninstall-PSResource` (Name param, ValueFromPipeline) + - `Update-PSResource` (Name param, ValueFromPipeline) + - `Get-PSResourceRepository` (Name param, ValueFromPipeline) + - `Set-PSResourceRepository` (Name param, ValueFromPipeline) + - `Register-PSResourceRepository` (None) + - `Unregister-PSResourceRepository` (Name param, ValueFromPipelineByPropertyName) +- Implement `-Tag` parameter set for `Find-PSResource` (i.e `Find-PSResource -Tag 'JSON'`) +- Implement `-Type` parameter set for `Find-PSResource` (i.e `Find-PSResource -Type Module`) +- Implement CommandName and DSCResourceName parameter sets for `Find-PSResource` (i.e `Find-PSResource -CommandName "Get-TargetResource"`). +- Add consistent pre-release version support for cmdlets, including `Uninstall-PSResource` and `Get-PSResource`. For example, running `Get-PSResource 'MyPackage' -Version '2.0.0-beta'` would only return MyPackage with version "2.0.0" and prerelease "beta", NOT MyPackage with version "2.0.0.0" (i.e a stable version). +- Add progress bar for installation completion for `Install-PSResource`, `Update-PSResource` and `Save-PSResource`. +- Implement `-Quiet` param for `Install-PSResource`, `Save-PSResource` and `Update-PSResource`. This suppresses the progress bar display when passed in. +- Implement `-PassThru` parameter for all appropriate cmdlets. `Install-PSResource`, `Save-PSResource`, `Update-PSResource` and `Unregister-PSResourceRepository` cmdlets now have `-PassThru` support thus completing this goal. +- Implement `-SkipDependencies` parameter for `Install-PSResource`, `Save-PSResource`, and `Update-PSResource` cmdlets. +- Implement `-AsNupkg` and `-IncludeXML` parameters for `Save-PSResource`. +- Implement `-DestinationPath` parameter for `Publish-PSResource`. +- Add `-NoClobber` functionality to `Install-PSResource`. +- Add thorough error handling to `Update-PSResource` to cover more cases and gracefully write errors when updates can't be performed. +- Add thorough error handling to `Install-PSResource` to cover more cases and not fail silently when installation could not happen successfully. Also fixes bug where package would install even if it was already installed and `-Reinstall` parameter was not specified. +- Restore package if installation attempt fails when reinstalling a package. +- Fix bug with some Modules installing as Scripts. +- Fix bug with separating `$env:PSModulePath` to now work with path separators across all OS systems including Unix. +- Fix bug to register repositories with local file share paths, ensuring repositories with valid URIs can be registered. +- Revert cmdlet name `Get-InstalledPSResource` to `Get-PSResource`. +- Remove DSCResources from PowerShellGet. +- Remove unnecessary assemblies. + +## 3.0.11-beta + +### Changes + +- Graceful handling of paths that do not exist +- The repository store (PSResourceRepository.xml) is auto-generated if it does not already exist. It also automatically registers the PowerShellGallery with a default priority of 50 and a default trusted value of false. +- Better Linux support, including graceful exits when paths do not exist +- Better pipeline input support all cmdlets +- General wildcard support for all cmdlets +- WhatIf support for all cmdlets +- All cmdlets output concrete return types +- Better help documentation for all cmdlets +- Using an exact prerelease version with Find, Install, or Save no longer requires `-Prerelease` tag +- Support for finding, installing, saving, and updating PowerShell resources from Azure Artifact feeds +- `Publish-PSResource` now properly dispays 'Tags' in nuspec +- `Find-PSResource` quickly cancels transactions with 'CTRL + C' +- `Register-PSRepository` now handles relative paths +- `Find-PSResource` and `Save-PSResource` deduplicates dependencies +- `Install-PSResource` no longer creates version folder with the prerelease tag +- `Update-PSResource` can now update all resources, and no longer requires name param +- `Save-PSResource` properly handles saving scripts +- `Get-InstalledPSResource` uses default PowerShell paths + +### Notes + +In this release, all cmdlets have been reviewed and implementation code refactored as needed. +Cmdlets have most of their functionality, but some parameters are not yet implemented and will be added in future releases. +All tests have been reviewed and rewritten as needed. + +## 3.0.0-beta10 + +### Bug Fixes + +- Bug fix for `-ModuleName` (used with `-Version`) in `Find-PSResource` returning incorrect resource type +- Make repositories unique by name +- Add tab completion for `-Name` parameter in `Get-PSResource`, `Set-PSResource`, and `Unregister-PSResource` +- Remove credential argument from `Register-PSResourceRepository` +- Change returned version type from 'NuGet.Version' to 'System.Version' +- Have Install output verbose message on successful installation (error for unsuccessful installation) +- Ensure that not passing credentials does not throw an error if searching through multiple repositories +- Remove attempt to remove loaded assemblies in psm1 + +## 3.0.0-beta9 + +### New Features + +- Add DSCResources + +### Bug Fixes + +- Fix bug related to finding dependencies that do not have a specified version in `Find-PSResource` +- Fix bug related to parsing 'RequiredModules' in .psd1 in `Publish-PSResource` +- Improve error handling for when repository in `Publish-PSResource` does not exist +- Fix for unix paths in `Get-PSResource`, `Install-PSResource`, and `Uninstall-PSResource` +- Add debugging statements for `Get-PSResource` and `Install-PSResource` +- Fix bug related to paths in `Uninstall-PSResource` + +## 3.0.0-beta8 + +### New Features + +- Add `-Type` parameter to `Install-PSResource` +- Add 'sudo' check for admin privileges in Unix in `Install-PSResource` + +### Bug Fixes + +- Fix bug with retrieving installed scripts in `Get-PSResource` +- Fix bug with AllUsers scope in Windows in `Install-PSResource` +- Fix bug with `Uninstall-PSResource` sometimes not fully uninstalling +- Change installed file paths to contain original version number instead of normalized version + +## 3.0.0-beta7 + +### New Features + +- Completed functionality for `Update-PSResource` +- `Input-Object` parameter for `Install-PSResource` + +### Bug Fixes + +- Improved experience when loading module for diffent frameworks +- Bug fix for assembly loading error in `Publish-PSResource` +- Allow for relative paths when registering psrepository +- Improved error handling for `Install-PSResource` and `Update-PSResource` +- Remove prerelease tag from module version directory +- Fix error getting thrown from paths with incorrectly formatted module versions +- Fix module installation paths on Linux and MacOS + +## 3.0.0-beta6 + +### New Feature + +- Implement functionality for `Publish-PSResource` + +## 3.0.0-beta5 + +- Note: 3.0.0-beta5 was skipped due to a packaging error + +## 3.0.0-beta4 + +### New Features + +- Implement `-Repository` '*' in `Find-PSResource` to search through all repositories instead of prioritized repository + +### Bug Fix + +- Fix poor error handling for when repository is not accessible in Find-PSResource + +## 3.0.0-beta3 + +### New Features + +- `-RequiredResource` parameter for `Install-PSResource` +- `-RequiredResourceFile` parameter for `Install-PSResource` +- `-IncludeXML` parameter in `Save-PSResource` + +### Bug Fixes + +- Resolved paths in `Install-PSRsource` and `Save-PSResource` +- Resolved issues with capitalization (for unix systems) in `Install-PSResource` and `Save-PSResource` + +## 3.0.0-beta2 + +### New Features + +- Progress bar and `-Quiet` parameter for `Install-PSResource` +- `-TrustRepository` parameter for `Install-PSResource` +- `-NoClobber` parameter for `Install-PSResource` +- `-AcceptLicense` for `Install-PSResource` +- `-Force` parameter for `Install-PSResource` +- `-Reinstall` parameter for `Install-PSResource` +- Improved error handling + +## 3.0.0-beta1 + +### BREAKING CHANGE +- Preview version of PowerShellGet. Many features are not fully implemented yet. Please see https://devblogs.microsoft.com/powershell/powershellget-3-0-preview1 for more details. diff --git a/CHANGELOG/preview.md b/CHANGELOG/preview.md new file mode 100644 index 000000000..978dd5dfa --- /dev/null +++ b/CHANGELOG/preview.md @@ -0,0 +1 @@ +This file will contain changelog updates for preview releases for Microsoft.PowerShell.PSResourceGet \ No newline at end of file From 58d59400113be74dbb5d4e4d950e916b188728b7 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Thu, 8 Feb 2024 12:26:21 -0800 Subject: [PATCH 044/160] Lowercase ACR package name when pushing to server (#1552) --- src/code/ACRServerAPICalls.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index a46cf4545..8528b5d41 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -1106,8 +1106,8 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p { errRecord = null; // Push the nupkg to the appropriate repository - var fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, pkgName + "." + pkgVersion.ToNormalizedString() + ".nupkg"); - + string fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, pkgName + "." + pkgVersion.ToNormalizedString() + ".nupkg"); + string pkgNameLower = pkgName.ToLower(); string accessToken = string.Empty; string tenantID = string.Empty; @@ -1136,7 +1136,8 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p /* Uploading .nupkg */ _cmdletPassedIn.WriteVerbose("Start uploading blob"); - var moduleLocation = GetStartUploadBlobLocation(registry, pkgName, acrAccessToken).Result; + // Note: ACR registries will only accept a name that is all lowercase. + var moduleLocation = GetStartUploadBlobLocation(registry, pkgNameLower, acrAccessToken).Result; _cmdletPassedIn.WriteVerbose("Computing digest for .nupkg file"); bool nupkgDigestCreated = CreateDigest(fullNupkgFile, out string nupkgDigest, out ErrorRecord nupkgDigestError); @@ -1159,7 +1160,7 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p } using (FileStream configStream = new FileStream(emptyFilePath, FileMode.Create)){ } _cmdletPassedIn.WriteVerbose("Start uploading an empty file"); - var emptyLocation = GetStartUploadBlobLocation(registry, pkgName, acrAccessToken).Result; + var emptyLocation = GetStartUploadBlobLocation(registry, pkgNameLower, acrAccessToken).Result; _cmdletPassedIn.WriteVerbose("Computing digest for empty file"); bool emptyDigestCreated = CreateDigest(emptyFilePath, out string emptyDigest, out ErrorRecord emptyDigestError); if (!emptyDigestCreated) @@ -1200,7 +1201,7 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p File.WriteAllText(configFilePath, fileContent); _cmdletPassedIn.WriteVerbose("Create the manifest layer"); - bool manifestCreated = CreateManifest(registry, pkgName, pkgVersion.OriginalVersion, configFilePath, true, acrAccessToken).Result; + bool manifestCreated = CreateManifest(registry, pkgNameLower, pkgVersion.OriginalVersion, configFilePath, true, acrAccessToken).Result; if (manifestCreated) { From f812154b2f9468a98efe104f2ea6108719fc67d5 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Tue, 13 Feb 2024 11:43:15 -0800 Subject: [PATCH 045/160] Enable testing against ACR (#1550) --- .ci/test.yml | 27 ++++++++++++++++++- .../FindPSResourceACRServer.Tests.ps1 | 12 +++------ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.ci/test.yml b/.ci/test.yml index 541d72542..13be97783 100644 --- a/.ci/test.yml +++ b/.ci/test.yml @@ -11,6 +11,14 @@ jobs: vmImage: ${{ parameters.imageName }} displayName: ${{ parameters.displayName }} steps: + - ${{ parameters.powershellExecutable }}: | + Install-Module -Name 'Microsoft.PowerShell.SecretManagement' -force -SkipPublisherCheck -AllowClobber + Install-Module -Name 'Microsoft.PowerShell.SecretStore' -force -SkipPublisherCheck -AllowClobber + $vaultPassword = ConvertTo-SecureString $("a!!"+ (Get-Random -Maximum ([int]::MaxValue))) -AsPlainText -Force + Set-SecretStoreConfiguration -Authentication None -Interaction None -Confirm:$false -Password $vaultPassword + Register-SecretVault -Name SecretStore -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault + displayName: Install Secret store + - task: DownloadBuildArtifacts@0 displayName: 'Download artifacts' inputs: @@ -53,6 +61,24 @@ jobs: displayName: Install module for test from downloaded artifact workingDirectory: ${{ parameters.buildDirectory }} + - task: AzurePowerShell@5 + inputs: + azureSubscription: PSResourceGetACR + azurePowerShellVersion: LatestVersion + ScriptType: InlineScript + pwsh: true + inline: | + Write-Verbose -Verbose "Getting Azure Container Registry" + Get-AzContainerRegistry -ResourceGroupName 'PSResourceGet' -Name 'psresourcegettest' | Select-Object -Property * + Write-Verbose -Verbose "Setting up secret for Azure Container Registry" + $azt = Get-AzAccessToken + $tenantId = $azt.TenantID + Set-Secret -Name $tenantId -Secret $azt.Token -Verbose + $vstsCommandString = "vso[task.setvariable variable=TenantId]$tenantId" + Write-Host "sending " + $vstsCommandString + Write-Host "##$vstsCommandString" + displayName: 'Setup Azure Container Registry secret' + - ${{ parameters.powershellExecutable }}: | $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath @@ -68,4 +94,3 @@ jobs: workingDirectory: ${{ parameters.buildDirectory }} errorActionPreference: continue condition: succeededOrFailed() - diff --git a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 index ed7e4bfc8..624b56c1e 100644 --- a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 @@ -1,20 +1,18 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -<# -# These tests are working with manual validation but there is currently no automated testing for ACR repositories. - $modPath = "$psscriptroot/../PSGetTestUtils.psm1" Import-Module $modPath -Force -Verbose Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { BeforeAll{ - $testModuleName = "hello-world" + $testModuleName = "test_local_mod" $ACRRepoName = "ACRRepo" - $ACRRepoUri = "https://psgetregistry.azurecr.io" + $ACRRepoUri = "https://psresourcegettest.azurecr.io" Get-NewPSResourceRepositoryFile - Register-PSResourceRepository -Name $ACRRepoName -Uri $ACRepoUri -ApiVersion "ACR" + $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose } AfterAll { @@ -138,5 +136,3 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $err[0].FullyQualifiedErrorId | Should -BeExactly "FindAllFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } } - -#> \ No newline at end of file From 23f908e0cb3e3e537d8fcb424377371003a80277 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Feb 2024 16:03:25 -0800 Subject: [PATCH 046/160] Bump NuGet.Packaging from 6.8.0 to 6.8.1 in /src/code (#1566) --- src/code/Microsoft.PowerShell.PSResourceGet.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index ca3a7d8e2..49407581b 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -15,9 +15,9 @@ - - - + + + From 7ae56573e25e7c5e5dfadd9581fe5c711fafe03d Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 13 Feb 2024 19:56:06 -0800 Subject: [PATCH 047/160] ACR bug fix: Update 'packageName' metadata check to 'org.opencontainers.image.title' (#1567) --- src/code/ACRServerAPICalls.cs | 12 ++++++------ .../Microsoft.PowerShell.PSResourceGet.csproj | 12 ++++++------ .../FindPSResourceACRServer.Tests.ps1 | 18 ++++++++++-------- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 8528b5d41..638bc1c0c 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -770,18 +770,18 @@ internal Tuple GetMetadataProperty(JObject foundTags, string pack var metadata = annotations["metadata"].ToString(); - var metadataPkgNameJToken = annotations["packageName"]; - if (metadataPkgNameJToken == null) + var metadataPkgTitleJToken = annotations["org.opencontainers.image.title"]; + if (metadataPkgTitleJToken == null) { - exception = new InvalidOrEmptyResponse($"Response does not contain 'packageName' element for package '{packageName}' in '{Repository.Name}'."); + exception = new InvalidOrEmptyResponse($"Response does not contain 'org.opencontainers.image.title' element for package '{packageName}' in '{Repository.Name}'."); return emptyTuple; } - string metadataPkgName = metadataPkgNameJToken.ToString(); + string metadataPkgName = metadataPkgTitleJToken.ToString(); if (string.IsNullOrWhiteSpace(metadataPkgName)) { - exception = new InvalidOrEmptyResponse($"Response element 'packageName' is empty for package '{packageName}' in '{Repository.Name}'."); + exception = new InvalidOrEmptyResponse($"Response element 'org.opencontainers.image.title' is empty for package '{packageName}' in '{Repository.Name}'."); return emptyTuple; } @@ -1244,7 +1244,7 @@ private string CreateJsonContent( jsonWriter.WriteStartObject(); jsonWriter.WritePropertyName("mediaType"); - jsonWriter.WriteValue("application/vnd.oci.image.layer.nondistributable.v1.tar+gzip'"); + jsonWriter.WriteValue("application/vnd.oci.image.layer.nondistributable.v1.tar+gzip"); jsonWriter.WritePropertyName("digest"); jsonWriter.WriteValue($"sha256:{nupkgDigest}"); jsonWriter.WritePropertyName("size"); diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index 49407581b..8c7caa15b 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -14,12 +14,12 @@ - - - - - - + + + + + + diff --git a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 index 624b56c1e..e7a19a58c 100644 --- a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 @@ -26,19 +26,12 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Version | Should -Be "5.0.0" } - It "Find resource given specific Name, Version null" { - # FindName() - $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName -Prerelease - $res.Name | Should -Be $testModuleName - $res.Version | Should -Be "5.0.0-alpha001" - } - It "Should not find resource given nonexistant Name" { # FindName() $res = Find-PSResource -Name NonExistantModule -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty $err.Count | Should -BeGreaterThan 0 - $err[0].FullyQualifiedErrorId | Should -BeExactly "ACRPackageNotFoundFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" $res | Should -BeNullOrEmpty } @@ -75,6 +68,14 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Count | Should -BeGreaterOrEqual 1 } + <# TODO: prerelease handling not yet implemented in ACR Server Protocol + It "Find resource given specific Name, Version null but allowing Prerelease" { + # FindName() + $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName -Prerelease + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0-alpha001" + } + It "Find resource with latest (including prerelease) version given Prerelease parameter" { # FindName() # test_local_mod resource's latest version is a prerelease version, before that it has a non-prerelease version @@ -92,6 +93,7 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $resWithPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $ACRRepoName -Prerelease $resWithPrerelease.Count | Should -BeGreaterOrEqual $resWithoutPrerelease.Count } + #> It "Should not find resource if Name, Version and Tag property are not all satisfied (single tag)" { # FindVersionWithTag() From 0ab2d3528a29fe92e36547c75218fff77a0c7eef Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 14 Feb 2024 11:02:21 -0800 Subject: [PATCH 048/160] Do not call .NET methods to enable use on CLM (#1564) --- src/code/ServerFactory.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/code/ServerFactory.cs b/src/code/ServerFactory.cs index b3e85187d..ec835a4f6 100644 --- a/src/code/ServerFactory.cs +++ b/src/code/ServerFactory.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using System.Collections; using System.Management.Automation; using System.Management.Automation.Runspaces; using System.Net; @@ -14,7 +15,7 @@ static UserAgentInfo() { using (System.Management.Automation.PowerShell ps = System.Management.Automation.PowerShell.Create(RunspaceMode.CurrentRunspace)) { - _psVersion = ps.AddScript("$PSVersionTable.PSVersion.ToString()").Invoke()[0]; + _psVersion = ps.AddScript("$PSVersionTable").Invoke()[0]["PSVersion"].ToString(); } _psResourceGetVersion = typeof(UserAgentInfo).Assembly.GetName().Version.ToString(); From 136fb95a3f6c6cf49ec9aa8890a91c6d148383c7 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Wed, 14 Feb 2024 11:21:09 -0800 Subject: [PATCH 049/160] Add ACR Install tests (#1560) --- .../InstallPSResourceACRServer.Tests.ps1 | 265 ++++++++++++++++++ .../InstallPSResourceGithubPackages.Tests.ps1 | 10 +- 2 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 diff --git a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 new file mode 100644 index 000000000..9809d4388 --- /dev/null +++ b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 @@ -0,0 +1,265 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$ProgressPreference = "SilentlyContinue" +$modPath = "$psscriptroot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { + + BeforeAll { + $testModuleName = "test_local_mod" + $testModuleName2 = "test_local_mod2" + $testScriptName = "test_ado_script" + $ACRRepoName = "ACRRepo" + $ACRRepoUri = "https://psresourcegettest.azurecr.io/" + Get-NewPSResourceRepositoryFile + $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + } + + AfterEach { + Uninstall-PSResource $testModuleName, $testModuleName2, $testScriptName -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue + } + + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + $testCases = @{Name="*"; ErrorId="NameContainsWildcard"}, + @{Name="Test_local_m*"; ErrorId="NameContainsWildcard"}, + @{Name="Test?local","Test[local"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} + + It "Should not install resource with wildcard in name" -TestCases $testCases { + param($Name, $ErrorId) + Install-PSResource -Name $Name -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "$ErrorId,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + $res = Get-InstalledPSResource $testModuleName + $res | Should -BeNullOrEmpty + } + + It "Install specific module resource by name" { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + <# TODO: enable after implementing script functionality + It "Install specific script resource by name" { + Install-PSResource -Name $testScriptName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testScriptName + $pkg.Name | Should -Be $testScriptName + $pkg.Version | Should -Be "1.0.0" + } + #> + + It "Install multiple resources by name" { + $pkgNames = @($testModuleName, $testModuleName2) + Install-PSResource -Name $pkgNames -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $pkgNames + $pkg.Name | Should -Be $pkgNames + } + + It "Should not install resource given nonexistant name" { + Install-PSResource -Name "NonExistantModule" -Repository $ACRRepoName -TrustRepository -ErrorVariable err -ErrorAction SilentlyContinue + $pkg = Get-InstalledPSResource "NonExistantModule" + $pkg | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + } + + # Do some version testing, but Find-PSResource should be doing thorough testing + It "Should install resource given name and exact version" { + Install-PSResource -Name $testModuleName -Version "1.0.0" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "1.0.0" + } + + It "Should install resource given name and exact version with bracket syntax" { + Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "1.0.0" + } + + It "Should install resource given name and exact range inclusive [1.0.0, 5.0.0]" { + Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Should install resource given name and exact range exclusive (1.0.0, 5.0.0)" { + Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "3.0.0" + } + + # TODO: Update this test and others like it that use try/catch blocks instead of Should -Throw + It "Should not install resource with incorrectly formatted version such as exclusive version (1.0.0.0)" { + $Version = "(1.0.0.0)" + { Install-PSResource -Name $testModuleName -Version $Version -Repository $ACRRepoName -TrustRepository -ErrorAction SilentlyContinue } | Should -Throw -ErrorId "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + + $res = Get-InstalledPSResource $testModuleName + $res | Should -BeNullOrEmpty + } + + It "Install resource when given Name, Version '*', should install the latest version" { + Install-PSResource -Name $testModuleName -Version "*" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + <# TODO: enable when prerelease functionality is implemented + It "Install resource with latest (including prerelease) version given Prerelease parameter" { + Install-PSResource -Name $testModuleName -Prerelease -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.2.5" + $pkg.Prerelease | Should -Be "alpha001" + } + #> + + It "Install resource via InputObject by piping from Find-PSresource" { + Find-PSResource -Name $testModuleName -Repository $ACRRepoName | Install-PSResource -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Install resource with copyright, description and repository source location and validate properties" { + $testModule = "test_module" + Install-PSResource -Name $testModule -Version "7.0.0" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModule + $pkg.Name | Should -Be $testModule + $pkg.Version | Should -Be "7.0.0" + $pkg.Copyright | Should -Be "(c) Anam Navied. All rights reserved." + $pkg.Description | Should -Be "This is a test module, for PSGallery team internal testing. Do not take a dependency on this package. This version contains tags for the package." + $pkg.RepositorySourceLocation | Should -Be $ACRRepoUri + } + + # Windows only + It "Install resource under CurrentUser scope - Windows only" -Skip:(!(Get-IsWindows)) { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository -Scope CurrentUser + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("Documents") | Should -Be $true + } + + # Windows only + It "Install resource under AllUsers scope - Windows only" -Skip:(!((Get-IsWindows) -and (Test-IsAdmin))) { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository -Scope AllUsers -Verbose + $pkg = Get-InstalledPSResource $testModuleName -Scope AllUsers + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("Program Files") | Should -Be $true + } + + # Windows only + It "Install resource under no specified scope - Windows only" -Skip:(!(Get-IsWindows)) { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("Documents") | Should -Be $true + } + + # Unix only + # Expected path should be similar to: '/home/janelane/.local/share/powershell/Modules' + It "Install resource under CurrentUser scope - Unix only" -Skip:(Get-IsWindows) { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository -Scope CurrentUser + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("$env:HOME/.local") | Should -Be $true + } + + # Unix only + # Expected path should be similar to: '/home/janelane/.local/share/powershell/Modules' + It "Install resource under no specified scope - Unix only" -Skip:(Get-IsWindows) { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("$env:HOME/.local") | Should -Be $true + } + + It "Should not install resource that is already installed" { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository -WarningVariable WarningVar -warningaction SilentlyContinue + $WarningVar | Should -Not -BeNullOrEmpty + } + + It "Reinstall resource that is already installed with -Reinstall parameter" { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -Reinstall -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Install PSResourceInfo object piped in" { + Find-PSResource -Name $testModuleName -Version "1.0.0.0" -Repository $ACRRepoName | Install-PSResource -TrustRepository + $res = Get-InstalledPSResource -Name $testModuleName + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "1.0.0" + } + + It "Install module using -PassThru" { + $res = Install-PSResource -Name $testModuleName -Repository $ACRRepoName -PassThru -TrustRepository + $res.Name | Should -Contain $testModuleName + } +} + +Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidationOnly' { + + BeforeAll { + $testModuleName = "TestModule" + $testModuleName2 = "testModuleWithlicense" + Get-NewPSResourceRepositoryFile + Register-LocalRepos + } + + AfterEach { + Uninstall-PSResource $testModuleName, $testModuleName2 -SkipDependencyCheck -ErrorAction SilentlyContinue + } + + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + # Unix only manual test + # Expected path should be similar to: '/usr/local/share/powershell/Modules' + It "Install resource under AllUsers scope - Unix only" -Skip:(Get-IsWindows) { + Install-PSResource -Name $testModuleName -Repository $TestGalleryName -Scope AllUsers + $pkg = Get-Module $testModuleName -ListAvailable + $pkg.Name | Should -Be $testModuleName + $pkg.Path.Contains("/usr/") | Should -Be $true + } + + # This needs to be manually tested due to prompt + It "Install resource that requires accept license without -AcceptLicense flag" { + Install-PSResource -Name $testModuleName2 -Repository $TestGalleryName + $pkg = Get-InstalledPSResource $testModuleName2 + $pkg.Name | Should -Be $testModuleName2 + $pkg.Version | Should -Be "0.0.1.0" + } + + # This needs to be manually tested due to prompt + It "Install resource should prompt 'trust repository' if repository is not trusted" { + Set-PSResourceRepository PoshTestGallery -Trusted:$false + + Install-PSResource -Name $testModuleName -Repository $TestGalleryName -confirm:$false + + $pkg = Get-Module $testModuleName -ListAvailable + $pkg.Name | Should -Be $testModuleName + + Set-PSResourceRepository PoshTestGallery -Trusted + } +} diff --git a/test/InstallPSResourceTests/InstallPSResourceGithubPackages.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceGithubPackages.Tests.ps1 index c8ed9cd96..7c4e68d6a 100644 --- a/test/InstallPSResourceTests/InstallPSResourceGithubPackages.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceGithubPackages.Tests.ps1 @@ -5,7 +5,7 @@ $ProgressPreference = "SilentlyContinue" $modPath = "$psscriptroot/../PSGetTestUtils.psm1" Import-Module $modPath -Force -Verbose -Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { +Describe 'Test Install-PSResource for GitHub packages' -tags 'CI' { BeforeAll { $testModuleName = "test_module" @@ -18,6 +18,8 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { $secureString = ConvertTo-SecureString $env:MAPPED_GITHUB_PAT -AsPlainText -Force $credential = New-Object pscredential ($env:GITHUB_USERNAME, $secureString) + + Uninstall-PSResource $testModuleName, $testScriptName -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue } AfterEach { @@ -28,9 +30,9 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { Get-RevertPSResourceRepositoryFile } - $testCases = @{Name="*"; ErrorId="NameContainsWildcard"}, - @{Name="Test_local_m*"; ErrorId="NameContainsWildcard"}, - @{Name="Test?local","Test[local"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} + $testCases = @{Name="*"; ErrorId="NameContainsWildcard"}, + @{Name="Test_m*"; ErrorId="NameContainsWildcard"}, + @{Name="Test?module","Test[module"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} It "Should not install resource with wildcard in name" -TestCases $testCases { param($Name, $ErrorId) From 75fe62438cf4b9f8e22d9382dda7d2c1e26ff958 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Tue, 20 Feb 2024 20:08:31 -0800 Subject: [PATCH 050/160] Ensure that exception is not swallowed by finally block (#1569) --- src/code/InstallHelper.cs | 28 +++++++++---- src/code/PublishPSResource.cs | 22 ++++++---- src/code/UpdateModuleManifest.cs | 34 ++++++++++++---- src/code/Utils.cs | 70 +++++++++++++++++--------------- 4 files changed, 96 insertions(+), 58 deletions(-) diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index d7c3e9cd4..a2135ab76 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -95,7 +95,7 @@ public IEnumerable BeginInstallPackages( { _cmdletPassedIn.WriteDebug("In InstallHelper::BeginInstallPackages()"); _cmdletPassedIn.WriteDebug(string.Format("Parameters passed in >>> Name: '{0}'; VersionRange: '{1}'; NuGetVersion: '{2}'; VersionType: '{3}'; Version: '{4}'; Prerelease: '{5}'; Repository: '{6}'; " + - "AcceptLicense: '{7}'; Quiet: '{8}'; Reinstall: '{9}'; TrustRepository: '{10}'; NoClobber: '{11}'; AsNupkg: '{12}'; IncludeXml '{13}'; SavePackage '{14}'; TemporaryPath '{15}'; SkipDependencyCheck: '{16}'; " + + "AcceptLicense: '{7}'; Quiet: '{8}'; Reinstall: '{9}'; TrustRepository: '{10}'; NoClobber: '{11}'; AsNupkg: '{12}'; IncludeXml '{13}'; SavePackage '{14}'; TemporaryPath '{15}'; SkipDependencyCheck: '{16}'; " + "AuthenticodeCheck: '{17}'; PathsToInstallPkg: '{18}'; Scope '{19}'", string.Join(",", names), versionRange != null ? (versionRange.OriginalString != null ? versionRange.OriginalString : string.Empty) : string.Empty, @@ -265,7 +265,7 @@ private List ProcessRepositories( List repositoryNamesToSearch = new List(); bool sourceTrusted = false; - // Loop through all the repositories provided (in priority order) until there no more packages to install. + // Loop through all the repositories provided (in priority order) until there no more packages to install. for (int i = 0; i < listOfRepositories.Count && _pkgNamesToInstall.Count > 0; i++) { PSRepositoryInfo currentRepository = listOfRepositories[i]; @@ -614,7 +614,7 @@ private List InstallPackages( depFindFailed = true; continue; } - + if (String.Equals(depPkg.Name, parentPkgObj.Name, StringComparison.OrdinalIgnoreCase)) { continue; @@ -683,6 +683,16 @@ private List InstallPackages( } } } + catch (Exception e) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + e, + "InstallPackageFailure", + ErrorCategory.InvalidOperation, + _cmdletPassedIn)); + + throw e; + } finally { DeleteInstallationTempPath(tempInstallPath); @@ -754,14 +764,14 @@ private Hashtable BeginPackageInstall( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { errRecord = new ErrorRecord( - currentResult.exception, - "FindConvertToPSResourceFailure", - ErrorCategory.ObjectNotFound, + currentResult.exception, + "FindConvertToPSResourceFailure", + ErrorCategory.ObjectNotFound, _cmdletPassedIn); } else if (searchVersionType == VersionType.VersionRange) { - // Check to see if version falls within version range + // Check to see if version falls within version range PSResourceInfo foundPkg = currentResult.returnedObject; string versionStr = $"{foundPkg.Version}"; if (foundPkg.IsPrerelease) @@ -800,7 +810,7 @@ private Hashtable BeginPackageInstall( if (_packagesOnMachine.Contains(currPkgNameVersion)) { _cmdletPassedIn.WriteWarning($"Resource '{pkgToInstall.Name}' with version '{pkgVersion}' is already installed. If you would like to reinstall, please run the cmdlet again with the -Reinstall parameter"); - + // Remove from tracking list of packages to install. _pkgNamesToInstall.RemoveAll(x => x.Equals(pkgToInstall.Name, StringComparison.InvariantCultureIgnoreCase)); @@ -1166,7 +1176,7 @@ private bool TrySaveNupkgToTempPath( /// /// Extracts files from .nupkg - /// Similar functionality as System.IO.Compression.ZipFile.ExtractToDirectory, + /// Similar functionality as System.IO.Compression.ZipFile.ExtractToDirectory, /// but while ExtractToDirectory cannot overwrite files, this method can. /// private bool TryExtractToDirectory(string zipPath, string extractPath, out ErrorRecord error) diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index afc012653..990e66cb8 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -23,8 +23,8 @@ namespace Microsoft.PowerShell.PSResourceGet.Cmdlets /// /// Publishes a module, script, or nupkg to a designated repository. /// - [Cmdlet(VerbsData.Publish, - "PSResource", + [Cmdlet(VerbsData.Publish, + "PSResource", SupportsShouldProcess = true)] [Alias("pbres")] public sealed class PublishPSResource : PSCmdlet @@ -505,6 +505,14 @@ out string[] _ } } } + catch (Exception e) + { + ThrowTerminatingError(new ErrorRecord( + e, + "PublishPSResourceError", + ErrorCategory.NotSpecified, + this)); + } finally { WriteVerbose(string.Format("Deleting temporary directory '{0}'", outputDir)); @@ -806,7 +814,7 @@ private Hashtable ParseRequiredModules(Hashtable parsedMetadataHash) if (!parsedMetadataHash.ContainsKey("requiredmodules")) { return null; - } + } LanguagePrimitives.TryConvertTo(parsedMetadataHash["requiredmodules"], out object[] requiredModules); // Required modules can be: @@ -839,7 +847,7 @@ private Hashtable ParseRequiredModules(Hashtable parsedMetadataHash) dependenciesHash.Add(moduleName, string.Empty); } } - var externalModuleDeps = parsedMetadataHash.ContainsKey("ExternalModuleDependencies") ? + var externalModuleDeps = parsedMetadataHash.ContainsKey("ExternalModuleDependencies") ? parsedMetadataHash["ExternalModuleDependencies"] : null; if (externalModuleDeps != null && LanguagePrimitives.TryConvertTo(externalModuleDeps, out string[] externalModuleNames)) @@ -1130,7 +1138,7 @@ private bool PushNupkg(string outputNupkgDir, string repoName, string repoUri, o WriteVerbose(string.Format("Successfully published the resource to '{0}'", repoUri)); error = null; success = true; - + return success; } @@ -1154,7 +1162,7 @@ private void InjectCredentialsToSettings(ISettings settings, IPackageSourceProvi var networkCred = Credential == null ? _networkCredential : Credential.GetNetworkCredential(); string key; - + if (packageSource == null) { @@ -1177,7 +1185,7 @@ private void InjectCredentialsToSettings(ISettings settings, IPackageSourceProvi isPasswordClearText: true, String.Empty)); } - + #endregion } } diff --git a/src/code/UpdateModuleManifest.cs b/src/code/UpdateModuleManifest.cs index 7778875b9..8ba86d87a 100644 --- a/src/code/UpdateModuleManifest.cs +++ b/src/code/UpdateModuleManifest.cs @@ -668,6 +668,14 @@ private void CreateModuleManifestHelper(Hashtable parsedMetadata, string resolve WriteVerbose($"Moving '{tmpModuleManifestPath}' to '{resolvedManifestPath}'"); Utils.MoveFiles(tmpModuleManifestPath, resolvedManifestPath, overwrite: true); } + catch (Exception e) + { + errorRecord = new ErrorRecord( + e, + "CreateModuleManifestFailed", + ErrorCategory.InvalidOperation, + this); + } finally { // Clean up temp file if move fails if (File.Exists(tmpModuleManifestPath)) @@ -970,7 +978,7 @@ private void CreateModuleManifestForWinPSHelper(Hashtable parsedMetadata, string "ErrorCreatingTempDir", ErrorCategory.InvalidData, this); - + return; } @@ -1007,7 +1015,7 @@ private void CreateModuleManifestForWinPSHelper(Hashtable parsedMetadata, string string privateDataString = GetPrivateDataString(tags, licenseUri, projectUri, iconUri, releaseNotes, prerelease, requireLicenseAcceptance, externalModuleDependencies); - // create new file in tmp path for updated module manifest (i.e updated with PrivateData entry) + // create new file in tmp path for updated module manifest (i.e updated with PrivateData entry) string newTmpModuleManifestPath = System.IO.Path.Combine(tmpParentPath, "Updated" + System.IO.Path.GetFileName(resolvedManifestPath)); if (!TryCreateNewPsd1WithUpdatedPrivateData(privateDataString, tmpModuleManifestPath, newTmpModuleManifestPath, out errorRecord)) { @@ -1020,6 +1028,14 @@ private void CreateModuleManifestForWinPSHelper(Hashtable parsedMetadata, string WriteVerbose($"Moving '{newTmpModuleManifestPath}' to '{resolvedManifestPath}'"); Utils.MoveFiles(newTmpModuleManifestPath, resolvedManifestPath, overwrite: true); } + catch (Exception e) + { + errorRecord = new ErrorRecord( + e, + "CreateModuleManifestForWinPSFailed", + ErrorCategory.InvalidOperation, + this); + } finally { // Clean up temp file if move fails if (File.Exists(tmpModuleManifestPath)) @@ -1043,7 +1059,7 @@ private string GetPrivateDataString(string[] tags, Uri licenseUri, Uri projectUr { /** Example PrivateData - + PrivateData = @{ PSData = @{ # Tags applied to this module. These help with module discovery in online galleries. @@ -1069,15 +1085,15 @@ Example PrivateData # External dependent modules of this module ExternalModuleDependencies = @('ModuleDep1, 'ModuleDep2') - + } # End of PSData hashtable - + } # End of PrivateData hashtable */ string tagsString = string.Join(", ", tags.Select(item => "'" + item + "'")); string tagLine = tags.Length != 0 ? $"Tags = @({tagsString})" : "# Tags = @()"; - + string licenseUriLine = licenseUri == null ? "# LicenseUri = ''" : $"LicenseUri = '{licenseUri.ToString()}'"; string projectUriLine = projectUri == null ? "# ProjectUri = ''" : $"ProjectUri = '{projectUri.ToString()}'"; string iconUriLine = iconUri == null ? "# IconUri = ''" : $"IconUri = '{iconUri.ToString()}'"; @@ -1089,7 +1105,7 @@ Example PrivateData string externalModuleDependenciesString = string.Join(", ", externalModuleDependencies.Select(item => "'" + item + "'")); string externalModuleDependenciesLine = externalModuleDependencies.Length == 0 ? "# ExternalModuleDependencies = @()" : $"ExternalModuleDependencies = @({externalModuleDependenciesString})"; - + string initialPrivateDataString = "PrivateData = @{" + System.Environment.NewLine + "PSData = @{" + System.Environment.NewLine; string privateDataString = $@" @@ -1157,7 +1173,7 @@ private bool TryCreateNewPsd1WithUpdatedPrivateData(string privateDataString, st { leftBracket--; } - + if (leftBracket == 0) { privateDataEndLine = i; @@ -1173,7 +1189,7 @@ private bool TryCreateNewPsd1WithUpdatedPrivateData(string privateDataString, st "PrivateDataEntryParsingError", ErrorCategory.InvalidOperation, this); - + return false; } diff --git a/src/code/Utils.cs b/src/code/Utils.cs index f09110423..d94aa1541 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -129,7 +129,7 @@ public static string[] GetStringArrayFromString(string[] delimeter, string strin return stringToConvertToArray.Split(delimeter, StringSplitOptions.RemoveEmptyEntries); } - + /// /// Converts an ArrayList of object types to a string array. /// @@ -169,7 +169,7 @@ public static string[] ProcessNameWildcards( { if (removeWildcardEntries) { - // Tag // CommandName // DSCResourceName + // Tag // CommandName // DSCResourceName errorMsgsList.Add($"{name} will be discarded from the provided entries."); continue; } @@ -260,17 +260,17 @@ public static bool TryGetVersionType( string[] versionSplit = version.Split(new string[] { "." }, StringSplitOptions.None); if (versionSplit.Length == 2 && versionSplit[1].Equals("*")) { - // eg: 2.* should translate to the version range "[2.0,2.99999]" + // eg: 2.* should translate to the version range "[2.0,2.99999]" modifiedVersion = $"[{versionSplit[0]}.0,{versionSplit[0]}.999999]"; } else if (versionSplit.Length == 3 && versionSplit[2].Equals("*")) { - // eg: 2.1.* should translate to the version range "[2.1.0,2.1.99999]" + // eg: 2.1.* should translate to the version range "[2.1.0,2.1.99999]" modifiedVersion = $"[{versionSplit[0]}.{versionSplit[1]}.0,{versionSplit[0]}.{versionSplit[1]}.999999]"; } else if (versionSplit.Length == 4 && versionSplit[3].Equals("*")) { - // eg: 2.8.8.* should translate to the version range "[2.1.3.0,2.1.3.99999]" + // eg: 2.8.8.* should translate to the version range "[2.1.3.0,2.1.3.99999]" modifiedVersion = $"[{versionSplit[0]}.{versionSplit[1]}.{versionSplit[2]}.0,{versionSplit[0]}.{versionSplit[1]}.{versionSplit[2]}.999999]"; } else { @@ -430,7 +430,7 @@ public static bool TryCreateValidUri( { // This is needed for a relative path Uri string. Does not throw error for an absolute path. var filePath = cmdletPassedIn.GetResolvedProviderPathFromPSPath(uriString, out ProviderInfo provider).First(); - + if (Uri.TryCreate(filePath, UriKind.Absolute, out uriResult)) { return true; @@ -633,7 +633,7 @@ public static PSCredential GetRepositoryCredentialFromSecretManagement( "RepositoryCredentialCannotGetSecretFromVault", ErrorCategory.InvalidOperation, cmdletPassedIn)); - + return null; } } @@ -920,7 +920,7 @@ public static NetworkCredential SetNetworkCredential( return networkCredential; } - + #endregion #region Path methods @@ -967,7 +967,7 @@ public static string GetInstalledPackageName(string pkgPath) return new DirectoryInfo(pkgPath).Parent.Name; } - // Find all potential resource paths + // Find all potential resource paths public static List GetPathsFromEnvVarAndScope( PSCmdlet psCmdlet, ScopeType? scope) @@ -998,7 +998,7 @@ public static List GetAllResourcePaths( ScopeType? scope = null) { List resourcePaths = GetPathsFromEnvVarAndScope(psCmdlet, scope); - + // resourcePaths should now contain, eg: // ./PowerShell/Scripts // ./PowerShell/Modules @@ -1048,7 +1048,7 @@ public static List GetAllInstallationPaths( ScopeType? scope) { List installationPaths = GetPathsFromEnvVarAndScope(psCmdlet, scope); - + installationPaths = installationPaths.Distinct(StringComparer.InvariantCultureIgnoreCase).ToList(); installationPaths.ForEach(dir => psCmdlet.WriteVerbose(string.Format("All paths to search: '{0}'", dir))); @@ -1216,7 +1216,7 @@ private static bool TryReadPSDataFile( allowedCommands: allowedCommands, allowedVariables: allowedVariables, allowEnvironmentVariables: allowEnvironmentVariables); - + // Convert contents into PSDataFile Hashtable by executing content as script. object result = scriptBlock.InvokeReturnAsIs(); if (result is PSObject psObject) @@ -1352,9 +1352,9 @@ public static bool TryCreateModuleSpecification( if (!moduleSpec.ContainsKey("ModuleName") || String.IsNullOrEmpty((string) moduleSpec["ModuleName"])) { errorList.Add(new ErrorRecord( - new ArgumentException($"RequiredModules Hashtable entry {moduleSpec.ToString()} is missing a key 'ModuleName' and associated value, which is required for each module specification entry"), - "NameMissingInModuleSpecification", - ErrorCategory.InvalidArgument, + new ArgumentException($"RequiredModules Hashtable entry {moduleSpec.ToString()} is missing a key 'ModuleName' and associated value, which is required for each module specification entry"), + "NameMissingInModuleSpecification", + ErrorCategory.InvalidArgument, null)); moduleSpecCreatedSuccessfully = false; continue; @@ -1376,9 +1376,9 @@ public static bool TryCreateModuleSpecification( else { errorList.Add(new ErrorRecord( - new ArgumentException($"ModuleSpecification object was not able to be created for {moduleSpecName}"), - "ModuleSpecificationNotCreated", - ErrorCategory.InvalidArgument, + new ArgumentException($"ModuleSpecification object was not able to be created for {moduleSpecName}"), + "ModuleSpecificationNotCreated", + ErrorCategory.InvalidArgument, null)); moduleSpecCreatedSuccessfully = false; continue; @@ -1395,9 +1395,9 @@ public static bool TryCreateModuleSpecification( if (String.IsNullOrEmpty(moduleSpecMaxVersion) && String.IsNullOrEmpty(moduleSpecModuleVersion) && String.IsNullOrEmpty(moduleSpecRequiredVersion)) { errorList.Add(new ErrorRecord( - new ArgumentException($"ModuleSpecification hashtable requires one of the following keys: MaximumVersion, ModuleVersion, RequiredVersion and failed to be created for {moduleSpecName}"), - "MissingModuleSpecificationMember", - ErrorCategory.InvalidArgument, + new ArgumentException($"ModuleSpecification hashtable requires one of the following keys: MaximumVersion, ModuleVersion, RequiredVersion and failed to be created for {moduleSpecName}"), + "MissingModuleSpecificationMember", + ErrorCategory.InvalidArgument, null)); moduleSpecCreatedSuccessfully = false; continue; @@ -1433,9 +1433,9 @@ public static bool TryCreateModuleSpecification( catch (Exception e) { errorList.Add(new ErrorRecord( - new ArgumentException($"ModuleSpecification instance was not able to be created with hashtable constructor due to: {e.Message}"), - "ModuleSpecificationNotCreated", - ErrorCategory.InvalidArgument, + new ArgumentException($"ModuleSpecification instance was not able to be created with hashtable constructor due to: {e.Message}"), + "ModuleSpecificationNotCreated", + ErrorCategory.InvalidArgument, null)); moduleSpecCreatedSuccessfully = false; } @@ -1492,6 +1492,10 @@ public static void DeleteDirectoryWithRestore(string dirPath) ex); } } + catch (Exception e) + { + throw e; + } finally { if (Directory.Exists(tempDirPath)) @@ -1907,9 +1911,9 @@ internal static bool CheckAuthenticodeSignature( string[] listOfExtensions = { "*.ps1", "*.psd1", "*.psm1", "*.mof", "*.cat", "*.ps1xml" }; authenticodeSignatures = cmdletPassedIn.InvokeCommand.InvokeScript( script: @"param ( - [string] $tempDirNameVersion, + [string] $tempDirNameVersion, [string[]] $listOfExtensions - ) + ) Get-ChildItem $tempDirNameVersion -Recurse -Include $listOfExtensions | Get-AuthenticodeSignature -ErrorAction SilentlyContinue", useNewScope: true, writeToPipeline: System.Management.Automation.Runspaces.PipelineResultTypes.None, @@ -1919,9 +1923,9 @@ internal static bool CheckAuthenticodeSignature( catch (Exception e) { errorRecord = new ErrorRecord( - new ArgumentException(e.Message), - "GetAuthenticodeSignatureError", - ErrorCategory.InvalidResult, + new ArgumentException(e.Message), + "GetAuthenticodeSignatureError", + ErrorCategory.InvalidResult, cmdletPassedIn); return false; @@ -1934,9 +1938,9 @@ internal static bool CheckAuthenticodeSignature( if (!signature.Status.Equals(SignatureStatus.Valid)) { errorRecord = new ErrorRecord( - new ArgumentException($"The signature for '{pkgName}' is '{signature.Status}."), - "GetAuthenticodeSignatureError", - ErrorCategory.InvalidResult, + new ArgumentException($"The signature for '{pkgName}' is '{signature.Status}."), + "GetAuthenticodeSignatureError", + ErrorCategory.InvalidResult, cmdletPassedIn); return false; @@ -1945,7 +1949,7 @@ internal static bool CheckAuthenticodeSignature( return true; } - + #endregion } From f1b3cf7af01eab5ef2e5b3cef7b910a4a4b1e7b4 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Thu, 22 Feb 2024 15:28:12 -0800 Subject: [PATCH 051/160] Add ACR Publish tests (#1571) --- .../PublishPSResourceACRServer.Tests.ps1 | 451 ++++++++++++++++++ 1 file changed, 451 insertions(+) create mode 100644 test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 diff --git a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 new file mode 100644 index 000000000..2b1a3ea64 --- /dev/null +++ b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 @@ -0,0 +1,451 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$modPath = "$psscriptroot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +function CreateTestModule +{ + param ( + [string] $Path = "$TestDrive", + [string] $ModuleName = 'temp-psresourcegettemptestmodule' + ) + + $modulePath = Join-Path -Path $Path -ChildPath $ModuleName + $moduleMan = Join-Path $modulePath -ChildPath ($ModuleName + '.psd1') + $moduleSrc = Join-Path $modulePath -ChildPath ($ModuleName + '.psm1') + + if ( Test-Path -Path $modulePath) { + Remove-Item -Path $modulePath -Recurse -Force + } + + $null = New-Item -Path $modulePath -ItemType Directory -Force + + @' + @{{ + RootModule = "{0}.psm1" + ModuleVersion = '1.0.0' + Author = 'None' + Description = 'None' + GUID = '0c2829fc-b165-4d72-9038-ae3a71a755c1' + FunctionsToExport = @('Test1') + RequiredModules = @('NonExistentModule') + }} +'@ -f $ModuleName | Out-File -FilePath $moduleMan + + @' + function Test1 { + Write-Output 'Hello from Test1' + } +'@ | Out-File -FilePath $moduleSrc +} + +Describe "Test Publish-PSResource" -tags 'CI' { + BeforeAll { + $script:testDir = (get-item $psscriptroot).parent.FullName + Get-NewPSResourceRepositoryFile + + # Register repositories + $ACRRepoName = "ACRRepo" + $ACRRepoUri = "https://psresourcegettest.azurecr.io" + $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + + # Create module + $script:tmpModulesPath = Join-Path -Path $TestDrive -ChildPath "tmpModulesPath" + $script:PublishModuleName = "temp-psresourcegettemptestmodule" + [System.Guid]::NewGuid(); + $script:PublishModuleBase = Join-Path $script:tmpModulesPath -ChildPath $script:PublishModuleName + if(!(Test-Path $script:PublishModuleBase)) + { + New-Item -Path $script:PublishModuleBase -ItemType Directory -Force + } + $script:PublishModuleBaseUNC = $script:PublishModuleBase -Replace '^(.):', '\\localhost\$1$' + + #Create dependency module + $script:DependencyModuleName = "TEMP-PackageManagement" + $script:DependencyModuleBase = Join-Path $script:tmpModulesPath -ChildPath $script:DependencyModuleName + if(!(Test-Path $script:DependencyModuleBase)) + { + New-Item -Path $script:DependencyModuleBase -ItemType Directory -Force + } + + # Create temp destination path + $script:destinationPath = [IO.Path]::GetFullPath((Join-Path -Path $TestDrive -ChildPath "tmpDestinationPath")) + $null = New-Item $script:destinationPath -ItemType directory -Force + + #Create folder where we shall place all script files to be published for these tests + $script:tmpScriptsFolderPath = Join-Path -Path $TestDrive -ChildPath "tmpScriptsPath" + if(!(Test-Path $script:tmpScriptsFolderPath)) + { + $null = New-Item -Path $script:tmpScriptsFolderPath -ItemType Directory -Force + } + + # Path to folder, within our test folder, where we store invalid module and script files used for testing + $script:testFilesFolderPath = Join-Path $script:testDir -ChildPath "testFiles" + + # Path to specifically to that invalid test modules folder + $script:testModulesFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testModules" + + # Path to specifically to that invalid test scripts folder + $script:testScriptsFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testScripts" + } + AfterEach { + if(!(Test-Path $script:PublishModuleBase)) + { + Remove-Item -Path $script:PublishModuleBase -Recurse -Force + } + } + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + It "Publish module with required module not installed on the local machine using -SkipModuleManifestValidate" { + $ModuleName = "modulewithmissingrequiredmodule-" + [System.Guid]::NewGuid() + CreateTestModule -Path $TestDrive -ModuleName $ModuleName + + # Skip the module manifest validation test, which fails from the missing manifest required module. + $testModulePath = Join-Path -Path $TestDrive -ChildPath $ModuleName + Publish-PSResource -Path $testModulePath -Repository $ACRRepoName -Confirm:$false -SkipDependenciesCheck -SkipModuleManifestValidate + + $results = Find-PSResource -Name $ModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $ModuleName + $results[0].Version | Should -Be "1.0.0" + } + + It "Publish a module with -Path pointing to a module directory (parent directory has same name)" { + $version = "1.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a module directory (parent directory has different name)" { + $version = "2.0.0" + $newModuleRoot = Join-Path -Path $script:PublishModuleBase -ChildPath "NewTestParentDirectory" + New-Item -Path $newModuleRoot -ItemType Directory -Force + New-ModuleManifest -Path (Join-Path -Path $newModuleRoot -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $newModuleRoot -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a .psd1 (parent directory has same name)" { + $version = "3.0.0" + $manifestPath = Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1" + New-ModuleManifest -Path $manifestPath -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $manifestPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a .psd1 (parent directory has different name)" { + $version = "4.0.0" + $newModuleRoot = Join-Path -Path $script:PublishModuleBase -ChildPath "NewTestParentDirectory" + New-Item -Path $newModuleRoot -ItemType Directory -Force + $manifestPath = Join-Path -Path $newModuleRoot -ChildPath "$script:PublishModuleName.psd1" + New-ModuleManifest -Path $manifestPath -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $manifestPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a module directory (parent directory has same name) on a network share" { + $version = "5.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBaseUNC -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $script:PublishModuleBaseUNC -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a module directory (parent directory has different name) on a network share" { + $version = "6.0.0" + $newModuleRoot = Join-Path -Path $script:PublishModuleBaseUNC -ChildPath "NewTestParentDirectory" + New-Item -Path $newModuleRoot -ItemType Directory -Force + New-ModuleManifest -Path (Join-Path -Path $newModuleRoot -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $newModuleRoot -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a .psd1 (parent directory has same name) on a network share" { + $version = "7.0.0" + $manifestPath = Join-Path -Path $script:PublishModuleBaseUNC -ChildPath "$script:PublishModuleName.psd1" + New-ModuleManifest -Path $manifestPath -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $manifestPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a .psd1 (parent directory has different name) on a network share" { + $version = "8.0.0" + $newModuleRoot = Join-Path -Path $script:PublishModuleBaseUNC -ChildPath "NewTestParentDirectory" + New-Item -Path $newModuleRoot -ItemType Directory -Force + $manifestPath = Join-Path -Path $newModuleRoot -ChildPath "$script:PublishModuleName.psd1" + New-ModuleManifest -Path $manifestPath -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $manifestPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module and preserve file structure" { + $version = "9.0.0" + $testFile = Join-Path -Path "TestSubDirectory" -ChildPath "TestSubDirFile.ps1" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + New-Item -Path (Join-Path -Path $script:PublishModuleBase -ChildPath $testFile) -Force + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName -ErrorAction Stop + + Save-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -AsNupkg -Path $TestDrive -TrustRepository + # Must change .nupkg to .zip so that Expand-Archive can work on Windows PowerShell + $nupkgPath = Join-Path -Path $TestDrive -ChildPath "$script:PublishModuleName.$version.nupkg" + $zipPath = Join-Path -Path $TestDrive -ChildPath "$script:PublishModuleName.$version.zip" + Rename-Item -Path $nupkgPath -NewName $zipPath + $unzippedPath = Join-Path -Path $TestDrive -ChildPath "$script:PublishModuleName" + New-Item $unzippedPath -Itemtype directory -Force + Expand-Archive -Path $zipPath -DestinationPath $unzippedPath + + Test-Path -Path (Join-Path -Path $unzippedPath -ChildPath $testFile) | Should -Be $True + } + + It "Publish a module with -Path -Repository and -DestinationPath" { + $version = "10.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName -DestinationPath $script:destinationPath + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + + $expectedPath = Join-Path -Path $script:destinationPath -ChildPath "$script:PublishModuleName.$version.nupkg" + Test-Path $expectedPath | Should -Be $true + } + + It "Publish a module and clean up properly when file in module is readonly" { + $version = "11.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + # Create a readonly file that will throw access denied error if deletion is attempted + $file = Join-Path -Path $script:PublishModuleBase -ChildPath "inaccessiblefile.txt" + New-Item $file -Itemtype file -Force + Set-ItemProperty -Path $file -Name IsReadOnly -Value $true + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module when the .psd1 version and the path version are different" { + $incorrectVersion = "15.2.4" + $correctVersion = "12.0.0" + $versionBase = (Join-Path -Path $script:PublishModuleBase -ChildPath $incorrectVersion) + New-Item -Path $versionBase -ItemType Directory -Force + $modManifestPath = (Join-Path -Path $versionBase -ChildPath "$script:PublishModuleName.psd1") + New-ModuleManifest -Path $modManifestPath -ModuleVersion $correctVersion -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $modManifestPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $correctVersion + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $correctVersion + } + + <# TODO: enable with scripts are supported in ACR + It "Publish a script"{ + $scriptName = "TEMP-PSGetTestScript" + $scriptVersion = "1.0.0" + + $params = @{ + Version = $scriptVersion + GUID = [guid]::NewGuid() + Author = 'Jane' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) 2020 Microsoft Corporation. All rights reserved.' + Description = "Description for the $scriptName script" + LicenseUri = "https://$scriptName.com/license" + IconUri = "https://$scriptName.com/icon" + ProjectUri = "https://$scriptName.com" + Tags = @('Tag1','Tag2', "Tag-$scriptName-$scriptVersion") + ReleaseNotes = "$scriptName release notes" + } + + $scriptPath = (Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$scriptName.ps1") + New-PSScriptFileInfo @params -Path $scriptPath + + Publish-PSResource -Path $scriptPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $scriptName + $results[0].Version | Should -Be $scriptVersion + } + + It "Should publish a script without lines in between comment blocks locally" { + $scriptName = "ScriptWithoutEmptyLinesBetweenCommentBlocks" + $scriptVersion = "1.0.0" + $scriptPath = (Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1") + + Publish-PSResource -Path $scriptPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $scriptName + $results[0].Version | Should -Be $scriptVersion + } + + It "Should publish a script without lines in help block locally" { + $scriptName = "ScriptWithoutEmptyLinesInMetadata" + $scriptVersion = "1.0.0" + $scriptPath = (Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1") + + Publish-PSResource -Path $scriptPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $scriptName + $results[0].Version | Should -Be $scriptVersion + } + + It "Should publish a script with ExternalModuleDependencies that are not published" { + $scriptName = "testscript" + $scriptVersion = "1.0.0" + $scriptPath = Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1" + New-PSScriptFileInfo -Description 'test' -Version $scriptVersion -RequiredModules @{ModuleName='testModule'} -ExternalModuleDependencies 'testModule' -Path $scriptPath -Force + + Publish-PSResource -Path $scriptPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $scriptName + $results[0].Version | Should -Be $scriptVersion + } + #> + + It "Should write error and not publish script when Author property is missing" { + $scriptName = "InvalidScriptMissingAuthor.ps1" + + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "psScriptMissingAuthor,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + + Find-PSResource -Name $scriptName -Repository $ACRRepoName -ErrorVariable findErr -ErrorAction SilentlyContinue + $findErr.Count | Should -BeGreaterThan 0 + $findErr[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should write error and not publish script when Version property is missing" { + $scriptName = "InvalidScriptMissingVersion.ps1" + + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "psScriptMissingVersion,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + + Find-PSResource -Name $scriptName -Repository $ACRRepoName -ErrorVariable findErr -ErrorAction SilentlyContinue + $findErr.Count | Should -BeGreaterThan 0 + $findErr[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should write error and not publish script when Guid property is missing" { + $scriptName = "InvalidScriptMissingGuid.ps1" + + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "psScriptMissingGuid,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + + Find-PSResource -Name $scriptName -Repository $ACRRepoName -ErrorVariable findErr -ErrorAction SilentlyContinue + $findErr.Count | Should -BeGreaterThan 0 + $findErr[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should write error and not publish script when Description property is missing" { + $scriptName = "InvalidScriptMissingDescription.ps1" + + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PSScriptInfoMissingDescription,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + + Find-PSResource -Name $scriptName -Repository $ACRRepoName -ErrorVariable findErr -ErrorAction SilentlyContinue + $findErr.Count | Should -BeGreaterThan 0 + $findErr[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should write error and not publish script when Description block altogether is missing" { + # we expect .ps1 files to have a separate comment block for .DESCRIPTION property, not to be included in the PSScriptInfo commment block + $scriptName = "InvalidScriptMissingDescriptionCommentBlock.ps1" + + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "missingHelpInfoCommentError,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + + Find-PSResource -Name $scriptName -Repository $ACRRepoName -ErrorVariable findErr -ErrorAction SilentlyContinue + $findErr.Count | Should -BeGreaterThan 0 + $findErr[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Publish a module with that has an invalid version format, should throw" { + $moduleName = "incorrectmoduleversion" + $incorrectmoduleversion = Join-Path -Path $script:testModulesFolderPath -ChildPath $moduleName + + { Publish-PSResource -Path $incorrectmoduleversion -Repository $ACRRepoName -ErrorAction Stop } | Should -Throw -ErrorId "InvalidModuleManifest,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + } + + + It "Publish a module with a dependency that has an invalid version format, should throw" { + $moduleName = "incorrectdepmoduleversion" + $incorrectdepmoduleversion = Join-Path -Path $script:testModulesFolderPath -ChildPath $moduleName + + { Publish-PSResource -Path $incorrectdepmoduleversion -Repository $ACRRepoName -ErrorAction Stop } | Should -Throw -ErrorId "InvalidModuleManifest,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + } + + It "Publish a module with using an invalid file path (path to .psm1), should throw" { + $fileName = "$script:PublishModuleName.psm1" + $psm1Path = Join-Path -Path $script:PublishModuleBase -ChildPath $fileName + $null = New-Item -Path $psm1Path -ItemType File -Force + + {Publish-PSResource -Path $psm1Path -Repository $ACRRepoName -ErrorAction Stop} | Should -Throw -ErrorId "InvalidPublishPath,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + } +} From f84e572ab3b677f272358084d540b10761498ec1 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Mon, 4 Mar 2024 13:32:24 -0500 Subject: [PATCH 052/160] Add support for ACR when publishing scripts (#1573) --- src/code/ACRServerAPICalls.cs | 130 ++++++++++-------- src/code/PSResourceInfo.cs | 24 +++- src/code/PublishPSResource.cs | 40 +++--- .../FindPSResourceACRServer.Tests.ps1 | 17 +++ .../InstallPSResourceACRServer.Tests.ps1 | 13 +- .../PublishPSResourceACRServer.Tests.ps1 | 25 ++-- 6 files changed, 152 insertions(+), 97 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 638bc1c0c..1c83eba54 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -133,6 +133,7 @@ public override FindResults FindName(string packageName, bool includePrerelease, _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindName()"); string accessToken = string.Empty; string tenantID = string.Empty; + string packageNameLowercase = packageName.ToLower(); // Need to set up secret management vault before hand var repositoryCredentialInfo = Repository.CredentialInfo; @@ -167,7 +168,7 @@ public override FindResults FindName(string packageName, bool includePrerelease, } _cmdletPassedIn.WriteVerbose("Getting tags"); - var foundTags = FindAcrImageTags(registry, packageName, "*", acrAccessToken, out errRecord); + var foundTags = FindAcrImageTags(registry, packageNameLowercase, "*", acrAccessToken, out errRecord); if (errRecord != null || foundTags == null) { return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); @@ -193,20 +194,29 @@ public override FindResults FindName(string packageName, bool includePrerelease, return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } - if (NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) + if (!NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) { - _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); - if (!pkgVersion.IsPrerelease || includePrerelease) - { - // Versions are always in descending order i.e 5.0.0, 3.0.0, 1.0.0 so grabbing the first match suffices - latestVersionResponse.Add(GetACRMetadata(registry, packageName, pkgVersion, acrAccessToken, out errRecord)); - if (errRecord != null) - { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: latestVersionResponse.ToArray(), responseType: acrFindResponseType); - } + errRecord = new ErrorRecord( + new ArgumentException($"Version {pkgVersionElement.ToString()} to be parsed from metadata is not a valid NuGet version."), + "FindNameFailure", + ErrorCategory.InvalidArgument, + this); - break; + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + } + + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + if (!pkgVersion.IsPrerelease || includePrerelease) + { + // TODO: ensure versions are in order, fix bug https://github.com/PowerShell/PSResourceGet/issues/1581 + Hashtable metadata = GetACRMetadata(registry, packageNameLowercase, pkgVersion, acrAccessToken, out errRecord); + if (errRecord != null || metadata.Count == 0) + { + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } + + latestVersionResponse.Add(metadata); + break; } } } @@ -285,6 +295,7 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindVersionGlobbing()"); string accessToken = string.Empty; string tenantID = string.Empty; + string packageNameLowercase = packageName.ToLower(); // Need to set up secret management vault beforehand var repositoryCredentialInfo = Repository.CredentialInfo; @@ -318,7 +329,7 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange } _cmdletPassedIn.WriteVerbose("Getting tags"); - var foundTags = FindAcrImageTags(registry, packageName, "*", acrAccessToken, out errRecord); + var foundTags = FindAcrImageTags(registry, packageNameLowercase, "*", acrAccessToken, out errRecord); if (errRecord != null || foundTags == null) { return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); @@ -355,7 +366,7 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange continue; } - latestVersionResponse.Add(GetACRMetadata(registry, packageName, pkgVersion, acrAccessToken, out errRecord)); + latestVersionResponse.Add(GetACRMetadata(registry, packageNameLowercase, pkgVersion, acrAccessToken, out errRecord)); if (errRecord != null) { return new FindResults(stringResponse: new string[] { }, hashtableResponse: latestVersionResponse.ToArray(), responseType: acrFindResponseType); @@ -706,7 +717,7 @@ internal Hashtable GetACRMetadata(string registry, string packageName, NuGetVers if (exception != null) { errRecord = new ErrorRecord(exception, "FindNameFailure", ErrorCategory.InvalidResult, this); - + return requiredVersionResponse; } @@ -727,14 +738,22 @@ internal Hashtable GetACRMetadata(string registry, string packageName, NuGetVers return requiredVersionResponse; } - if (NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) + if (!NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) { - _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + errRecord = new ErrorRecord( + new ArgumentException($"Version {pkgVersionElement.ToString()} to be parsed from metadata is not a valid NuGet version."), + "FindNameFailure", + ErrorCategory.InvalidArgument, + this); - if (pkgVersion == requiredVersion) - { - requiredVersionResponse.Add(metadataPkgName, metadata); - } + return requiredVersionResponse; + } + + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + + if (pkgVersion == requiredVersion) + { + requiredVersionResponse.Add(metadataPkgName, metadata); } } @@ -1102,7 +1121,7 @@ private static Collection> GetDefaultHeaders(string }; } - internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, Hashtable parsedMetadataHash, out ErrorRecord errRecord) + internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, ResourceType resourceType, Hashtable parsedMetadataHash, out ErrorRecord errRecord) { errRecord = null; // Push the nupkg to the appropriate repository @@ -1188,7 +1207,7 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p /* Create manifest layer */ _cmdletPassedIn.WriteVerbose("Create package version metadata as JSON string"); - string jsonString = CreateMetadataContent(psd1OrPs1File, parsedMetadataHash, out ErrorRecord metadataCreationError); + string jsonString = CreateMetadataContent(psd1OrPs1File, resourceType, parsedMetadataHash, out ErrorRecord metadataCreationError); if (metadataCreationError != null) { _cmdletPassedIn.ThrowTerminatingError(metadataCreationError); @@ -1197,7 +1216,7 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p FileInfo nupkgFile = new FileInfo(fullNupkgFile); var fileSize = nupkgFile.Length; var fileName = System.IO.Path.GetFileName(fullNupkgFile); - string fileContent = CreateJsonContent(nupkgDigest, configDigest, fileSize, fileName, pkgName, jsonString); + string fileContent = CreateJsonContent(nupkgDigest, configDigest, fileSize, fileName, pkgName, resourceType, jsonString); File.WriteAllText(configFilePath, fileContent); _cmdletPassedIn.WriteVerbose("Create the manifest layer"); @@ -1207,6 +1226,7 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p { return true; } + return false; } @@ -1216,6 +1236,7 @@ private string CreateJsonContent( long nupkgFileSize, string fileName, string packageName, + ResourceType resourceType, string jsonString) { StringBuilder stringBuilder = new StringBuilder(); @@ -1224,6 +1245,7 @@ private string CreateJsonContent( jsonWriter.Formatting = Newtonsoft.Json.Formatting.Indented; + // start of manifest JSON object jsonWriter.WriteStartObject(); jsonWriter.WritePropertyName("schemaVersion"); @@ -1257,17 +1279,18 @@ private string CreateJsonContent( jsonWriter.WriteValue(fileName); jsonWriter.WritePropertyName("metadata"); jsonWriter.WriteValue(jsonString); - jsonWriter.WriteEndObject(); - jsonWriter.WriteEndObject(); + jsonWriter.WritePropertyName("artifactType"); + jsonWriter.WriteValue(resourceType.ToString()); + jsonWriter.WriteEndObject(); // end of annotations object - jsonWriter.WriteEndArray(); - jsonWriter.WriteEndObject(); + jsonWriter.WriteEndObject(); // end of 'layers' entry object + + jsonWriter.WriteEndArray(); // end of 'layers' array + jsonWriter.WriteEndObject(); // end of manifest JSON object return stringWriter.ToString(); } - - // ACR method private bool CreateDigest(string fileName, out string digest, out ErrorRecord error) { @@ -1310,45 +1333,40 @@ private bool CreateDigest(string fileName, out string digest, out ErrorRecord er return true; } - private string CreateMetadataContent(string manifestFilePath, Hashtable parsedMetadata, out ErrorRecord metadataCreationError) + private string CreateMetadataContent(string manifestFilePath, ResourceType resourceType, Hashtable parsedMetadata, out ErrorRecord metadataCreationError) { metadataCreationError = null; - Hashtable parsedMetadataHash = null; string jsonString = string.Empty; - // A script will already have the metadata parsed into the parsedMetadatahash, - // a module will still need the module manifest to be parsed. if (parsedMetadata == null || parsedMetadata.Count == 0) - { - // Use the parsed module manifest data as 'parsedMetadataHash' instead of the passed-in data. - if (!Utils.TryReadManifestFile( - manifestFilePath: manifestFilePath, - manifestInfo: out parsedMetadataHash, - error: out Exception manifestReadError)) - { - metadataCreationError = new ErrorRecord( - manifestReadError, - "ManifestFileReadParseForACRPublishError", - ErrorCategory.ReadError, - _cmdletPassedIn); - - return jsonString; - } - } - - if (parsedMetadataHash == null) { metadataCreationError = new ErrorRecord( - new InvalidOperationException("Error parsing package metadata into hashtable."), - "PackageMetadataHashEmptyError", - ErrorCategory.InvalidData, + new ArgumentException("Hashtable created from .ps1 or .psd1 containing package metadata was null or empty"), + "MetadataHashtableEmptyError", + ErrorCategory.InvalidArgument, _cmdletPassedIn); return jsonString; } _cmdletPassedIn.WriteVerbose("Serialize JSON into string."); - jsonString = System.Text.Json.JsonSerializer.Serialize(parsedMetadataHash); + + if (parsedMetadata.ContainsKey("Version") && parsedMetadata["Version"] is NuGetVersion pkgNuGetVersion) + { + // do not serialize NuGetVersion, this will populate more metadata than is needed and makes it harder to deserialize later + parsedMetadata.Remove("Version"); + parsedMetadata["Version"] = pkgNuGetVersion.ToString(); + } + + try + { + jsonString = System.Text.Json.JsonSerializer.Serialize(parsedMetadata); + } + catch (Exception ex) + { + metadataCreationError = new ErrorRecord(ex, "JsonSerializationError", ErrorCategory.InvalidResult, _cmdletPassedIn); + return jsonString; + } return jsonString; } diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 91e802ece..838d90660 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -819,7 +819,7 @@ public static bool TryConvertFromACRJson( if (packageMetadata == null) { - errorMsg = "TryConvertJsonToPSResourceInfo: Invalid json object. Object cannot be null."; + errorMsg = "TryConvertFromACRJson: Invalid json object. Object cannot be null."; return false; } @@ -829,24 +829,34 @@ public static bool TryConvertFromACRJson( JsonElement rootDom = packageMetadata.RootElement; // Version - if (rootDom.TryGetProperty("ModuleVersion", out JsonElement versionElement)) + if (rootDom.TryGetProperty("ModuleVersion", out JsonElement versionElement) || rootDom.TryGetProperty("Version", out versionElement)) { string versionValue = versionElement.ToString(); - metadata["Version"] = ParseHttpVersion(versionValue, out string prereleaseLabel); + + Version pkgVersion = ParseHttpVersion(versionValue, out string prereleaseLabel); + metadata["Version"] = pkgVersion; metadata["Prerelease"] = prereleaseLabel; metadata["IsPrerelease"] = !String.IsNullOrEmpty(prereleaseLabel); - if (!NuGetVersion.TryParse(versionValue, out NuGetVersion parsedNormalizedVersion)) + if (!NuGetVersion.TryParse(versionValue, out NuGetVersion parsedNormalizedVersion) && pkgVersion == null) { errorMsg = string.Format( CultureInfo.InvariantCulture, - @"TryReadPSGetInfo: Cannot parse NormalizedVersion"); + @"TryConvertFromACRJson: Cannot parse NormalizedVersion or System.Version from version in metadata."); - parsedNormalizedVersion = new NuGetVersion("1.0.0.0"); + return false; } metadata["NormalizedVersion"] = parsedNormalizedVersion; } + else + { + errorMsg = string.Format( + CultureInfo.InvariantCulture, + @"TryConvertFromACRJson: Neither 'ModuleVersion' nor 'Version' could be found in package metadata"); + + return false; + } // License Url if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement)) @@ -972,7 +982,7 @@ public static bool TryConvertFromACRJson( { errorMsg = string.Format( CultureInfo.InvariantCulture, - @"TryConvertFromJson: Cannot parse PSResourceInfo from json object with error: {0}", + @"TryConvertFromACRJson: Cannot parse PSResourceInfo from json object with error: {0}", ex.Message); return false; diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index 990e66cb8..ce65916f8 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -261,7 +261,6 @@ out string[] _ } else { - // parsedMetadata needs to be initialized for modules, will later be passed in to create nuspec if (!string.IsNullOrEmpty(pathToModuleManifestToPublish)) { _pkgName = System.IO.Path.GetFileNameWithoutExtension(pathToModuleManifestToPublish); @@ -305,6 +304,20 @@ out string[] _ ErrorCategory.InvalidOperation, this)); } + + if (!Utils.TryReadManifestFile( + manifestFilePath: pathToModuleManifestToPublish, + manifestInfo: out parsedMetadata, + error: out Exception manifestReadError)) + { + WriteError(new ErrorRecord( + manifestReadError, + "ManifestFileReadParseForACRPublishError", + ErrorCategory.ReadError, + this)); + + return; + } } // Create a temp folder to push the nupkg to and delete it later @@ -327,7 +340,6 @@ out string[] _ try { // Create a nuspec - // Right now parsedMetadataHash will be empty for modules and will contain metadata for scripts Hashtable dependencies; string nuspec = string.Empty; try @@ -487,7 +499,7 @@ out string[] _ ACRServerAPICalls acrServer = new ACRServerAPICalls(repository, this, _networkCredential, userAgentString); var pkgMetadataFile = (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish; - if (!acrServer.PushNupkgACR(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, repository, parsedMetadata, out ErrorRecord pushNupkgACRError)) + if (!acrServer.PushNupkgACR(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, repository, resourceType, parsedMetadata, out ErrorRecord pushNupkgACRError)) { WriteError(pushNupkgACRError); // exit out of processing @@ -535,24 +547,14 @@ private string CreateNuspec( bool isModule = resourceType != ResourceType.Script; requiredModules = new Hashtable(); - // A script will already have the metadata parsed into the parsedMetadatahash, - // a module will still need the module manifest to be parsed. - if (isModule) + if (parsedMetadataHash == null || parsedMetadataHash.Count == 0) { - // Use the parsed module manifest data as 'parsedMetadataHash' instead of the passed-in data. - if (!Utils.TryReadManifestFile( - manifestFilePath: filePath, - manifestInfo: out parsedMetadataHash, - error: out Exception manifestReadError)) - { - WriteError(new ErrorRecord( - manifestReadError, - "ManifestFileReadParseForNuspecError", - ErrorCategory.ReadError, - this)); + WriteError(new ErrorRecord(new ArgumentException("Hashtable provided with package metadata was null or empty"), + "PackageMetadataHashtableNullOrEmptyError", + ErrorCategory.ReadError, + this)); - return string.Empty; - } + return string.Empty; } // now we have parsedMetadatahash to fill out the nuspec information diff --git a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 index e7a19a58c..e7b87b4a8 100644 --- a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 @@ -8,6 +8,7 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { BeforeAll{ $testModuleName = "test_local_mod" + $testScript = "testscript" $ACRRepoName = "ACRRepo" $ACRRepoUri = "https://psresourcegettest.azurecr.io" Get-NewPSResourceRepositoryFile @@ -137,4 +138,20 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $err.Count | Should -BeGreaterThan 0 $err[0].FullyQualifiedErrorId | Should -BeExactly "FindAllFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } + + It "Should find script given Name" { + # FindName() + $res = Find-PSResource -Name $testScript -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $testScript + $res.Version | Should -Be "2.0.0" + } + + It "Should find script given Name and Version" { + # FindVersion() + $res = Find-PSResource -Name $testScript -Version "1.0.0" -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $testScript + $res.Version | Should -Be "1.0.0" + } } diff --git a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 index 9809d4388..0e4e64ae4 100644 --- a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 @@ -10,7 +10,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { BeforeAll { $testModuleName = "test_local_mod" $testModuleName2 = "test_local_mod2" - $testScriptName = "test_ado_script" + $testScriptName = "testscript" $ACRRepoName = "ACRRepo" $ACRRepoUri = "https://psresourcegettest.azurecr.io/" Get-NewPSResourceRepositoryFile @@ -46,14 +46,19 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { $pkg.Version | Should -Be "5.0.0" } - <# TODO: enable after implementing script functionality It "Install specific script resource by name" { Install-PSResource -Name $testScriptName -Repository $ACRRepoName -TrustRepository $pkg = Get-InstalledPSResource $testScriptName + $pkg.Name | Should -BeExactly $testScriptName + $pkg.Version | Should -Be "2.0.0" + } + + It "Install script resource by name and version" { + Install-PSResource -Name $testScriptName -Version "1.0.0" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testScriptName $pkg.Name | Should -Be $testScriptName - $pkg.Version | Should -Be "1.0.0" + $pkg.Version | Should -BeExactly "1.0.0" } - #> It "Install multiple resources by name" { $pkgNames = @($testModuleName, $testModuleName2) diff --git a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 index 2b1a3ea64..b3d717412 100644 --- a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 @@ -289,9 +289,9 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Version | Should -Be $correctVersion } - <# TODO: enable with scripts are supported in ACR It "Publish a script"{ - $scriptName = "TEMP-PSGetTestScript" + $scriptBaseName = "temp-testscript" + $scriptName = $scriptBaseName + [System.Guid]::NewGuid(); $scriptVersion = "1.0.0" $params = @{ @@ -299,13 +299,13 @@ Describe "Test Publish-PSResource" -tags 'CI' { GUID = [guid]::NewGuid() Author = 'Jane' CompanyName = 'Microsoft Corporation' - Copyright = '(c) 2020 Microsoft Corporation. All rights reserved.' - Description = "Description for the $scriptName script" - LicenseUri = "https://$scriptName.com/license" - IconUri = "https://$scriptName.com/icon" - ProjectUri = "https://$scriptName.com" - Tags = @('Tag1','Tag2', "Tag-$scriptName-$scriptVersion") - ReleaseNotes = "$scriptName release notes" + Copyright = '(c) 2024 Microsoft Corporation. All rights reserved.' + Description = "Description for the $scriptBaseName script" + LicenseUri = "https://$scriptBaseName.com/license" + IconUri = "https://$scriptBaseName.com/icon" + ProjectUri = "https://$scriptBaseName.com" + Tags = @('Tag1','Tag2', "Tag-$scriptBaseName-$scriptVersion") + ReleaseNotes = "$scriptBaseName release notes" } $scriptPath = (Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$scriptName.ps1") @@ -319,6 +319,7 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Version | Should -Be $scriptVersion } + <# This test does not work currently due to a bug if the last digit is 0. Link to issue: https://github.com/PowerShell/PSResourceGet/issues/1582 It "Should publish a script without lines in between comment blocks locally" { $scriptName = "ScriptWithoutEmptyLinesBetweenCommentBlocks" $scriptVersion = "1.0.0" @@ -331,7 +332,9 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Name | Should -Be $scriptName $results[0].Version | Should -Be $scriptVersion } + #> + <# This test does not work currently due to a bug if the last digit is 0. Link to issue: https://github.com/PowerShell/PSResourceGet/issues/1582 It "Should publish a script without lines in help block locally" { $scriptName = "ScriptWithoutEmptyLinesInMetadata" $scriptVersion = "1.0.0" @@ -344,9 +347,10 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Name | Should -Be $scriptName $results[0].Version | Should -Be $scriptVersion } + #> It "Should publish a script with ExternalModuleDependencies that are not published" { - $scriptName = "testscript" + $scriptName = "ScriptWithExternalDependencies" $scriptVersion = "1.0.0" $scriptPath = Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1" New-PSScriptFileInfo -Description 'test' -Version $scriptVersion -RequiredModules @{ModuleName='testModule'} -ExternalModuleDependencies 'testModule' -Path $scriptPath -Force @@ -358,7 +362,6 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Name | Should -Be $scriptName $results[0].Version | Should -Be $scriptVersion } - #> It "Should write error and not publish script when Author property is missing" { $scriptName = "InvalidScriptMissingAuthor.ps1" From b3fc7e2a9be6f322d4d7a6c169cb7ade89d404bc Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 5 Mar 2024 12:48:12 -0800 Subject: [PATCH 053/160] Add support for dependencies when publishing to ACR server (#1577) --- src/code/ACRServerAPICalls.cs | 504 +++++++++++++++--- src/code/PSGetException.cs | 8 + src/code/PublishPSResource.cs | 2 +- src/code/Utils.cs | 20 + .../PublishPSResourceACRServer.Tests.ps1 | 46 +- 5 files changed, 509 insertions(+), 71 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 1c83eba54..20aeec6fe 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -30,6 +30,7 @@ internal class ACRServerAPICalls : ServerApiCall #region Members public override PSRepositoryInfo Repository { get; set; } + public String Registry { get; set; } private readonly PSCmdlet _cmdletPassedIn; private HttpClient _sessionClient { get; set; } private static readonly Hashtable[] emptyHashResponses = new Hashtable[] { }; @@ -54,6 +55,7 @@ internal class ACRServerAPICalls : ServerApiCall public ACRServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, NetworkCredential networkCredential, string userAgentString) : base(repository, networkCredential) { Repository = repository; + Registry = Repository.Uri.Host; _cmdletPassedIn = cmdletPassedIn; HttpClientHandler handler = new HttpClientHandler() { @@ -435,17 +437,17 @@ public override FindResults FindVersion(string packageName, string version, Reso return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } - _cmdletPassedIn.WriteVerbose("Getting tags"); + _cmdletPassedIn.WriteVerbose("Getting ACR metadata"); List results = new List { GetACRMetadata(registry, packageName, requiredVersion, acrAccessToken, out errRecord) }; + if (errRecord != null) { return new FindResults(stringResponse: new string[] { }, hashtableResponse: results.ToArray(), responseType: acrFindResponseType); } - return new FindResults(stringResponse: new string[] { }, hashtableResponse: results.ToArray(), responseType: acrFindResponseType); } @@ -601,6 +603,35 @@ private string GetDigestFromManifest(JObject manifest, out ErrorRecord errRecord return digest; } + internal string GetACRRegistryToken(out ErrorRecord errRecord) + { + string accessToken = string.Empty; + string tenantID = string.Empty; + + // Need to set up secret management vault before hand + var repositoryCredentialInfo = Repository.CredentialInfo; + if (repositoryCredentialInfo != null) + { + accessToken = Utils.GetACRAccessTokenFromSecretManagement( + Repository.Name, + repositoryCredentialInfo, + _cmdletPassedIn); + + _cmdletPassedIn.WriteVerbose("Access token retrieved."); + + tenantID = repositoryCredentialInfo.SecretName; + _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); + } + + // Call asynchronous network methods in a try/catch block to handle exceptions. + + _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); + var acrRefreshToken = GetAcrRefreshToken(Registry, tenantID, accessToken, out errRecord); + _cmdletPassedIn.WriteVerbose("Getting acr access token"); + + return GetAcrAccessToken(Registry, acrRefreshToken, out errRecord); + } + internal string GetAcrRefreshToken(string registry, string tenant, string accessToken, out ErrorRecord errRecord) { string content = string.Format(acrRefreshTokenTemplate, registry, tenant, accessToken); @@ -750,7 +781,6 @@ internal Hashtable GetACRMetadata(string registry, string packageName, NuGetVers } _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); - if (pkgVersion == requiredVersion) { requiredVersionResponse.Add(metadataPkgName, metadata); @@ -824,12 +854,12 @@ internal JObject FindAcrManifest(string registry, string packageName, string ver } } - internal async Task GetStartUploadBlobLocation(string registry, string pkgName, string acrAccessToken) + internal async Task GetStartUploadBlobLocation(string pkgName, string acrAccessToken) { try { var defaultHeaders = GetDefaultHeaders(acrAccessToken); - var startUploadUrl = string.Format(acrStartUploadTemplate, registry, pkgName); + var startUploadUrl = string.Format(acrStartUploadTemplate, Registry, pkgName); return (await GetHttpResponseHeader(startUploadUrl, HttpMethod.Post, defaultHeaders)).Location.ToString(); } catch (HttpRequestException e) @@ -838,11 +868,11 @@ internal async Task GetStartUploadBlobLocation(string registry, string p } } - internal async Task EndUploadBlob(string registry, string location, string filePath, string digest, bool isManifest, string acrAccessToken) + internal async Task EndUploadBlob(string location, string filePath, string digest, bool isManifest, string acrAccessToken) { try { - var endUploadUrl = string.Format(acrEndUploadTemplate, registry, location, digest); + var endUploadUrl = string.Format(acrEndUploadTemplate, Registry, location, digest); var defaultHeaders = GetDefaultHeaders(acrAccessToken); return await PutRequestAsync(endUploadUrl, filePath, isManifest, defaultHeaders); } @@ -852,11 +882,25 @@ internal async Task EndUploadBlob(string registry, string location, string } } - internal async Task CreateManifest(string registry, string pkgName, string pkgVersion, string configPath, bool isManifest, string acrAccessToken) + internal async Task UploadManifest(string pkgName, string pkgVersion, string configPath, bool isManifest, string acrAccessToken) { try { - var createManifestUrl = string.Format(acrManifestUrlTemplate, registry, pkgName, pkgVersion); + var createManifestUrl = string.Format(acrManifestUrlTemplate, Registry, pkgName, pkgVersion); + var defaultHeaders = GetDefaultHeaders(acrAccessToken); + return await PutRequestAsync(createManifestUrl, configPath, isManifest, defaultHeaders); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to create manifest: " + e.Message); + } + } + + internal async Task UploadDependencyManifest(string pkgName, string referenceSHA, string configPath, bool isManifest, string acrAccessToken) + { + try + { + var createManifestUrl = string.Format(acrManifestUrlTemplate, Registry, pkgName, referenceSHA); var defaultHeaders = GetDefaultHeaders(acrAccessToken); return await PutRequestAsync(createManifestUrl, configPath, isManifest, defaultHeaders); } @@ -1081,7 +1125,7 @@ private static async Task SendRequestHeaderAsync(HttpReques } } - private static async Task PutRequestAsync(string url, string filePath, bool isManifest, Collection> contentHeaders) + private static async Task PutRequestAsync(string url, string filePath, bool isManifest, Collection> contentHeaders) { try { @@ -1100,10 +1144,7 @@ private static async Task PutRequestAsync(string url, string filePath, boo httpContent.Headers.Add("Content-Type", "application/octet-stream"); } - HttpResponseMessage response = await s_client.PutAsync(url, httpContent); - response.EnsureSuccessStatusCode(); - - return response.IsSuccessStatusCode; + return await s_client.PutAsync(url, httpContent); ; } } catch (HttpRequestException e) @@ -1121,54 +1162,66 @@ private static Collection> GetDefaultHeaders(string }; } - internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, ResourceType resourceType, Hashtable parsedMetadataHash, out ErrorRecord errRecord) + internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, ResourceType resourceType, Hashtable parsedMetadataHash, Hashtable dependencies, out ErrorRecord errRecord) { - errRecord = null; - // Push the nupkg to the appropriate repository string fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, pkgName + "." + pkgVersion.ToNormalizedString() + ".nupkg"); string pkgNameLower = pkgName.ToLower(); - string accessToken = string.Empty; - string tenantID = string.Empty; - // Need to set up secret management vault before hand - var repositoryCredentialInfo = repository.CredentialInfo; - if (repositoryCredentialInfo != null) - { - accessToken = Utils.GetACRAccessTokenFromSecretManagement( - repository.Name, - repositoryCredentialInfo, - _cmdletPassedIn); + // Get access token (includes refresh tokens) + var acrAccessToken = GetACRRegistryToken(out errRecord); - _cmdletPassedIn.WriteVerbose("Access token retrieved."); + // Upload .nupkg + TryUploadNupkg(pkgNameLower, acrAccessToken, fullNupkgFile, out string nupkgDigest); - tenantID = repositoryCredentialInfo.SecretName; - _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); + // Create and upload an empty file-- needed by ACR server + TryCreateAndUploadEmptyFile(outputNupkgDir, pkgNameLower, acrAccessToken); + + // Create config.json file + var configFilePath = System.IO.Path.Combine(outputNupkgDir, "config.json"); + TryCreateConfig(configFilePath, out string configDigest); + + _cmdletPassedIn.WriteVerbose("Create package version metadata as JSON string"); + // Create module metadata string + string metadataJson = CreateMetadataContent(psd1OrPs1File, resourceType, parsedMetadataHash, out ErrorRecord metadataCreationError); + if (metadataCreationError != null) + { + _cmdletPassedIn.ThrowTerminatingError(metadataCreationError); } - // Call asynchronous network methods in a try/catch block to handle exceptions. - string registry = repository.Uri.Host; + // Create and upload manifest + TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, pkgName, resourceType, metadataJson, configFilePath, + pkgNameLower, pkgVersion, acrAccessToken, out HttpResponseMessage manifestResponse); - _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); - _cmdletPassedIn.WriteVerbose("Getting acr access token"); - var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + // After manifest is created, see if there are any dependencies that need to be tracked on the server + if (dependencies != null && dependencies.Count > 0) + { + TryProcessDependencies(dependencies, pkgNameLower, acrAccessToken, manifestResponse); + } - /* Uploading .nupkg */ + return true; + } + + private bool TryUploadNupkg(string pkgNameLower, string acrAccessToken, string fullNupkgFile, out string nupkgDigest) + { _cmdletPassedIn.WriteVerbose("Start uploading blob"); // Note: ACR registries will only accept a name that is all lowercase. - var moduleLocation = GetStartUploadBlobLocation(registry, pkgNameLower, acrAccessToken).Result; + var moduleLocation = GetStartUploadBlobLocation(pkgNameLower, acrAccessToken).Result; _cmdletPassedIn.WriteVerbose("Computing digest for .nupkg file"); - bool nupkgDigestCreated = CreateDigest(fullNupkgFile, out string nupkgDigest, out ErrorRecord nupkgDigestError); + bool nupkgDigestCreated = CreateDigest(fullNupkgFile, out nupkgDigest, out ErrorRecord nupkgDigestError); if (!nupkgDigestCreated) { _cmdletPassedIn.ThrowTerminatingError(nupkgDigestError); } _cmdletPassedIn.WriteVerbose("Finish uploading blob"); - bool moduleUploadSuccess = EndUploadBlob(registry, moduleLocation, fullNupkgFile, nupkgDigest, false, acrAccessToken).Result; + var responseNupkg = EndUploadBlob(moduleLocation, fullNupkgFile, nupkgDigest, false, acrAccessToken).Result; - /* Upload an empty file-- needed by ACR server */ + return responseNupkg.IsSuccessStatusCode; + } + + private bool TryCreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLower, string acrAccessToken) + { _cmdletPassedIn.WriteVerbose("Create an empty file"); string emptyFileName = "empty.txt"; var emptyFilePath = System.IO.Path.Combine(outputNupkgDir, emptyFileName); @@ -1177,9 +1230,10 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p { emptyFilePath = Guid.NewGuid().ToString() + ".txt"; } - using (FileStream configStream = new FileStream(emptyFilePath, FileMode.Create)){ } + Utils.CreateFile(emptyFilePath); + _cmdletPassedIn.WriteVerbose("Start uploading an empty file"); - var emptyLocation = GetStartUploadBlobLocation(registry, pkgNameLower, acrAccessToken).Result; + var emptyLocation = GetStartUploadBlobLocation(pkgNameLower, acrAccessToken).Result; _cmdletPassedIn.WriteVerbose("Computing digest for empty file"); bool emptyDigestCreated = CreateDigest(emptyFilePath, out string emptyDigest, out ErrorRecord emptyDigestError); if (!emptyDigestCreated) @@ -1187,57 +1241,232 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p _cmdletPassedIn.ThrowTerminatingError(emptyDigestError); } _cmdletPassedIn.WriteVerbose("Finish uploading empty file"); - bool emptyFileUploadSuccess = EndUploadBlob(registry, emptyLocation, emptyFilePath, emptyDigest, false, acrAccessToken).Result; + var emptyResponse = EndUploadBlob(emptyLocation, emptyFilePath, emptyDigest, false, acrAccessToken).Result; - /* Create config layer */ + return emptyResponse.IsSuccessStatusCode; + } + + private bool TryCreateConfig(string configFilePath, out string configDigest) + { _cmdletPassedIn.WriteVerbose("Create the config file"); - string configFileName = "config.json"; - var configFilePath = System.IO.Path.Combine(outputNupkgDir, configFileName); while (File.Exists(configFilePath)) { configFilePath = Guid.NewGuid().ToString() + ".json"; } - using (FileStream configStream = new FileStream(configFilePath, FileMode.Create)){ } + Utils.CreateFile(configFilePath); + _cmdletPassedIn.WriteVerbose("Computing digest for config"); - bool configDigestCreated = CreateDigest(configFilePath, out string configDigest, out ErrorRecord configDigestError); + bool configDigestCreated = CreateDigest(configFilePath, out configDigest, out ErrorRecord configDigestError); if (!configDigestCreated) { _cmdletPassedIn.ThrowTerminatingError(configDigestError); } - /* Create manifest layer */ - _cmdletPassedIn.WriteVerbose("Create package version metadata as JSON string"); - string jsonString = CreateMetadataContent(psd1OrPs1File, resourceType, parsedMetadataHash, out ErrorRecord metadataCreationError); - if (metadataCreationError != null) - { - _cmdletPassedIn.ThrowTerminatingError(metadataCreationError); - } + return configDigestCreated; + } + private bool TryCreateAndUploadManifest(string fullNupkgFile, string nupkgDigest, string configDigest, string pkgName, ResourceType resourceType, string metadataJson, string configFilePath, + string pkgNameLower, NuGetVersion pkgVersion, string acrAccessToken, out HttpResponseMessage manifestResponse) + { FileInfo nupkgFile = new FileInfo(fullNupkgFile); var fileSize = nupkgFile.Length; var fileName = System.IO.Path.GetFileName(fullNupkgFile); - string fileContent = CreateJsonContent(nupkgDigest, configDigest, fileSize, fileName, pkgName, resourceType, jsonString); + string fileContent = CreateManifestContent(nupkgDigest, configDigest, fileSize, fileName, pkgName, resourceType, metadataJson); File.WriteAllText(configFilePath, fileContent); _cmdletPassedIn.WriteVerbose("Create the manifest layer"); - bool manifestCreated = CreateManifest(registry, pkgNameLower, pkgVersion.OriginalVersion, configFilePath, true, acrAccessToken).Result; + manifestResponse = UploadManifest(pkgNameLower, pkgVersion.OriginalVersion, configFilePath, true, acrAccessToken).Result; + bool manifestCreated = manifestResponse.IsSuccessStatusCode; + if (!manifestCreated) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException("Error uploading package manifest"), + "PackageManifestUploadError", + ErrorCategory.InvalidResult, + _cmdletPassedIn)); + return false; + } + + return manifestCreated; + } + + private bool TryProcessDependencies(Hashtable dependencies, string pkgNameLower, string acrAccessToken, HttpResponseMessage manifestResponse) + { + string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + + try + { + Directory.CreateDirectory(tempPath); + + // Create dependency.json + TryCreateAndUploadDependencyJson(tempPath, dependencies, pkgNameLower, acrAccessToken, out string depJsonContent, out string depDigest, out long depFileSize, out string depFileName); - if (manifestCreated) + // Create and upload an empty file-- needed by ACR server + if (!TryCreateAndUploadEmptyTxtFile(tempPath, pkgNameLower, acrAccessToken)) + { + return false; + } + + // Create artifactconfig.json file + string depConfigFileName = "artifactconfig.json"; + var depConfigFilePath = System.IO.Path.Combine(tempPath, depConfigFileName); + while (File.Exists(depConfigFilePath)) + { + depConfigFilePath = System.IO.Path.Combine(tempPath, Guid.NewGuid().ToString() + ".json"); + } + if (!TryCreateDependencyConfigFile(depConfigFilePath, manifestResponse, depJsonContent, depDigest, depFileSize, depFileName, out string artifactDigest)) + { + return false; + } + + _cmdletPassedIn.WriteVerbose("Create the manifest"); + + // Upload Manifest + var depManifestResponse = UploadDependencyManifest(pkgNameLower, $"sha256:{artifactDigest}", depConfigFilePath, true, acrAccessToken).Result; + bool depManifestCreated = depManifestResponse.IsSuccessStatusCode; + if (!depManifestCreated) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException("Error uploading dependency manifest"), + "DependencyManifestUploadError", + ErrorCategory.InvalidResult, + _cmdletPassedIn)); + return false; + } + + _cmdletPassedIn.WriteVerbose("End of dependency processing"); + } + catch (Exception e) { - return true; + throw new ProcessDependencyException("Error processing dependencies: " + e.Message); } + finally + { + if (Directory.Exists(tempPath)) + { + // Delete the temp directory and all its contents + _cmdletPassedIn.WriteVerbose($"Attempting to delete '{tempPath}'"); + Utils.DeleteDirectoryWithRestore(tempPath); + } + } + + return true; + } + + private bool TryCreateAndUploadDependencyJson(string tempPath, + Hashtable dependencies, string pkgNameLower, string acrAccessToken, out string depJsonContent, out string depDigest, out long depFileSize, out string depFileName) + { + depFileName = "dependency.json"; + var depFilePath = System.IO.Path.Combine(tempPath, depFileName); + Utils.CreateFile(depFilePath); + FileInfo depFile = new FileInfo(depFilePath); + + depJsonContent = CreateDependencyJsonContent(dependencies); + File.WriteAllText(depFilePath, depJsonContent); + depFileSize = depFile.Length; - return false; + bool depDigestCreated = CreateDigest(depFilePath, out depDigest, out ErrorRecord depDigestError); + if (depDigestError != null) + { + _cmdletPassedIn.ThrowTerminatingError(depDigestError); + } + + // Upload dependency.json + var depLocation = GetStartUploadBlobLocation(pkgNameLower, acrAccessToken).Result; + var depFileResponse = EndUploadBlob(depLocation, depFilePath, depDigest, isManifest: false, acrAccessToken).Result; + + return depFileResponse.IsSuccessStatusCode; } - private string CreateJsonContent( + private bool TryCreateAndUploadEmptyTxtFile(string tempPath, string pkgNameLower, string acrAccessToken) + { + _cmdletPassedIn.WriteVerbose("Create an empty artifact file"); + string emptyArtifactFileName = "artifactEmpty.txt"; + var emptyArtifactFilePath = System.IO.Path.Combine(tempPath, emptyArtifactFileName); + // Rename the empty file in case such a file already exists in the temp folder (although highly unlikely) + while (File.Exists(emptyArtifactFilePath)) + { + emptyArtifactFilePath = Guid.NewGuid().ToString() + ".txt"; + } + Utils.CreateFile(emptyArtifactFilePath); + + _cmdletPassedIn.WriteVerbose("Start uploading an empty artifact file"); + var emptyArtifactLocation = GetStartUploadBlobLocation(pkgNameLower, acrAccessToken).Result; + _cmdletPassedIn.WriteVerbose("Computing digest for empty file"); + bool emptyArtifactDigestCreated = CreateDigest(emptyArtifactFilePath, out string emptyArtifactDigest, out ErrorRecord emptyArtifactDigestError); + if (!emptyArtifactDigestCreated) + { + _cmdletPassedIn.ThrowTerminatingError(emptyArtifactDigestError); + } + _cmdletPassedIn.WriteVerbose("Finish uploading empty file"); + var emptyArtifactResponse = EndUploadBlob(emptyArtifactLocation, emptyArtifactFilePath, emptyArtifactDigest, false, acrAccessToken).Result; + + return emptyArtifactResponse.IsSuccessStatusCode; + } + + private bool TryCreateDependencyConfigFile(string depConfigFilePath, HttpResponseMessage manifestResponse, string depJsonContent, string depDigest, long depFileSize, + string depFileName, out string artifactDigest) + { + artifactDigest = string.Empty; + _cmdletPassedIn.WriteVerbose("Create the dependency config file"); + Utils.CreateFile(depConfigFilePath); + + _cmdletPassedIn.WriteVerbose("Computing digest for artifact config"); + bool depConfigDigestCreated = CreateDigest(depConfigFilePath, out string emptyConfigArtifactDigest, out ErrorRecord depConfigDigestError); + if (!depConfigDigestCreated) + { + _cmdletPassedIn.ThrowTerminatingError(depConfigDigestError); + } + FileInfo depConfigFile = new FileInfo(depConfigFilePath); + + + // Can either get the digest/size through response from pushing parent manifest earlier, + string[] parentLocation = manifestResponse.Headers.Location.OriginalString.Split(':'); + if (parentLocation == null && parentLocation.Length < 2) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException("Error creating dependency manifest. Parent manifest location is invalid."), + "DependencyManifestCreationError", + ErrorCategory.InvalidResult, + _cmdletPassedIn)); + return false; + } + + string parentDigest = parentLocation[1]; + var contentLength = manifestResponse.RequestMessage.Content.Headers.ContentLength; + if (contentLength == null) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException("Error creating dependency manifest. Parent manifest size is invalid."), + "DependencyManifestCreationError", + ErrorCategory.InvalidResult, + _cmdletPassedIn)); + } + long parentSize = (long)manifestResponse.RequestMessage.Content.Headers.ContentLength; + + // Create manifest for dependencies + var depJsonStr = depJsonContent.Replace("\r", String.Empty).Replace("\n", String.Empty); + string depFileContent = CreateDependencyManifestContent(emptyConfigArtifactDigest, 0, depDigest, depFileSize, depFileName, depJsonStr, parentDigest, parentSize); + File.WriteAllText(depConfigFilePath, depFileContent); + + var depManifestDigestCreated = CreateDigest(depConfigFilePath, out artifactDigest, out ErrorRecord artifactDigestError); + if (!depManifestDigestCreated) + { + _cmdletPassedIn.ThrowTerminatingError(artifactDigestError); + } + + return depManifestDigestCreated; + } + + + private string CreateManifestContent( string nupkgDigest, string configDigest, long nupkgFileSize, string fileName, string packageName, ResourceType resourceType, - string jsonString) + string metadata) { StringBuilder stringBuilder = new StringBuilder(); StringWriter stringWriter = new StringWriter(stringBuilder); @@ -1250,11 +1479,13 @@ private string CreateJsonContent( jsonWriter.WritePropertyName("schemaVersion"); jsonWriter.WriteValue(2); + jsonWriter.WritePropertyName("mediaType"); + jsonWriter.WriteValue("application/vnd.oci.image.manifest.v1+json"); jsonWriter.WritePropertyName("config"); jsonWriter.WriteStartObject(); jsonWriter.WritePropertyName("mediaType"); - jsonWriter.WriteValue("application/vnd.unknown.config.v1+json"); + jsonWriter.WriteValue("application/vnd.oci.image.config.v1+json"); jsonWriter.WritePropertyName("digest"); jsonWriter.WriteValue($"sha256:{configDigest}"); jsonWriter.WritePropertyName("size"); @@ -1266,7 +1497,7 @@ private string CreateJsonContent( jsonWriter.WriteStartObject(); jsonWriter.WritePropertyName("mediaType"); - jsonWriter.WriteValue("application/vnd.oci.image.layer.nondistributable.v1.tar+gzip"); + jsonWriter.WriteValue("application/vnd.oci.image.layer.v1.tar+gzip"); jsonWriter.WritePropertyName("digest"); jsonWriter.WriteValue($"sha256:{nupkgDigest}"); jsonWriter.WritePropertyName("size"); @@ -1278,7 +1509,7 @@ private string CreateJsonContent( jsonWriter.WritePropertyName("org.opencontainers.image.description"); jsonWriter.WriteValue(fileName); jsonWriter.WritePropertyName("metadata"); - jsonWriter.WriteValue(jsonString); + jsonWriter.WriteValue(metadata); jsonWriter.WritePropertyName("artifactType"); jsonWriter.WriteValue(resourceType.ToString()); jsonWriter.WriteEndObject(); // end of annotations object @@ -1291,11 +1522,103 @@ private string CreateJsonContent( return stringWriter.ToString(); } - // ACR method + private string CreateDependencyManifestContent( + string depConfigDigest, + long depConfigFileSize, + string depDigest, + long depFileSize, + string depFileName, + string dependenciesStr, + string parentDigest, + long parentSize) + { + StringBuilder stringBuilder = new StringBuilder(); + StringWriter stringWriter = new StringWriter(stringBuilder); + JsonTextWriter jsonWriter = new JsonTextWriter(stringWriter); + + jsonWriter.Formatting = Newtonsoft.Json.Formatting.Indented; + + jsonWriter.WriteStartObject(); + + jsonWriter.WritePropertyName("schemaVersion"); + jsonWriter.WriteValue(2); + jsonWriter.WritePropertyName("mediaType"); + jsonWriter.WriteValue("application/vnd.oci.image.manifest.v1+json"); + + jsonWriter.WritePropertyName("config"); + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("mediaType"); + jsonWriter.WriteValue("dependency"); + jsonWriter.WritePropertyName("digest"); + jsonWriter.WriteValue($"sha256:{depConfigDigest}"); + jsonWriter.WritePropertyName("size"); + jsonWriter.WriteValue(depConfigFileSize); + jsonWriter.WriteEndObject(); + + jsonWriter.WritePropertyName("layers"); + jsonWriter.WriteStartArray(); + + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("mediaType"); + jsonWriter.WriteValue("application/vnd.oci.image.layer.v1.tar"); + jsonWriter.WritePropertyName("digest"); + jsonWriter.WriteValue($"sha256:{depDigest}"); + jsonWriter.WritePropertyName("size"); + jsonWriter.WriteValue(depFileSize); + jsonWriter.WritePropertyName("annotations"); + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("org.opencontainers.image.title"); + jsonWriter.WriteValue(depFileName);; + jsonWriter.WritePropertyName("dependencies"); + jsonWriter.WriteValue(dependenciesStr); + jsonWriter.WriteEndObject(); + + jsonWriter.WriteEndObject(); + + jsonWriter.WriteEndArray(); + + + jsonWriter.WritePropertyName("subject"); + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("mediaType"); + jsonWriter.WriteValue("application/vnd.oci.image.manifest.v1+json"); + jsonWriter.WritePropertyName("digest"); + jsonWriter.WriteValue($"sha256:{parentDigest}"); + jsonWriter.WritePropertyName("size"); + jsonWriter.WriteValue(parentSize); + jsonWriter.WriteEndObject(); + + jsonWriter.WriteEndObject(); + + return stringWriter.ToString(); + } + + private string CreateDependencyJsonContent(Hashtable dependencies) + { + StringBuilder stringBuilder = new StringBuilder(); + StringWriter stringWriter = new StringWriter(stringBuilder); + JsonTextWriter jsonWriter = new JsonTextWriter(stringWriter); + + jsonWriter.Formatting = Newtonsoft.Json.Formatting.Indented; + + jsonWriter.WriteStartObject(); + + foreach (string dependencyName in dependencies.Keys) + { + jsonWriter.WritePropertyName(dependencyName); + jsonWriter.WriteValue(dependencies[dependencyName]); + } + + jsonWriter.WriteEndObject(); + + return stringWriter.ToString(); + } + private bool CreateDigest(string fileName, out string digest, out ErrorRecord error) { FileInfo fileInfo = new FileInfo(fileName); SHA256 mySHA256 = SHA256.Create(); + using (FileStream fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read)) { digest = string.Empty; @@ -1312,7 +1635,51 @@ private bool CreateDigest(string fileName, out string digest, out ErrorRecord er stringBuilder.AppendFormat("{0:x2}", b); digest = stringBuilder.ToString(); // Write the name and hash value of the file to the console. - _cmdletPassedIn.WriteVerbose($"{fileInfo.Name}: {hashValue}"); + _cmdletPassedIn.WriteVerbose($"{fileInfo.Name}: {digest}"); + error = null; + } + catch (IOException ex) + { + var IOError = new ErrorRecord(ex, $"IOException for .nupkg file: {ex.Message}", ErrorCategory.InvalidOperation, null); + error = IOError; + } + catch (UnauthorizedAccessException ex) + { + var AuthorizationError = new ErrorRecord(ex, $"UnauthorizedAccessException for .nupkg file: {ex.Message}", ErrorCategory.PermissionDenied, null); + error = AuthorizationError; + } + } + if (error != null) + { + return false; + } + + return true; + } + + private bool CreateDependencyDigest(out string digest, out ErrorRecord error) + { + SHA256 mySHA256 = SHA256.Create(); + string myGuid = new Guid().ToString(); + byte[] byteArray = Encoding.UTF8.GetBytes(myGuid); + + using (MemoryStream memStream = new MemoryStream(byteArray)) + { + digest = string.Empty; + + try + { + // Create a MemoryStream for the Guid. + // Be sure it's positioned to the beginning of the stream. + memStream.Position = 0; + // Compute the hash of the MemoryStream. + byte[] hashValue = mySHA256.ComputeHash(memStream); + StringBuilder stringBuilder = new StringBuilder(); + foreach (byte b in hashValue) + stringBuilder.AppendFormat("{0:x2}", b); + digest = stringBuilder.ToString(); + // Write the name and hash value of the file to the console. + _cmdletPassedIn.WriteVerbose($"dependency digest: {digest}"); error = null; } catch (IOException ex) @@ -1330,6 +1697,7 @@ private bool CreateDigest(string fileName, out string digest, out ErrorRecord er { return false; } + return true; } diff --git a/src/code/PSGetException.cs b/src/code/PSGetException.cs index cbfa57833..6b37c42e7 100644 --- a/src/code/PSGetException.cs +++ b/src/code/PSGetException.cs @@ -68,4 +68,12 @@ public LocalResourceNotFoundException(string message, Exception innerException = { } } + + public class ProcessDependencyException : Exception + { + public ProcessDependencyException(string message, Exception innerException = null) + : base(message) + { + } + } } diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index ce65916f8..8f96973a7 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -499,7 +499,7 @@ out string[] _ ACRServerAPICalls acrServer = new ACRServerAPICalls(repository, this, _networkCredential, userAgentString); var pkgMetadataFile = (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish; - if (!acrServer.PushNupkgACR(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, repository, resourceType, parsedMetadata, out ErrorRecord pushNupkgACRError)) + if (!acrServer.PushNupkgACR(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, repository, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgACRError)) { WriteError(pushNupkgACRError); // exit out of processing diff --git a/src/code/Utils.cs b/src/code/Utils.cs index d94aa1541..f3df60e7c 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1724,6 +1724,26 @@ private static void RestoreDirContents( } } + public static void CreateFile(string filePath) + { + FileStream fileStream = null; + try + { + fileStream = File.Create(filePath); + } + catch (Exception e) + { + throw new Exception($"Error creating file '{filePath}': {e.Message}"); + } + finally + { + if (fileStream != null) + { + fileStream.Close(); + } + } + } + #endregion } diff --git a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 index b3d717412..aa571a809 100644 --- a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 @@ -256,8 +256,50 @@ Describe "Test Publish-PSResource" -tags 'CI' { Test-Path $expectedPath | Should -Be $true } - It "Publish a module and clean up properly when file in module is readonly" { + It "Publish a module with one dependency" { $version = "11.0.0" + $dependencyName = 'test_module' + $dependencyVersion = '9.0.0' + + # New-ModuleManifest requires that the module be installed before it can be added as a dependency + Install-PSResource -Name $dependencyName -Version $dependencyVersion -Repository $ACRRepoName -TrustRepository -Verbose + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -RequiredModules @(@{ ModuleName = $dependencyName; ModuleVersion = $dependencyVersion }) + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + # TODO: Uncomment when Find-PSResource returns dependencies + #$results[0].Dependencies.Name | Should -Be $dependencyName + #$results[0].Dependencies.VersionRange | Should -Be $dependencyVersion + } + + It "Publish a module with multiple dependencies" { + $version = "12.0.0" + $dependency1Name = 'testdep' + $dependency2Name = 'test_local_mod' + $dependency2Version = '5.0.0' + + # New-ModuleManifest requires that the module be installed before it can be added as a dependency + Install-PSResource -Name $dependency1Name -Repository $ACRRepoName -TrustRepository -Verbose + Install-PSResource -Name $dependency2Name -Version $dependency2Version -Repository $ACRRepoName -TrustRepository -verbose + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -RequiredModules @( $dependency1Name , @{ ModuleName = $dependency2Name; ModuleVersion = $dependency2Version }) + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + # TODO: Uncomment when Find-PSResource returns dependencies + #$results[0].Dependencies.Name | Should -Be $dependency1Name, $dependency2Name + #$results[0].Dependencies.VersionRange | Should -Be $dependency2Version + } + + It "Publish a module and clean up properly when file in module is readonly" { + $version = "13.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" # Create a readonly file that will throw access denied error if deletion is attempted @@ -275,7 +317,7 @@ Describe "Test Publish-PSResource" -tags 'CI' { It "Publish a module when the .psd1 version and the path version are different" { $incorrectVersion = "15.2.4" - $correctVersion = "12.0.0" + $correctVersion = "14.0.0" $versionBase = (Join-Path -Path $script:PublishModuleBase -ChildPath $incorrectVersion) New-Item -Path $versionBase -ItemType Directory -Force $modManifestPath = (Join-Path -Path $versionBase -ChildPath "$script:PublishModuleName.psd1") From ed6da243936793d4b5c2c68b78ecf1e52f460062 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Tue, 5 Mar 2024 17:55:52 -0800 Subject: [PATCH 054/160] Add acquiring ACR access token using Azure.Identity and allow unauthenticated ACR endpoints (#1576) --- .ci/ci.yml | 7 + .ci/test.yml | 32 ++- buildtools.psd1 | 26 +- buildtools.psm1 | 30 ++- doBuild.ps1 | 19 +- src/code/ACRServerAPICalls.cs | 228 +++++++----------- .../Microsoft.PowerShell.PSResourceGet.csproj | 1 + src/code/ModuleInitAndCleanup.cs | 18 +- src/code/RepositorySettings.cs | 24 +- src/code/Utils.cs | 24 ++ .../FindPSResourceACRServer.Tests.ps1 | 15 +- .../InstallPSResourceACRServer.Tests.ps1 | 36 ++- .../PublishPSResourceACRServer.Tests.ps1 | 116 +++++---- 13 files changed, 333 insertions(+), 243 deletions(-) diff --git a/.ci/ci.yml b/.ci/ci.yml index 8d6c431ac..031b42ab3 100644 --- a/.ci/ci.yml +++ b/.ci/ci.yml @@ -137,3 +137,10 @@ stages: jobName: TestPkgWinMacOS displayName: PowerShell Core on macOS imageName: macOS-latest + + - template: test.yml + parameters: + jobName: TestPkgWinAzAuth + displayName: AzAuth PowerShell Core on Windows + imageName: windows-latest + useAzAuth: true diff --git a/.ci/test.yml b/.ci/test.yml index 13be97783..b527e0617 100644 --- a/.ci/test.yml +++ b/.ci/test.yml @@ -4,6 +4,7 @@ parameters: displayName: PowerShell Core on Windows powershellExecutable: pwsh buildDirectory: '.' + useAzAuth: false jobs: - job: ${{ parameters.jobName }} @@ -18,6 +19,7 @@ jobs: Set-SecretStoreConfiguration -Authentication None -Interaction None -Confirm:$false -Password $vaultPassword Register-SecretVault -Name SecretStore -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault displayName: Install Secret store + condition: eq(${{ parameters.useAzAuth }}, false) - task: DownloadBuildArtifacts@0 displayName: 'Download artifacts' @@ -78,6 +80,34 @@ jobs: Write-Host "sending " + $vstsCommandString Write-Host "##$vstsCommandString" displayName: 'Setup Azure Container Registry secret' + condition: eq(${{ parameters.useAzAuth }}, false) + + - powershell: | + # Set environment variable to identify in tests that secret store should not be used. + $vstsCommandString = "vso[task.setvariable variable=UsingAzAuth]true" + Write-Host "sending " + $vstsCommandString + Write-Host "##$vstsCommandString" + displayName: 'Set UsingAzAuth environment variable' + condition: eq(${{ parameters.useAzAuth }}, true) + + - task: AzurePowerShell@5 + inputs: + azureSubscription: PSResourceGetACR + azurePowerShellVersion: LatestVersion + ScriptType: InlineScript + inline: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath + Write-Verbose -Verbose "Importing build utilities (buildtools.psd1)" + Import-Module -Name (Join-Path -Path '${{ parameters.buildDirectory }}' -ChildPath 'buildtools.psd1') -Force + Invoke-ModuleTestsACR -Type Functional + env: + MAPPED_GITHUB_PAT: $(github_pat) + MAPPED_ADO_PUBLIC_PAT: $(ado_public_pat) + MAPPED_ADO_PRIVATE_PAT: $(ado_private_pat) + MAPPED_ADO_PRIVATE_REPO_URL: $(ado_private_repo_url) + displayName: 'Execute functional tests with AzAuth' + condition: eq(${{ parameters.useAzAuth }}, true) - ${{ parameters.powershellExecutable }}: | $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' @@ -93,4 +123,4 @@ jobs: displayName: Execute functional tests workingDirectory: ${{ parameters.buildDirectory }} errorActionPreference: continue - condition: succeededOrFailed() + condition: eq(${{ parameters.useAzAuth }}, false) diff --git a/buildtools.psd1 b/buildtools.psd1 index 6542e455e..dffba0919 100644 --- a/buildtools.psd1 +++ b/buildtools.psd1 @@ -4,25 +4,25 @@ @{ # Script module or binary module file associated with this manifest. RootModule = '.\buildtools.psm1' - + # Version number of this module. ModuleVersion = '1.0.0' - + # Supported PSEditions CompatiblePSEditions = @('Core') - + # ID used to uniquely identify this module GUID = 'fcdd259e-1163-4da2-8bfa-ce36a839f337' - + # Author of this module Author = 'Microsoft Corporation' - + # Company or vendor of this module CompanyName = 'Microsoft Corporation' - + # Copyright statement for this module Copyright = '(c) Microsoft Corporation. All rights reserved.' - + # Description of the functionality provided by this module Description = "Build utilties." @@ -32,20 +32,20 @@ # @{ ModuleName = 'Pester'; ModuleVersion = '4.8.1' }, # @{ ModuleName = 'PSScriptAnalyzer'; ModuleVersion = '1.18.0' } #) - + # Minimum version of the PowerShell engine required by this module PowerShellVersion = '5.1' - + # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. CmdletsToExport = @() - + # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. FunctionsToExport = @( - 'Get-BuildConfiguration', 'Invoke-ModuleBuild', 'Publish-ModulePackage', 'Install-ModulePackageForTest', 'Invoke-ModuleTests') - + 'Get-BuildConfiguration', 'Invoke-ModuleBuild', 'Publish-ModulePackage', 'Install-ModulePackageForTest', 'Invoke-ModuleTests', 'Invoke-ModuleTestsACR') + # Variables to export from this module VariablesToExport = '*' - + # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. AliasesToExport = @() } diff --git a/buildtools.psm1 b/buildtools.psm1 index 9ad5ed352..32cceec4d 100644 --- a/buildtools.psm1 +++ b/buildtools.psm1 @@ -126,11 +126,29 @@ function Install-ModulePackageForTest { Unregister-PSResourceRepository -Name $localRepoName -Confirm:$false } +function Invoke-ModuleTestsACR { + [CmdletBinding()] + param ( + [ValidateSet("Functional", "StaticAnalysis")] + [string[]] $Type = "Functional" + ) + + $acrTestFiles = @( + "FindPSResourceTests/FindPSResourceACRServer.Tests.ps1", + "InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1", + "PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1" + ) + + Invoke-ModuleTests -Type $Type -TestFilePath $acrTestFiles +} + + function Invoke-ModuleTests { [CmdletBinding()] param ( [ValidateSet("Functional", "StaticAnalysis")] - [string[]] $Type = "Functional" + [string[]] $Type = "Functional", + [string[]] $TestFilePath = "." ) Write-Verbose -Verbose -Message "Starting module Pester tests..." @@ -143,7 +161,15 @@ function Invoke-ModuleTests { $testPath = $config.TestPath Write-Verbose -Verbose $config.ModuleName $moduleToTest = Join-Path -Path $config.BuildOutputPath -ChildPath "Microsoft.PowerShell.PSResourceGet" - $command = "Import-Module -Name ${moduleToTest} -Force -Verbose; Set-Location -Path ${testPath}; Invoke-Pester -Path . -OutputFile ${testResultFileName} -Tags '${tags}' -ExcludeTag '${excludeTag}'" + + if ($TestFilePath.Count -gt 1) { + $TestFilePathJoined = $TestFilePath -join ',' + } + else { + $TestFilePathJoined = $TestFilePath + } + + $command = "Import-Module -Name ${moduleToTest} -Force -Verbose; Set-Location -Path ${testPath}; Invoke-Pester -Script ${TestFilePathJoined} -OutputFile ${testResultFileName} -Tags '${tags}' -ExcludeTag '${excludeTag}'" $pwshExePath = (Get-Process -Id $pid).Path Write-Verbose -Verbose -Message "Running Pester tests with command: $command using pwsh.exe path: $pwshExePath" diff --git a/doBuild.ps1 b/doBuild.ps1 index b62e1f74d..6eab1d609 100644 --- a/doBuild.ps1 +++ b/doBuild.ps1 @@ -76,6 +76,16 @@ function DoBuild ) $depAssemblyNames = @( + 'Azure.Core' + 'Azure.Identity' + 'Microsoft.Bcl.AsyncInterfaces' + 'Microsoft.Extensions.FileProviders.Abstractions' + 'Microsoft.Extensions.FileSystemGlobbing' + 'Microsoft.Extensions.Primitives' + 'Microsoft.Identity.Client' + 'Microsoft.Identity.Client.Extensions.Msal' + 'Microsoft.IdentityModel.Abstractions' + 'Newtonsoft.Json' 'NuGet.Commands' 'NuGet.Common' 'NuGet.Configuration' @@ -87,16 +97,19 @@ function DoBuild 'NuGet.ProjectModel' 'NuGet.Protocol' 'NuGet.Versioning' - 'Newtonsoft.Json' - 'System.Text.Json' 'System.Buffers' + 'System.Diagnostics.DiagnosticSource' + 'System.IO.FileSystem.AccessControl' + 'System.Memory.Data' 'System.Memory' 'System.Numerics.Vectors' 'System.Runtime.CompilerServices.Unsafe' + 'System.Security.AccessControl' + 'System.Security.Cryptography.ProtectedData' + 'System.Security.Principal.Windows' 'System.Text.Encodings.Web' 'System.Text.Json' 'System.Threading.Tasks.Extensions' - 'Microsoft.Bcl.AsyncInterfaces' 'System.ValueTuple' ) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 20aeec6fe..04cc6fa37 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -137,38 +137,13 @@ public override FindResults FindName(string packageName, bool includePrerelease, string tenantID = string.Empty; string packageNameLowercase = packageName.ToLower(); - // Need to set up secret management vault before hand - var repositoryCredentialInfo = Repository.CredentialInfo; - if (repositoryCredentialInfo != null) - { - accessToken = Utils.GetACRAccessTokenFromSecretManagement( - Repository.Name, - repositoryCredentialInfo, - _cmdletPassedIn); - - _cmdletPassedIn.WriteVerbose("Access token retrieved."); - - tenantID = repositoryCredentialInfo.SecretName; - _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); - } - - // Call asynchronous network methods in a try/catch block to handle exceptions. - string registry = Repository.Uri.Host; - - _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); - if (errRecord != null) - { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } - - _cmdletPassedIn.WriteVerbose("Getting acr access token"); - var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + string acrAccessToken = GetAcrAccessToken(Repository, out errRecord); if (errRecord != null) { return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } + string registry = Repository.Uri.Host; _cmdletPassedIn.WriteVerbose("Getting tags"); var foundTags = FindAcrImageTags(registry, packageNameLowercase, "*", acrAccessToken, out errRecord); if (errRecord != null || foundTags == null) @@ -299,37 +274,14 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange string tenantID = string.Empty; string packageNameLowercase = packageName.ToLower(); - // Need to set up secret management vault beforehand - var repositoryCredentialInfo = Repository.CredentialInfo; - if (repositoryCredentialInfo != null) - { - accessToken = Utils.GetACRAccessTokenFromSecretManagement( - Repository.Name, - repositoryCredentialInfo, - _cmdletPassedIn); - - _cmdletPassedIn.WriteVerbose("Access token retrieved."); - - tenantID = repositoryCredentialInfo.SecretName; - _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); - } - - string registry = Repository.Uri.Host; - - _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); - if (errRecord != null) - { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } - - _cmdletPassedIn.WriteVerbose("Getting acr access token"); - var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + string registryUrl = Repository.Uri.ToString(); + string acrAccessToken = GetAcrAccessToken(Repository, out errRecord); if (errRecord != null) { return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } + string registry = Repository.Uri.Host; _cmdletPassedIn.WriteVerbose("Getting tags"); var foundTags = FindAcrImageTags(registry, packageNameLowercase, "*", acrAccessToken, out errRecord); if (errRecord != null || foundTags == null) @@ -405,39 +357,16 @@ public override FindResults FindVersion(string packageName, string version, Reso string accessToken = string.Empty; string tenantID = string.Empty; + string registryUrl = Repository.Uri.ToString(); - // Need to set up secret management vault beforehand - var repositoryCredentialInfo = Repository.CredentialInfo; - if (repositoryCredentialInfo != null) - { - accessToken = Utils.GetACRAccessTokenFromSecretManagement( - Repository.Name, - repositoryCredentialInfo, - _cmdletPassedIn); - - _cmdletPassedIn.WriteVerbose("Access token retrieved."); - - tenantID = repositoryCredentialInfo.SecretName; - _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); - } - - string registry = Repository.Uri.Host; - - _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); - if (errRecord != null) - { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } - - _cmdletPassedIn.WriteVerbose("Getting acr access token"); - var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + string acrAccessToken = GetAcrAccessToken(Repository, out errRecord); if (errRecord != null) { return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } - _cmdletPassedIn.WriteVerbose("Getting ACR metadata"); + string registry = Repository.Uri.Host; + _cmdletPassedIn.WriteVerbose("Getting tags"); List results = new List { GetACRMetadata(registry, packageName, requiredVersion, acrAccessToken, out errRecord) @@ -509,37 +438,14 @@ private Stream InstallVersion( string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(tempPath); - var repositoryCredentialInfo = Repository.CredentialInfo; - if (repositoryCredentialInfo != null) - { - accessToken = Utils.GetACRAccessTokenFromSecretManagement( - Repository.Name, - repositoryCredentialInfo, - _cmdletPassedIn); - - _cmdletPassedIn.WriteVerbose("Access token retrieved."); - - tenantID = repositoryCredentialInfo.SecretName; - _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); - } - - // Call asynchronous network methods in a try/catch block to handle exceptions. - string registry = Repository.Uri.Host; - - _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); - if (errRecord != null) - { - return null; - } - - _cmdletPassedIn.WriteVerbose("Getting acr access token"); - var acrAccessToken = GetAcrAccessToken(registry, acrRefreshToken, out errRecord); + string registryUrl = Repository.Uri.ToString(); + string acrAccessToken = GetAcrAccessToken(Repository, out errRecord); if (errRecord != null) { return null; } + string registry = Repository.Uri.Host; _cmdletPassedIn.WriteVerbose($"Getting manifest for {moduleName} - {moduleVersion}"); var manifest = GetAcrRepositoryManifestAsync(registry, moduleName, moduleVersion, acrAccessToken, out errRecord); if (errRecord != null) @@ -603,12 +509,14 @@ private string GetDigestFromManifest(JObject manifest, out ErrorRecord errRecord return digest; } - internal string GetACRRegistryToken(out ErrorRecord errRecord) + // access token can be empty if the repository is unauthenticated + internal string GetAcrAccessToken(PSRepositoryInfo repositoryInfo, out ErrorRecord errRecord) { string accessToken = string.Empty; + string acrAccessToken = string.Empty; string tenantID = string.Empty; + errRecord = null; - // Need to set up secret management vault before hand var repositoryCredentialInfo = Repository.CredentialInfo; if (repositoryCredentialInfo != null) { @@ -622,14 +530,48 @@ internal string GetACRRegistryToken(out ErrorRecord errRecord) tenantID = repositoryCredentialInfo.SecretName; _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); } + else + { + bool isRepositoryUnauthenticated = IsContainerRegistryUnauthenticated(repositoryInfo.Uri.ToString()); + + if (!isRepositoryUnauthenticated) + { + accessToken = Utils.GetAzAccessToken(); + if (string.IsNullOrEmpty(accessToken)) + { + errRecord = new ErrorRecord( + new InvalidOperationException("Failed to get access token from Azure."), + "AzAccessTokenFailure", + ErrorCategory.AuthenticationError, + this); + + return null; + } + } + } - // Call asynchronous network methods in a try/catch block to handle exceptions. + string registry = repositoryInfo.Uri.Host; - _cmdletPassedIn.WriteVerbose("Getting acr refresh token"); - var acrRefreshToken = GetAcrRefreshToken(Registry, tenantID, accessToken, out errRecord); - _cmdletPassedIn.WriteVerbose("Getting acr access token"); - - return GetAcrAccessToken(Registry, acrRefreshToken, out errRecord); + var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); + if (errRecord != null) + { + return null; + } + + acrAccessToken = GetAcrAccessTokenByRefreshToken(registry, acrRefreshToken, out errRecord); + if (errRecord != null) + { + return null; + } + + return acrAccessToken; + } + + internal bool IsContainerRegistryUnauthenticated(string registryUrl) + { + string endpoint = $"{registryUrl}/v2/"; + var response = s_client.SendAsync(new HttpRequestMessage(HttpMethod.Head, endpoint)).Result; + return (response.StatusCode == HttpStatusCode.OK); } internal string GetAcrRefreshToken(string registry, string tenant, string accessToken, out ErrorRecord errRecord) @@ -638,7 +580,7 @@ internal string GetAcrRefreshToken(string registry, string tenant, string access var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; string exchangeUrl = string.Format(acrOAuthExchangeUrlTemplate, registry); var results = GetHttpResponseJObjectUsingContentHeaders(exchangeUrl, HttpMethod.Post, content, contentHeaders, out errRecord); - + if (results != null && results["refresh_token"] != null) { return results["refresh_token"].ToString(); @@ -647,7 +589,7 @@ internal string GetAcrRefreshToken(string registry, string tenant, string access return string.Empty; } - internal string GetAcrAccessToken(string registry, string refreshToken, out ErrorRecord errRecord) + internal string GetAcrAccessTokenByRefreshToken(string registry, string refreshToken, out ErrorRecord errRecord) { string content = string.Format(acrAccessTokenTemplate, registry, refreshToken); var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; @@ -655,7 +597,7 @@ internal string GetAcrAccessToken(string registry, string refreshToken, out Erro var results = GetHttpResponseJObjectUsingContentHeaders(tokenUrl, HttpMethod.Post, content, contentHeaders, out errRecord); if (results != null && results["access_token"] != null) - { + { return results["access_token"].ToString(); } @@ -798,7 +740,7 @@ internal Tuple GetMetadataProperty(JObject foundTags, string pack if (layers == null || layers[0] == null) { exception = new InvalidOrEmptyResponse($"Response does not contain 'layers' element in manifest for package '{packageName}' in '{Repository.Name}'."); - + return emptyTuple; } @@ -806,14 +748,14 @@ internal Tuple GetMetadataProperty(JObject foundTags, string pack if (annotations == null) { exception = new InvalidOrEmptyResponse($"Response does not contain 'annotations' element in manifest for package '{packageName}' in '{Repository.Name}'."); - + return emptyTuple; } if (annotations["metadata"] == null) { exception = new InvalidOrEmptyResponse($"Response does not contain 'metadata' element in manifest for package '{packageName}' in '{Repository.Name}'."); - + return emptyTuple; } @@ -823,7 +765,7 @@ internal Tuple GetMetadataProperty(JObject foundTags, string pack if (metadataPkgTitleJToken == null) { exception = new InvalidOrEmptyResponse($"Response does not contain 'org.opencontainers.image.title' element for package '{packageName}' in '{Repository.Name}'."); - + return emptyTuple; } @@ -831,7 +773,7 @@ internal Tuple GetMetadataProperty(JObject foundTags, string pack if (string.IsNullOrWhiteSpace(metadataPkgName)) { exception = new InvalidOrEmptyResponse($"Response element 'org.opencontainers.image.title' is empty for package '{packageName}' in '{Repository.Name}'."); - + return emptyTuple; } @@ -1156,10 +1098,16 @@ private static async Task PutRequestAsync(string url, strin private static Collection> GetDefaultHeaders(string acrAccessToken) { - return new Collection> { - new KeyValuePair("Authorization", acrAccessToken), - new KeyValuePair("Accept", "application/vnd.oci.image.manifest.v1+json") - }; + var defaultHeaders = new Collection>(); + + if (!string.IsNullOrEmpty(acrAccessToken)) + { + defaultHeaders.Add(new KeyValuePair("Authorization", acrAccessToken)); + } + + defaultHeaders.Add(new KeyValuePair("Accept", "application/vnd.oci.image.manifest.v1+json")); + + return defaultHeaders; } internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, ResourceType resourceType, Hashtable parsedMetadataHash, Hashtable dependencies, out ErrorRecord errRecord) @@ -1168,7 +1116,7 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p string pkgNameLower = pkgName.ToLower(); // Get access token (includes refresh tokens) - var acrAccessToken = GetACRRegistryToken(out errRecord); + var acrAccessToken = GetAcrAccessToken(Repository, out errRecord); // Upload .nupkg TryUploadNupkg(pkgNameLower, acrAccessToken, fullNupkgFile, out string nupkgDigest); @@ -1188,8 +1136,8 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p _cmdletPassedIn.ThrowTerminatingError(metadataCreationError); } - // Create and upload manifest - TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, pkgName, resourceType, metadataJson, configFilePath, + // Create and upload manifest + TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, pkgName, resourceType, metadataJson, configFilePath, pkgNameLower, pkgVersion, acrAccessToken, out HttpResponseMessage manifestResponse); // After manifest is created, see if there are any dependencies that need to be tracked on the server @@ -1254,7 +1202,7 @@ private bool TryCreateConfig(string configFilePath, out string configDigest) configFilePath = Guid.NewGuid().ToString() + ".json"; } Utils.CreateFile(configFilePath); - + _cmdletPassedIn.WriteVerbose("Computing digest for config"); bool configDigestCreated = CreateDigest(configFilePath, out configDigest, out ErrorRecord configDigestError); if (!configDigestCreated) @@ -1353,7 +1301,7 @@ private bool TryProcessDependencies(Hashtable dependencies, string pkgNameLower, return true; } - private bool TryCreateAndUploadDependencyJson(string tempPath, + private bool TryCreateAndUploadDependencyJson(string tempPath, Hashtable dependencies, string pkgNameLower, string acrAccessToken, out string depJsonContent, out string depDigest, out long depFileSize, out string depFileName) { depFileName = "dependency.json"; @@ -1374,7 +1322,7 @@ private bool TryCreateAndUploadDependencyJson(string tempPath, // Upload dependency.json var depLocation = GetStartUploadBlobLocation(pkgNameLower, acrAccessToken).Result; var depFileResponse = EndUploadBlob(depLocation, depFilePath, depDigest, isManifest: false, acrAccessToken).Result; - + return depFileResponse.IsSuccessStatusCode; } @@ -1400,17 +1348,17 @@ private bool TryCreateAndUploadEmptyTxtFile(string tempPath, string pkgNameLower } _cmdletPassedIn.WriteVerbose("Finish uploading empty file"); var emptyArtifactResponse = EndUploadBlob(emptyArtifactLocation, emptyArtifactFilePath, emptyArtifactDigest, false, acrAccessToken).Result; - + return emptyArtifactResponse.IsSuccessStatusCode; } - private bool TryCreateDependencyConfigFile(string depConfigFilePath, HttpResponseMessage manifestResponse, string depJsonContent, string depDigest, long depFileSize, + private bool TryCreateDependencyConfigFile(string depConfigFilePath, HttpResponseMessage manifestResponse, string depJsonContent, string depDigest, long depFileSize, string depFileName, out string artifactDigest) { artifactDigest = string.Empty; _cmdletPassedIn.WriteVerbose("Create the dependency config file"); Utils.CreateFile(depConfigFilePath); - + _cmdletPassedIn.WriteVerbose("Computing digest for artifact config"); bool depConfigDigestCreated = CreateDigest(depConfigFilePath, out string emptyConfigArtifactDigest, out ErrorRecord depConfigDigestError); if (!depConfigDigestCreated) @@ -1460,9 +1408,9 @@ private bool TryCreateDependencyConfigFile(string depConfigFilePath, HttpRespons private string CreateManifestContent( - string nupkgDigest, - string configDigest, - long nupkgFileSize, + string nupkgDigest, + string configDigest, + long nupkgFileSize, string fileName, string packageName, ResourceType resourceType, @@ -1515,7 +1463,7 @@ private string CreateManifestContent( jsonWriter.WriteEndObject(); // end of annotations object jsonWriter.WriteEndObject(); // end of 'layers' entry object - + jsonWriter.WriteEndArray(); // end of 'layers' array jsonWriter.WriteEndObject(); // end of manifest JSON object @@ -1662,7 +1610,7 @@ private bool CreateDependencyDigest(out string digest, out ErrorRecord error) SHA256 mySHA256 = SHA256.Create(); string myGuid = new Guid().ToString(); byte[] byteArray = Encoding.UTF8.GetBytes(myGuid); - + using (MemoryStream memStream = new MemoryStream(byteArray)) { digest = string.Empty; diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index 8c7caa15b..cdf0feb9d 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -24,6 +24,7 @@ + diff --git a/src/code/ModuleInitAndCleanup.cs b/src/code/ModuleInitAndCleanup.cs index bccae4064..28e83f284 100644 --- a/src/code/ModuleInitAndCleanup.cs +++ b/src/code/ModuleInitAndCleanup.cs @@ -13,6 +13,15 @@ public class UnsafeAssemblyHandler : IModuleAssemblyInitializer, IModuleAssembly private static readonly HashSet s_dependencies; private static readonly AssemblyLoadContextProxy s_proxy; + private static readonly HashSet NetFrameworkLoadFromPath = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "System.Runtime.CompilerServices.Unsafe", + "System.Memory", + "System.Diagnostics.DiagnosticSource", + "System.Text.Json", + "System.Security.Cryptography.ProtectedData" + }; + static UnsafeAssemblyHandler() { s_self = Assembly.GetExecutingAssembly(); @@ -52,13 +61,14 @@ private static bool IsAssemblyMatching(AssemblyName assemblyName, Assembly reque private static Assembly HandleAssemblyResolve(object sender, ResolveEventArgs args) { var requiredAssembly = new AssemblyName(args.Name); + string requiredAssemblyName = requiredAssembly.Name; - // If on .NET framework and requesting assembly is System.Memory load the version dependency folder + // If on .NET framework load specific assemblies from dependency folder if (s_proxy is null - && string.Equals(requiredAssembly.Name, "System.Runtime.CompilerServices.Unsafe")) + && NetFrameworkLoadFromPath.Contains(requiredAssemblyName)) { - var compileServiceDllPath = Path.Combine(s_dependencyFolder, "System.Runtime.CompilerServices.Unsafe.dll"); - return Assembly.LoadFrom(compileServiceDllPath); + var netFxDepDllPath = Path.Combine(s_dependencyFolder, $"{requiredAssemblyName}.dll"); + return Assembly.LoadFrom(netFxDepDllPath); } if (IsAssemblyMatching(requiredAssembly, args.RequestingAssembly)) diff --git a/src/code/RepositorySettings.cs b/src/code/RepositorySettings.cs index b7d4e226e..85119026d 100644 --- a/src/code/RepositorySettings.cs +++ b/src/code/RepositorySettings.cs @@ -313,12 +313,12 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio errorMsg = $"Repository element does not contain neccessary 'Priority' attribute, in file located at path: {FullRepositoryPath}. Fix this in your file and run again."; return null; } - + if (node.Attribute("Trusted") == null) { errorMsg = $"Repository element does not contain neccessary 'Trusted' attribute, in file located at path: {FullRepositoryPath}. Fix this in your file and run again."; return null; - } + } if (node.Attribute("APIVersion") == null) { @@ -343,7 +343,7 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio // determine if existing repository node (which we wish to update) had Url or Uri attribute Uri thisUrl = null; - if (repoUri != null) + if (repoUri != null) { if (urlAttributeExists) { @@ -360,7 +360,7 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio apiVersion = GetRepoAPIVersion(repoUri); } } - else + else { if (urlAttributeExists) { @@ -500,7 +500,7 @@ public static List Remove(string[] repoNames, out string[] err tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Priority' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); continue; } - + if (node.Attribute("Trusted") == null) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Trusted' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); @@ -519,7 +519,7 @@ public static List Remove(string[] repoNames, out string[] err if (!urlAttributeExists && !uriAttributeExists) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Url' or equivalent 'Uri' attribute (it must contain one per Repository), in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); - continue; + continue; } string attributeUrlUriName = urlAttributeExists ? "Url" : "Uri"; @@ -577,7 +577,7 @@ public static List Read(string[] repoNames, out string[] error tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Priority' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); continue; } - + if (repo.Attribute("Trusted") == null) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Trusted' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); @@ -590,7 +590,7 @@ public static List Read(string[] repoNames, out string[] error if (!urlAttributeExists && !uriAttributeExists) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Url' or equivalent 'Uri' attribute (it must contain one), in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); - continue; + continue; } Uri thisUrl = null; @@ -680,7 +680,7 @@ public static List Read(string[] repoNames, out string[] error tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Priority' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); continue; } - + if (node.Attribute("Trusted") == null) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Trusted' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); @@ -695,7 +695,7 @@ public static List Read(string[] repoNames, out string[] error if (!urlAttributeExists && !uriAttributeExists) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Url' or equivalent 'Uri' attribute (it must contain one), in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); - continue; + continue; } Uri thisUrl = null; @@ -780,7 +780,7 @@ public static List Read(string[] repoNames, out string[] error errorList = tempErrorList.ToArray(); // Sort by priority, then by repo name var reposToReturn = foundRepos.OrderBy(x => x.Priority).ThenBy(x => x.Name); - + return reposToReturn.ToList(); } @@ -791,7 +791,7 @@ public static List Read(string[] repoNames, out string[] error private static XElement FindRepositoryElement(XDocument doc, string name) { return doc.Descendants("Repository").Where( - e => e.Attribute("Name") != null && + e => e.Attribute("Name") != null && string.Equals( e.Attribute("Name").Value, name, diff --git a/src/code/Utils.cs b/src/code/Utils.cs index f3df60e7c..b72ad090e 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -18,6 +18,8 @@ using System.Net.Http; using System.Globalization; using System.Security; +using Azure.Core; +using Azure.Identity; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses { @@ -638,6 +640,28 @@ public static PSCredential GetRepositoryCredentialFromSecretManagement( } } + public static string GetAzAccessToken() + { + var credOptions = new DefaultAzureCredentialOptions + { + ExcludeEnvironmentCredential = true, + ExcludeVisualStudioCodeCredential = true, + ExcludeVisualStudioCredential = true, + ExcludeWorkloadIdentityCredential = true, + ExcludeManagedIdentityCredential = false, // ManagedIdentityCredential makes the experience slow + + ExcludeAzureCliCredential = false, + ExcludeAzurePowerShellCredential = false, + ExcludeInteractiveBrowserCredential = false, + ExcludeSharedTokenCacheCredential = false + }; + + var dCred = new DefaultAzureCredential(credOptions); + var tokenRequestContext = new TokenRequestContext(new string[] { "https://management.azure.com/.default" }); + var token = dCred.GetTokenAsync(tokenRequestContext).Result; + return token.Token; + } + public static string GetACRAccessTokenFromSecretManagement( string repositoryName, PSCredentialInfo repositoryCredentialInfo, diff --git a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 index e7b87b4a8..06a7627e2 100644 --- a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 @@ -12,8 +12,19 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $ACRRepoName = "ACRRepo" $ACRRepoUri = "https://psresourcegettest.azurecr.io" Get-NewPSResourceRepositoryFile - $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") - Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + + $usingAzAuth = $env:USINGAZAUTH -eq 'true' + + if ($usingAzAuth) + { + Write-Verbose -Verbose "Using Az module for authentication" + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -Verbose + } + else + { + $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + } } AfterAll { diff --git a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 index 0e4e64ae4..5d330c1db 100644 --- a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 @@ -14,8 +14,18 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { $ACRRepoName = "ACRRepo" $ACRRepoUri = "https://psresourcegettest.azurecr.io/" Get-NewPSResourceRepositoryFile - $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") - Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + + $usingAzAuth = $env:USINGAZAUTH -eq 'true' + + if ($usingAzAuth) + { + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -Verbose + } + else + { + $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + } } AfterEach { @@ -62,7 +72,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { It "Install multiple resources by name" { $pkgNames = @($testModuleName, $testModuleName2) - Install-PSResource -Name $pkgNames -Repository $ACRRepoName -TrustRepository + Install-PSResource -Name $pkgNames -Repository $ACRRepoName -TrustRepository $pkg = Get-InstalledPSResource $pkgNames $pkg.Name | Should -Be $pkgNames } @@ -72,7 +82,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { $pkg = Get-InstalledPSResource "NonExistantModule" $pkg | Should -BeNullOrEmpty $err.Count | Should -BeGreaterThan 0 - $err[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" } # Do some version testing, but Find-PSResource should be doing thorough testing @@ -84,21 +94,21 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { } It "Should install resource given name and exact version with bracket syntax" { - Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $ACRRepoName -TrustRepository + Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $ACRRepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "1.0.0" } It "Should install resource given name and exact range inclusive [1.0.0, 5.0.0]" { - Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $ACRRepoName -TrustRepository + Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $ACRRepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.0.0" } It "Should install resource given name and exact range exclusive (1.0.0, 5.0.0)" { - Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $ACRRepoName -TrustRepository + Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $ACRRepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "3.0.0" @@ -122,7 +132,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { <# TODO: enable when prerelease functionality is implemented It "Install resource with latest (including prerelease) version given Prerelease parameter" { - Install-PSResource -Name $testModuleName -Prerelease -Repository $ACRRepoName -TrustRepository + Install-PSResource -Name $testModuleName -Prerelease -Repository $ACRRepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.2.5" @@ -131,7 +141,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { #> It "Install resource via InputObject by piping from Find-PSresource" { - Find-PSResource -Name $testModuleName -Repository $ACRRepoName | Install-PSResource -TrustRepository + Find-PSResource -Name $testModuleName -Repository $ACRRepoName | Install-PSResource -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.0.0" @@ -244,15 +254,15 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidatio It "Install resource under AllUsers scope - Unix only" -Skip:(Get-IsWindows) { Install-PSResource -Name $testModuleName -Repository $TestGalleryName -Scope AllUsers $pkg = Get-Module $testModuleName -ListAvailable - $pkg.Name | Should -Be $testModuleName + $pkg.Name | Should -Be $testModuleName $pkg.Path.Contains("/usr/") | Should -Be $true } # This needs to be manually tested due to prompt It "Install resource that requires accept license without -AcceptLicense flag" { Install-PSResource -Name $testModuleName2 -Repository $TestGalleryName - $pkg = Get-InstalledPSResource $testModuleName2 - $pkg.Name | Should -Be $testModuleName2 + $pkg = Get-InstalledPSResource $testModuleName2 + $pkg.Name | Should -Be $testModuleName2 $pkg.Version | Should -Be "0.0.1.0" } @@ -261,7 +271,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidatio Set-PSResourceRepository PoshTestGallery -Trusted:$false Install-PSResource -Name $testModuleName -Repository $TestGalleryName -confirm:$false - + $pkg = Get-Module $testModuleName -ListAvailable $pkg.Name | Should -Be $testModuleName diff --git a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 index aa571a809..67edb3c2c 100644 --- a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 @@ -48,9 +48,19 @@ Describe "Test Publish-PSResource" -tags 'CI' { # Register repositories $ACRRepoName = "ACRRepo" $ACRRepoUri = "https://psresourcegettest.azurecr.io" - $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") - Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose - + + $usingAzAuth = $env:USINGAZAUTH -eq 'true' + + if ($usingAzAuth) + { + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -Verbose + } + else + { + $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + } + # Create module $script:tmpModulesPath = Join-Path -Path $TestDrive -ChildPath "tmpModulesPath" $script:PublishModuleName = "temp-psresourcegettemptestmodule" + [System.Guid]::NewGuid(); @@ -98,16 +108,16 @@ Describe "Test Publish-PSResource" -tags 'CI' { AfterAll { Get-RevertPSResourceRepositoryFile } - + It "Publish module with required module not installed on the local machine using -SkipModuleManifestValidate" { $ModuleName = "modulewithmissingrequiredmodule-" + [System.Guid]::NewGuid() CreateTestModule -Path $TestDrive -ModuleName $ModuleName - + # Skip the module manifest validation test, which fails from the missing manifest required module. $testModulePath = Join-Path -Path $TestDrive -ChildPath $ModuleName Publish-PSResource -Path $testModulePath -Repository $ACRRepoName -Confirm:$false -SkipDependenciesCheck -SkipModuleManifestValidate - $results = Find-PSResource -Name $ModuleName -Repository $ACRRepoName + $results = Find-PSResource -Name $ModuleName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty $results[0].Name | Should -Be $ModuleName $results[0].Version | Should -Be "1.0.0" @@ -116,10 +126,10 @@ Describe "Test Publish-PSResource" -tags 'CI' { It "Publish a module with -Path pointing to a module directory (parent directory has same name)" { $version = "1.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" - + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName - $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty $results[0].Name | Should -Be $script:PublishModuleName $results[0].Version | Should -Be $version @@ -133,7 +143,7 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $newModuleRoot -Repository $ACRRepoName - $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty $results[0].Name | Should -Be $script:PublishModuleName $results[0].Version | Should -Be $version @@ -146,7 +156,7 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $manifestPath -Repository $ACRRepoName - $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty $results[0].Name | Should -Be $script:PublishModuleName $results[0].Version | Should -Be $version @@ -161,7 +171,7 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $manifestPath -Repository $ACRRepoName - $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty $results[0].Name | Should -Be $script:PublishModuleName $results[0].Version | Should -Be $version @@ -173,12 +183,12 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $script:PublishModuleBaseUNC -Repository $ACRRepoName - $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $script:PublishModuleName - $results[0].Version | Should -Be $version + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version } - + It "Publish a module with -Path pointing to a module directory (parent directory has different name) on a network share" { $version = "6.0.0" $newModuleRoot = Join-Path -Path $script:PublishModuleBaseUNC -ChildPath "NewTestParentDirectory" @@ -187,10 +197,10 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $newModuleRoot -Repository $ACRRepoName - $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $script:PublishModuleName - $results[0].Version | Should -Be $version + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version } It "Publish a module with -Path pointing to a .psd1 (parent directory has same name) on a network share" { @@ -200,10 +210,10 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $manifestPath -Repository $ACRRepoName - $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $script:PublishModuleName - $results[0].Version | Should -Be $version + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version } It "Publish a module with -Path pointing to a .psd1 (parent directory has different name) on a network share" { @@ -215,12 +225,12 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $manifestPath -Repository $ACRRepoName - $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $script:PublishModuleName - $results[0].Version | Should -Be $version + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version } - + It "Publish a module and preserve file structure" { $version = "9.0.0" $testFile = Join-Path -Path "TestSubDirectory" -ChildPath "TestSubDirFile.ps1" @@ -228,12 +238,12 @@ Describe "Test Publish-PSResource" -tags 'CI' { New-Item -Path (Join-Path -Path $script:PublishModuleBase -ChildPath $testFile) -Force Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName -ErrorAction Stop - + Save-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -AsNupkg -Path $TestDrive -TrustRepository # Must change .nupkg to .zip so that Expand-Archive can work on Windows PowerShell $nupkgPath = Join-Path -Path $TestDrive -ChildPath "$script:PublishModuleName.$version.nupkg" $zipPath = Join-Path -Path $TestDrive -ChildPath "$script:PublishModuleName.$version.zip" - Rename-Item -Path $nupkgPath -NewName $zipPath + Rename-Item -Path $nupkgPath -NewName $zipPath $unzippedPath = Join-Path -Path $TestDrive -ChildPath "$script:PublishModuleName" New-Item $unzippedPath -Itemtype directory -Force Expand-Archive -Path $zipPath -DestinationPath $unzippedPath @@ -249,8 +259,8 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $script:PublishModuleName - $results[0].Version | Should -Be $version + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version $expectedPath = Join-Path -Path $script:destinationPath -ChildPath "$script:PublishModuleName.$version.nupkg" Test-Path $expectedPath | Should -Be $true @@ -311,10 +321,10 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $script:PublishModuleName - $results[0].Version | Should -Be $version + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version } - + It "Publish a module when the .psd1 version and the path version are different" { $incorrectVersion = "15.2.4" $correctVersion = "14.0.0" @@ -327,8 +337,8 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $correctVersion $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $script:PublishModuleName - $results[0].Version | Should -Be $correctVersion + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $correctVersion } It "Publish a script"{ @@ -355,10 +365,10 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $scriptPath -Repository $ACRRepoName - $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $scriptName - $results[0].Version | Should -Be $scriptVersion + $results[0].Name | Should -Be $scriptName + $results[0].Version | Should -Be $scriptVersion } <# This test does not work currently due to a bug if the last digit is 0. Link to issue: https://github.com/PowerShell/PSResourceGet/issues/1582 @@ -369,13 +379,13 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $scriptPath -Repository $ACRRepoName - $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $scriptName - $results[0].Version | Should -Be $scriptVersion + $results[0].Name | Should -Be $scriptName + $results[0].Version | Should -Be $scriptVersion } #> - + <# This test does not work currently due to a bug if the last digit is 0. Link to issue: https://github.com/PowerShell/PSResourceGet/issues/1582 It "Should publish a script without lines in help block locally" { $scriptName = "ScriptWithoutEmptyLinesInMetadata" @@ -384,13 +394,13 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $scriptPath -Repository $ACRRepoName - $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $scriptName - $results[0].Version | Should -Be $scriptVersion + $results[0].Name | Should -Be $scriptName + $results[0].Version | Should -Be $scriptVersion } #> - + It "Should publish a script with ExternalModuleDependencies that are not published" { $scriptName = "ScriptWithExternalDependencies" $scriptVersion = "1.0.0" @@ -399,10 +409,10 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $scriptPath -Repository $ACRRepoName - $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $scriptName - $results[0].Version | Should -Be $scriptVersion + $results[0].Name | Should -Be $scriptName + $results[0].Version | Should -Be $scriptVersion } It "Should write error and not publish script when Author property is missing" { @@ -412,17 +422,17 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue $err.Count | Should -BeGreaterThan 0 $err[0].FullyQualifiedErrorId | Should -BeExactly "psScriptMissingAuthor,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" - + Find-PSResource -Name $scriptName -Repository $ACRRepoName -ErrorVariable findErr -ErrorAction SilentlyContinue $findErr.Count | Should -BeGreaterThan 0 $findErr[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } - + It "Should write error and not publish script when Version property is missing" { $scriptName = "InvalidScriptMissingVersion.ps1" $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName - Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue $err.Count | Should -BeGreaterThan 0 $err[0].FullyQualifiedErrorId | Should -BeExactly "psScriptMissingVersion,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" @@ -478,14 +488,14 @@ Describe "Test Publish-PSResource" -tags 'CI' { { Publish-PSResource -Path $incorrectmoduleversion -Repository $ACRRepoName -ErrorAction Stop } | Should -Throw -ErrorId "InvalidModuleManifest,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" } - + It "Publish a module with a dependency that has an invalid version format, should throw" { $moduleName = "incorrectdepmoduleversion" $incorrectdepmoduleversion = Join-Path -Path $script:testModulesFolderPath -ChildPath $moduleName { Publish-PSResource -Path $incorrectdepmoduleversion -Repository $ACRRepoName -ErrorAction Stop } | Should -Throw -ErrorId "InvalidModuleManifest,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" } - + It "Publish a module with using an invalid file path (path to .psm1), should throw" { $fileName = "$script:PublishModuleName.psm1" $psm1Path = Join-Path -Path $script:PublishModuleBase -ChildPath $fileName From d5e5d8d5afbcdeb66e0786cb8d60970aa8614a7f Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Thu, 7 Mar 2024 13:29:24 -0800 Subject: [PATCH 055/160] ACR - Update `PSRepositoryInfo.APIVersion` to replace `acr` with `ContainerRegistry` (#1589) --- src/code/FindHelper.cs | 2 +- src/code/InstallHelper.cs | 4 +-- src/code/PSRepositoryInfo.cs | 12 ++++----- src/code/PublishPSResource.cs | 2 +- src/code/RegisterPSResourceRepository.cs | 7 ++--- src/code/RepositorySettings.cs | 26 +++++++++---------- src/code/ResponseUtilFactory.cs | 12 ++++----- src/code/ServerFactory.cs | 12 ++++----- .../FindPSResourceACRServer.Tests.ps1 | 4 +-- .../InstallPSResourceACRServer.Tests.ps1 | 4 +-- .../PublishPSResourceACRServer.Tests.ps1 | 4 +-- .../RegisterPSResourceRepository.Tests.ps1 | 11 ++++++++ 12 files changed, 55 insertions(+), 45 deletions(-) diff --git a/src/code/FindHelper.cs b/src/code/FindHelper.cs index 1d3fce098..a2a14fa72 100644 --- a/src/code/FindHelper.cs +++ b/src/code/FindHelper.cs @@ -956,7 +956,7 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R // After retrieving all packages find their dependencies if (_includeDependencies) { - if (currentServer.Repository.ApiVersion == PSRepositoryInfo.APIVersion.v3) + if (currentServer.Repository.ApiVersion == PSRepositoryInfo.APIVersion.V3) { _cmdletPassedIn.WriteWarning("Installing dependencies is not currently supported for V3 server protocol repositories. The package will be installed without installing dependencies."); yield break; diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index a2135ab76..6ea1cd9ca 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -315,7 +315,7 @@ private List ProcessRepositories( } repositoryNamesToSearch.Add(repoName); - if ((currentRepository.ApiVersion == PSRepositoryInfo.APIVersion.v3) && (!installDepsForRepo)) + if ((currentRepository.ApiVersion == PSRepositoryInfo.APIVersion.V3) && (!installDepsForRepo)) { _cmdletPassedIn.WriteWarning("Installing dependencies is not currently supported for V3 server protocol repositories. The package will be installed without installing dependencies."); installDepsForRepo = true; @@ -598,7 +598,7 @@ private List InstallPackages( if (!skipDependencyCheck) { - if (currentServer.Repository.ApiVersion == PSRepositoryInfo.APIVersion.v3) + if (currentServer.Repository.ApiVersion == PSRepositoryInfo.APIVersion.V3) { _cmdletPassedIn.WriteWarning("Installing dependencies is not currently supported for V3 server protocol repositories. The package will be installed without installing dependencies."); } diff --git a/src/code/PSRepositoryInfo.cs b/src/code/PSRepositoryInfo.cs index 4700d5d2a..df99908f8 100644 --- a/src/code/PSRepositoryInfo.cs +++ b/src/code/PSRepositoryInfo.cs @@ -15,12 +15,12 @@ public sealed class PSRepositoryInfo public enum APIVersion { - unknown, - v2, - v3, - local, - nugetServer, - acr + Unknown, + V2, + V3, + Local, + NugetServer, + ContainerRegistry } #endregion diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index 8f96973a7..496b1b03d 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -494,7 +494,7 @@ out string[] _ string repositoryUri = repository.Uri.AbsoluteUri; - if (repository.ApiVersion == PSRepositoryInfo.APIVersion.acr) + if (repository.ApiVersion == PSRepositoryInfo.APIVersion.ContainerRegistry) { ACRServerAPICalls acrServer = new ACRServerAPICalls(repository, this, _networkCredential, userAgentString); diff --git a/src/code/RegisterPSResourceRepository.cs b/src/code/RegisterPSResourceRepository.cs index 5b84749fd..1a86db210 100644 --- a/src/code/RegisterPSResourceRepository.cs +++ b/src/code/RegisterPSResourceRepository.cs @@ -339,11 +339,12 @@ private PSRepositoryInfo RepoValidationHelper(Hashtable repo) if (repo.ContainsKey("ApiVersion") && (repo["ApiVersion"] == null || String.IsNullOrEmpty(repo["ApiVersion"].ToString()) || - !(repo["ApiVersion"].ToString().Equals("local") || repo["ApiVersion"].ToString().Equals("v2") || - repo["ApiVersion"].ToString().Equals("v3") || repo["ApiVersion"].ToString().Equals("nugetServer") || repo["ApiVersion"].ToString().Equals("unknown")))) + !(repo["ApiVersion"].ToString().Equals("Local", StringComparison.OrdinalIgnoreCase) || repo["ApiVersion"].ToString().Equals("V2", StringComparison.OrdinalIgnoreCase) || + repo["ApiVersion"].ToString().Equals("V3", StringComparison.OrdinalIgnoreCase) || repo["ApiVersion"].ToString().Equals("NugetServer", StringComparison.OrdinalIgnoreCase) || + repo["ApiVersion"].ToString().Equals("Unknown", StringComparison.OrdinalIgnoreCase)))) { WriteError(new ErrorRecord( - new PSInvalidOperationException("Repository ApiVersion must be either 'local', 'v2', 'v3', 'nugetServer' or 'unknown'"), + new PSInvalidOperationException("Repository ApiVersion must be either 'Local', 'V2', 'V3', 'NugetServer', 'ContainRegistry' or 'Unknown'"), "IncorrectApiVersionForRepositoriesParameterSetRegistration", ErrorCategory.InvalidArgument, this)); diff --git a/src/code/RepositorySettings.cs b/src/code/RepositorySettings.cs index 85119026d..b42e7e581 100644 --- a/src/code/RepositorySettings.cs +++ b/src/code/RepositorySettings.cs @@ -7,8 +7,6 @@ using System.IO; using System.Linq; using System.Management.Automation; -using System.Security.Cryptography; -using System.Text; using System.Xml; using System.Xml.Linq; using static Microsoft.PowerShell.PSResourceGet.UtilClasses.PSRepositoryInfo; @@ -63,7 +61,7 @@ public static void CheckRepositoryStore() // Add PSGallery to the newly created store Uri psGalleryUri = new Uri(PSGalleryRepoUri); - Add(PSGalleryRepoName, psGalleryUri, DefaultPriority, DefaultTrusted, repoCredentialInfo: null, PSRepositoryInfo.APIVersion.v2, force: false); + Add(PSGalleryRepoName, psGalleryUri, DefaultPriority, DefaultTrusted, repoCredentialInfo: null, PSRepositoryInfo.APIVersion.V2, force: false); } // Open file (which should exist now), if cannot/is corrupted then throw error @@ -416,7 +414,7 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio } // Update APIVersion if necessary - PSRepositoryInfo.APIVersion resolvedAPIVersion = PSRepositoryInfo.APIVersion.unknown; + PSRepositoryInfo.APIVersion resolvedAPIVersion = PSRepositoryInfo.APIVersion.Unknown; if (apiVersion != null) { resolvedAPIVersion = (PSRepositoryInfo.APIVersion)apiVersion; @@ -424,7 +422,7 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio } else { - resolvedAPIVersion = (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value); + resolvedAPIVersion = (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true); } @@ -531,7 +529,7 @@ public static List Remove(string[] repoNames, out string[] err Int32.Parse(node.Attribute("Priority").Value), Boolean.Parse(node.Attribute("Trusted").Value), repoCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value))); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true))); // Remove item from file node.Remove(); @@ -661,7 +659,7 @@ public static List Read(string[] repoNames, out string[] error Int32.Parse(repo.Attribute("Priority").Value), Boolean.Parse(repo.Attribute("Trusted").Value), thisCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), repo.Attribute("APIVersion").Value)); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), repo.Attribute("APIVersion").Value, ignoreCase: true)); foundRepos.Add(currentRepoItem); } @@ -765,7 +763,7 @@ public static List Read(string[] repoNames, out string[] error Int32.Parse(node.Attribute("Priority").Value), Boolean.Parse(node.Attribute("Trusted").Value), thisCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value)); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true)); foundRepos.Add(currentRepoItem); } @@ -823,30 +821,30 @@ private static PSRepositoryInfo.APIVersion GetRepoAPIVersion(Uri repoUri) if (repoUri.AbsoluteUri.EndsWith("/v2", StringComparison.OrdinalIgnoreCase)) { // Scenario: V2 server protocol repositories (i.e PSGallery) - return PSRepositoryInfo.APIVersion.v2; + return PSRepositoryInfo.APIVersion.V2; } else if (repoUri.AbsoluteUri.EndsWith("/index.json", StringComparison.OrdinalIgnoreCase)) { // Scenario: V3 server protocol repositories (i.e NuGet.org, Azure Artifacts (ADO), Artifactory, Github Packages, MyGet.org) - return PSRepositoryInfo.APIVersion.v3; + return PSRepositoryInfo.APIVersion.V3; } else if (repoUri.AbsoluteUri.EndsWith("/nuget", StringComparison.OrdinalIgnoreCase)) { // Scenario: ASP.Net application feed created with NuGet.Server to host packages - return PSRepositoryInfo.APIVersion.nugetServer; + return PSRepositoryInfo.APIVersion.NugetServer; } else if (repoUri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase) || repoUri.Scheme.Equals("temp", StringComparison.OrdinalIgnoreCase)) { // repositories with Uri Scheme "temp" may have PSPath Uri's like: "Temp:\repo" and we should consider them as local repositories. - return PSRepositoryInfo.APIVersion.local; + return PSRepositoryInfo.APIVersion.Local; } else if (repoUri.AbsoluteUri.EndsWith(".azurecr.io") || repoUri.AbsoluteUri.EndsWith(".azurecr.io/")) { - return PSRepositoryInfo.APIVersion.acr; + return PSRepositoryInfo.APIVersion.ContainerRegistry; } else { - return PSRepositoryInfo.APIVersion.unknown; + return PSRepositoryInfo.APIVersion.Unknown; } } diff --git a/src/code/ResponseUtilFactory.cs b/src/code/ResponseUtilFactory.cs index 843f9042b..a90091099 100644 --- a/src/code/ResponseUtilFactory.cs +++ b/src/code/ResponseUtilFactory.cs @@ -14,27 +14,27 @@ public static ResponseUtil GetResponseUtil(PSRepositoryInfo repository) switch (repoApiVersion) { - case PSRepositoryInfo.APIVersion.v2: + case PSRepositoryInfo.APIVersion.V2: currentResponseUtil = new V2ResponseUtil(repository); break; - case PSRepositoryInfo.APIVersion.v3: + case PSRepositoryInfo.APIVersion.V3: currentResponseUtil = new V3ResponseUtil(repository); break; - case PSRepositoryInfo.APIVersion.local: + case PSRepositoryInfo.APIVersion.Local: currentResponseUtil = new LocalResponseUtil(repository); break; - case PSRepositoryInfo.APIVersion.nugetServer: + case PSRepositoryInfo.APIVersion.NugetServer: currentResponseUtil = new NuGetServerResponseUtil(repository); break; - case PSRepositoryInfo.APIVersion.acr: + case PSRepositoryInfo.APIVersion.ContainerRegistry: currentResponseUtil = new ACRResponseUtil(repository); break; - case PSRepositoryInfo.APIVersion.unknown: + case PSRepositoryInfo.APIVersion.Unknown: break; } diff --git a/src/code/ServerFactory.cs b/src/code/ServerFactory.cs index ec835a4f6..40652fe73 100644 --- a/src/code/ServerFactory.cs +++ b/src/code/ServerFactory.cs @@ -43,27 +43,27 @@ public static ServerApiCall GetServer(PSRepositoryInfo repository, PSCmdlet cmdl switch (repoApiVersion) { - case PSRepositoryInfo.APIVersion.v2: + case PSRepositoryInfo.APIVersion.V2: currentServer = new V2ServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); break; - case PSRepositoryInfo.APIVersion.v3: + case PSRepositoryInfo.APIVersion.V3: currentServer = new V3ServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); break; - case PSRepositoryInfo.APIVersion.local: + case PSRepositoryInfo.APIVersion.Local: currentServer = new LocalServerAPICalls(repository, cmdletPassedIn, networkCredential); break; - case PSRepositoryInfo.APIVersion.nugetServer: + case PSRepositoryInfo.APIVersion.NugetServer: currentServer = new NuGetServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); break; - case PSRepositoryInfo.APIVersion.acr: + case PSRepositoryInfo.APIVersion.ContainerRegistry: currentServer = new ACRServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); break; - case PSRepositoryInfo.APIVersion.unknown: + case PSRepositoryInfo.APIVersion.Unknown: break; } diff --git a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 index 06a7627e2..3956b23cb 100644 --- a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 @@ -18,12 +18,12 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { if ($usingAzAuth) { Write-Verbose -Verbose "Using Az module for authentication" - Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -Verbose + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -Verbose } else { $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") - Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose } } diff --git a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 index 5d330c1db..f156270bc 100644 --- a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 @@ -19,12 +19,12 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { if ($usingAzAuth) { - Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -Verbose + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -Verbose } else { $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") - Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose } } diff --git a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 index 67edb3c2c..0b366f4da 100644 --- a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 @@ -53,12 +53,12 @@ Describe "Test Publish-PSResource" -tags 'CI' { if ($usingAzAuth) { - Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -Verbose + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -Verbose } else { $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") - Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'acr' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose } # Create module diff --git a/test/ResourceRepositoryTests/RegisterPSResourceRepository.Tests.ps1 b/test/ResourceRepositoryTests/RegisterPSResourceRepository.Tests.ps1 index c49db4e15..fcaa9dcda 100644 --- a/test/ResourceRepositoryTests/RegisterPSResourceRepository.Tests.ps1 +++ b/test/ResourceRepositoryTests/RegisterPSResourceRepository.Tests.ps1 @@ -403,4 +403,15 @@ Describe "Test Register-PSResourceRepository" -tags 'CI' { $res.Uri.LocalPath | Should -Contain $tmpDir1Path $res.ApiVersion | Should -Be 'v2' } + + It "should register container registry repository with correct ApiVersion" { + $ContainerRegistryName = "ACRRepo" + $ContainerRegistryUri = "https://psresourcegettest.azurecr.io/" + Register-PSResourceRepository -Name $ContainerRegistryName -Uri $ContainerRegistryUri + $res = Get-PSResourceRepository -Name $ContainerRegistryName + + $res.Name | Should -Be $ContainerRegistryName + $res.Uri.AbsoluteUri | Should -Contain $ContainerRegistryUri + $res.ApiVersion | Should -Be 'ContainerRegistry' + } } From 868c96dbe10eb3dae1adb45ce5d138905eb25eac Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Thu, 7 Mar 2024 18:55:38 -0500 Subject: [PATCH 056/160] ACR: Ensure versions returned are sorted and can be correctly compared if number of trailing zeroes differ (#1588) --- src/code/ACRServerAPICalls.cs | 296 +++++++++--------- src/code/PSResourceInfo.cs | 46 ++- .../FindPSResourceACRServer.Tests.ps1 | 25 +- .../InstallPSResourceACRServer.Tests.ps1 | 2 - .../PublishPSResourceACRServer.Tests.ps1 | 8 +- 5 files changed, 212 insertions(+), 165 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 04cc6fa37..c189a357c 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -133,72 +133,15 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include public override FindResults FindName(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindName()"); - string accessToken = string.Empty; - string tenantID = string.Empty; - string packageNameLowercase = packageName.ToLower(); - string acrAccessToken = GetAcrAccessToken(Repository, out errRecord); + // for FindName(), need to consider all versions (hence VersionType.VersionRange and VersionRange.All, and no requiredVersion) but only pick latest (hence getOnlyLatest: true) + Hashtable[] pkgResult = FindPackagesWithVersionHelper(packageName, VersionType.VersionRange, versionRange: VersionRange.All, requiredVersion: null, includePrerelease, getOnlyLatest: true, out errRecord); if (errRecord != null) { return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } - string registry = Repository.Uri.Host; - _cmdletPassedIn.WriteVerbose("Getting tags"); - var foundTags = FindAcrImageTags(registry, packageNameLowercase, "*", acrAccessToken, out errRecord); - if (errRecord != null || foundTags == null) - { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } - - List latestVersionResponse = new List(); - List allVersionsList = foundTags["tags"].ToList(); - allVersionsList.Reverse(); - - foreach (var pkgVersionTagInfo in allVersionsList) - { - using (JsonDocument pkgVersionEntry = JsonDocument.Parse(pkgVersionTagInfo.ToString())) - { - JsonElement rootDom = pkgVersionEntry.RootElement; - if (!rootDom.TryGetProperty("name", out JsonElement pkgVersionElement)) - { - errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain version element ('name') for package '{packageName}' in '{Repository.Name}'."), - "FindNameFailure", - ErrorCategory.InvalidResult, - this); - - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } - - if (!NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) - { - errRecord = new ErrorRecord( - new ArgumentException($"Version {pkgVersionElement.ToString()} to be parsed from metadata is not a valid NuGet version."), - "FindNameFailure", - ErrorCategory.InvalidArgument, - this); - - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } - - _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); - if (!pkgVersion.IsPrerelease || includePrerelease) - { - // TODO: ensure versions are in order, fix bug https://github.com/PowerShell/PSResourceGet/issues/1581 - Hashtable metadata = GetACRMetadata(registry, packageNameLowercase, pkgVersion, acrAccessToken, out errRecord); - if (errRecord != null || metadata.Count == 0) - { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } - - latestVersionResponse.Add(metadata); - break; - } - } - } - - return new FindResults(stringResponse: new string[] {}, hashtableResponse: latestVersionResponse.ToArray(), responseType: acrFindResponseType); + return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResult.ToArray(), responseType: acrFindResponseType); } /// @@ -217,7 +160,6 @@ public override FindResults FindNameWithTag(string packageName, string[] tags, b this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } /// @@ -270,67 +212,15 @@ public override FindResults FindNameGlobbingWithTag(string packageName, string[] public override FindResults FindVersionGlobbing(string packageName, VersionRange versionRange, bool includePrerelease, ResourceType type, bool getOnlyLatest, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindVersionGlobbing()"); - string accessToken = string.Empty; - string tenantID = string.Empty; - string packageNameLowercase = packageName.ToLower(); - string registryUrl = Repository.Uri.ToString(); - string acrAccessToken = GetAcrAccessToken(Repository, out errRecord); + // for FindVersionGlobbing(), need to consider all versions that match version range criteria (hence VersionType.VersionRange and no requiredVersion) + Hashtable[] pkgResults = FindPackagesWithVersionHelper(packageName, VersionType.VersionRange, versionRange: versionRange, requiredVersion: null, includePrerelease, getOnlyLatest: false, out errRecord); if (errRecord != null) { return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } - string registry = Repository.Uri.Host; - _cmdletPassedIn.WriteVerbose("Getting tags"); - var foundTags = FindAcrImageTags(registry, packageNameLowercase, "*", acrAccessToken, out errRecord); - if (errRecord != null || foundTags == null) - { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } - - List latestVersionResponse = new List(); - List allVersionsList = foundTags["tags"].ToList(); - allVersionsList.Reverse(); - foreach (var packageVersion in allVersionsList) - { - var packageVersionStr = packageVersion.ToString(); - using (JsonDocument pkgVersionEntry = JsonDocument.Parse(packageVersionStr)) - { - JsonElement rootDom = pkgVersionEntry.RootElement; - if (!rootDom.TryGetProperty("name", out JsonElement pkgVersionElement)) - { - errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain version element ('name') for package '{packageName}' in '{Repository.Name}'."), - "FindNameFailure", - ErrorCategory.InvalidResult, - this); - - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); - } - - if (NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) - { - _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); - if (versionRange.Satisfies(pkgVersion)) - { - if (!includePrerelease && pkgVersion.IsPrerelease == true) - { - _cmdletPassedIn.WriteDebug($"Prerelease version '{pkgVersion}' found, but not included."); - continue; - } - - latestVersionResponse.Add(GetACRMetadata(registry, packageNameLowercase, pkgVersion, acrAccessToken, out errRecord)); - if (errRecord != null) - { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: latestVersionResponse.ToArray(), responseType: acrFindResponseType); - } - } - } - } - } - - return new FindResults(stringResponse: new string[] { }, hashtableResponse: latestVersionResponse.ToArray(), responseType: acrFindResponseType); + return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResults.ToArray(), responseType: acrFindResponseType); } /// @@ -353,31 +243,18 @@ public override FindResults FindVersion(string packageName, string version, Reso return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{requiredVersion}'"); + bool includePrereleaseVersions = requiredVersion.IsPrerelease; - string accessToken = string.Empty; - string tenantID = string.Empty; - string registryUrl = Repository.Uri.ToString(); - - string acrAccessToken = GetAcrAccessToken(Repository, out errRecord); + // for FindVersion(), need to consider the specific required version (hence VersionType.SpecificVersion and no version range) + Hashtable[] pkgResult = FindPackagesWithVersionHelper(packageName, VersionType.SpecificVersion, versionRange: VersionRange.None, requiredVersion: requiredVersion, includePrereleaseVersions, getOnlyLatest: false, out errRecord); if (errRecord != null) { return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } - string registry = Repository.Uri.Host; - _cmdletPassedIn.WriteVerbose("Getting tags"); - List results = new List - { - GetACRMetadata(registry, packageName, requiredVersion, acrAccessToken, out errRecord) - }; - - if (errRecord != null) - { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: results.ToArray(), responseType: acrFindResponseType); - } - - return new FindResults(stringResponse: new string[] { }, hashtableResponse: results.ToArray(), responseType: acrFindResponseType); + return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResult.ToArray(), responseType: acrFindResponseType); } /// @@ -654,11 +531,11 @@ internal JObject FindAcrImageTags(string registry, string repositoryName, string } } - internal Hashtable GetACRMetadata(string registry, string packageName, NuGetVersion requiredVersion, string acrAccessToken, out ErrorRecord errRecord) + internal Hashtable GetACRMetadata(string registry, string packageName, string exactTagVersion, string acrAccessToken, out ErrorRecord errRecord) { Hashtable requiredVersionResponse = new Hashtable(); - var foundTags = FindAcrManifest(registry, packageName, requiredVersion.ToNormalizedString(), acrAccessToken, out errRecord); + var foundTags = FindAcrManifest(registry, packageName, exactTagVersion, acrAccessToken, out errRecord); if (errRecord != null || foundTags == null) { return requiredVersionResponse; @@ -696,11 +573,26 @@ internal Hashtable GetACRMetadata(string registry, string packageName, NuGetVers string metadataPkgName = metadataTuple.Item1; string metadata = metadataTuple.Item2; + string pkgVersionString = String.Empty; using (JsonDocument metadataJSONDoc = JsonDocument.Parse(metadata)) { JsonElement rootDom = metadataJSONDoc.RootElement; - if (!rootDom.TryGetProperty("ModuleVersion", out JsonElement pkgVersionElement) && - !rootDom.TryGetProperty("Version", out pkgVersionElement)) + if (rootDom.TryGetProperty("ModuleVersion", out JsonElement pkgVersionElement)) + { + // module metadata will have "ModuleVersion" property + pkgVersionString = pkgVersionElement.ToString(); + if (rootDom.TryGetProperty("PrivateData", out JsonElement pkgPrivateDataElement) && pkgPrivateDataElement.TryGetProperty("PSData", out JsonElement pkgPSDataElement) + && pkgPSDataElement.TryGetProperty("Prerelease", out JsonElement pkgPrereleaseLabelElement) && !String.IsNullOrEmpty(pkgPrereleaseLabelElement.ToString().Trim())) + { + pkgVersionString += $"-{pkgPrereleaseLabelElement.ToString()}"; + } + } + else if(rootDom.TryGetProperty("Version", out pkgVersionElement)) + { + // script metadata will have "Version" property + pkgVersionString = pkgVersionElement.ToString(); + } + else { errRecord = new ErrorRecord( new InvalidOrEmptyResponse($"Response does not contain 'ModuleVersion' or 'Version' property in metadata for package '{packageName}' in '{Repository.Name}'."), @@ -711,10 +603,21 @@ internal Hashtable GetACRMetadata(string registry, string packageName, NuGetVers return requiredVersionResponse; } - if (!NuGetVersion.TryParse(pkgVersionElement.ToString(), out NuGetVersion pkgVersion)) + if (!NuGetVersion.TryParse(pkgVersionString, out NuGetVersion pkgVersion)) { errRecord = new ErrorRecord( - new ArgumentException($"Version {pkgVersionElement.ToString()} to be parsed from metadata is not a valid NuGet version."), + new ArgumentException($"Version {pkgVersionString} to be parsed from metadata is not a valid NuGet version."), + "FindNameFailure", + ErrorCategory.InvalidArgument, + this); + + return requiredVersionResponse; + } + + if (!NuGetVersion.TryParse(exactTagVersion, out NuGetVersion requiredVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Version {exactTagVersion} to be parsed from method input is not a valid NuGet version."), "FindNameFailure", ErrorCategory.InvalidArgument, this); @@ -723,7 +626,7 @@ internal Hashtable GetACRMetadata(string registry, string packageName, NuGetVers } _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); - if (pkgVersion == requiredVersion) + if (pkgVersion.ToNormalizedString() == requiredVersion.ToNormalizedString()) { requiredVersionResponse.Add(metadataPkgName, metadata); } @@ -1687,6 +1590,117 @@ private string CreateMetadataContent(string manifestFilePath, ResourceType resou return jsonString; } + private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionType versionType, VersionRange versionRange, NuGetVersion requiredVersion, bool includePrerelease, bool getOnlyLatest, out ErrorRecord errRecord) + { + string accessToken = string.Empty; + string tenantID = string.Empty; + string registryUrl = Repository.Uri.ToString(); + string packageNameLowercase = packageName.ToLower(); + + string acrAccessToken = GetAcrAccessToken(Repository, out errRecord); + if (errRecord != null) + { + return emptyHashResponses; + } + + var foundTags = FindAcrImageTags(Registry, packageNameLowercase, "*", acrAccessToken, out errRecord); + if (errRecord != null || foundTags == null) + { + return emptyHashResponses; + } + + List latestVersionResponse = new List(); + List allVersionsList = foundTags["tags"].ToList(); + + SortedDictionary sortedQualifyingPkgs = GetPackagesWithRequiredVersion(allVersionsList, versionType, versionRange, requiredVersion, packageNameLowercase, includePrerelease, out errRecord); + if (errRecord != null) + { + return emptyHashResponses; + } + + var pkgsInDescendingOrder = sortedQualifyingPkgs.Reverse(); + + foreach(var pkgVersionTag in pkgsInDescendingOrder) + { + string exactTagVersion = pkgVersionTag.Value.ToString(); + Hashtable metadata = GetACRMetadata(Registry, packageNameLowercase, exactTagVersion, acrAccessToken, out errRecord); + if (errRecord != null || metadata.Count == 0) + { + return emptyHashResponses; + } + + latestVersionResponse.Add(metadata); + if (getOnlyLatest) + { + // getOnlyLatest will be true for FindName(), as only the latest criteria satisfying version should be returned + break; + } + } + + return latestVersionResponse.ToArray(); + } + + private SortedDictionary GetPackagesWithRequiredVersion(List allPkgVersions, VersionType versionType, VersionRange versionRange, NuGetVersion specificVersion, string packageName, bool includePrerelease, out ErrorRecord errRecord) + { + errRecord = null; + // we need NuGetVersion to sort versions by order, and string pkgVersionString (which is the exact tag from the server) to call GetACRMetadata() later with exact version tag. + SortedDictionary sortedPkgs = new SortedDictionary(VersionComparer.Default); + bool isSpecificVersionSearch = versionType == VersionType.SpecificVersion; + + foreach (var pkgVersionTagInfo in allPkgVersions) + { + using (JsonDocument pkgVersionEntry = JsonDocument.Parse(pkgVersionTagInfo.ToString())) + { + JsonElement rootDom = pkgVersionEntry.RootElement; + if (!rootDom.TryGetProperty("name", out JsonElement pkgVersionElement)) + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain version element ('name') for package '{packageName}' in '{Repository.Name}'."), + "FindNameFailure", + ErrorCategory.InvalidResult, + this); + + return null; + } + + string pkgVersionString = pkgVersionElement.ToString(); + // determine if the package version that is a repository tag is a valid NuGetVersion + if (!NuGetVersion.TryParse(pkgVersionString, out NuGetVersion pkgVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Version {pkgVersionString} to be parsed from metadata is not a valid NuGet version."), + "FindNameFailure", + ErrorCategory.InvalidArgument, + this); + + return null; + } + + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + + if (isSpecificVersionSearch) + { + if (pkgVersion.ToNormalizedString() == specificVersion.ToNormalizedString()) + { + // accounts for FindVersion() scenario + sortedPkgs.Add(pkgVersion, pkgVersionString); + break; + } + } + else + { + if (versionRange.Satisfies(pkgVersion) && (!pkgVersion.IsPrerelease || includePrerelease)) + { + // accounts for FindVersionGlobbing() and FindName() scenario + sortedPkgs.Add(pkgVersion, pkgVersionString); + } + } + } + } + + return sortedPkgs; + } + #endregion } } diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 838d90660..04f8f646a 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -827,27 +827,38 @@ public static bool TryConvertFromACRJson( { Hashtable metadata = new Hashtable(StringComparer.InvariantCultureIgnoreCase); JsonElement rootDom = packageMetadata.RootElement; + metadata["IsPrerelease"] = false; + metadata["Prerelease"] = String.Empty; + string versionValue = String.Empty; + Version pkgVersion = null; // Version - if (rootDom.TryGetProperty("ModuleVersion", out JsonElement versionElement) || rootDom.TryGetProperty("Version", out versionElement)) + // For scripts (i.e with "Version" property) the version can contain prerelease label + if (rootDom.TryGetProperty("Version", out JsonElement scriptVersionElement)) { - string versionValue = versionElement.ToString(); - - Version pkgVersion = ParseHttpVersion(versionValue, out string prereleaseLabel); + versionValue = scriptVersionElement.ToString(); + pkgVersion = ParseHttpVersion(versionValue, out string prereleaseLabel); metadata["Version"] = pkgVersion; metadata["Prerelease"] = prereleaseLabel; metadata["IsPrerelease"] = !String.IsNullOrEmpty(prereleaseLabel); + } + else if(rootDom.TryGetProperty("ModuleVersion", out JsonElement moduleVersionElement)) + { + // For modules (i.e with "ModuleVersion" property) it will just contain the numerical part not prerelease label, so we must find that from PrivateData.PSData.Prerelease entry + versionValue = moduleVersionElement.ToString(); + pkgVersion = ParseHttpVersion(versionValue, out string prereleaseLabel); + metadata["Version"] = pkgVersion; - if (!NuGetVersion.TryParse(versionValue, out NuGetVersion parsedNormalizedVersion) && pkgVersion == null) + if (rootDom.TryGetProperty("PrivateData", out JsonElement privateDataElement) && privateDataElement.TryGetProperty("PSData", out JsonElement psDataElement)) { - errorMsg = string.Format( - CultureInfo.InvariantCulture, - @"TryConvertFromACRJson: Cannot parse NormalizedVersion or System.Version from version in metadata."); - - return false; + if (psDataElement.TryGetProperty("Prerelease", out JsonElement pkgPrereleaseLabelElement) && !String.IsNullOrEmpty(pkgPrereleaseLabelElement.ToString().Trim())) + { + prereleaseLabel = pkgPrereleaseLabelElement.ToString().Trim(); + versionValue += $"-{prereleaseLabel}"; + metadata["Prerelease"] = prereleaseLabel; + metadata["IsPrerelease"] = true; + } } - - metadata["NormalizedVersion"] = parsedNormalizedVersion; } else { @@ -858,6 +869,17 @@ public static bool TryConvertFromACRJson( return false; } + if (!NuGetVersion.TryParse(versionValue, out NuGetVersion parsedNormalizedVersion) && pkgVersion == null) + { + errorMsg = string.Format( + CultureInfo.InvariantCulture, + @"TryConvertFromACRJson: Cannot parse NormalizedVersion or System.Version from version in metadata."); + + return false; + } + + metadata["NormalizedVersion"] = parsedNormalizedVersion.ToNormalizedString(); + // License Url if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement)) { diff --git a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 index 3956b23cb..27c210b3b 100644 --- a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 @@ -80,12 +80,12 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Count | Should -BeGreaterOrEqual 1 } - <# TODO: prerelease handling not yet implemented in ACR Server Protocol It "Find resource given specific Name, Version null but allowing Prerelease" { # FindName() $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName -Prerelease $res.Name | Should -Be $testModuleName - $res.Version | Should -Be "5.0.0-alpha001" + $res.Version | Should -Be "5.2.5" + $res.Prerelease | Should -Be "alpha001" } It "Find resource with latest (including prerelease) version given Prerelease parameter" { @@ -95,7 +95,7 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Version | Should -Be "5.0.0" $resPrerelease = Find-PSResource -Name $testModuleName -Prerelease -Repository $ACRRepoName - $resPrerelease.Version | Should -Be "5.0.0" + $resPrerelease.Version | Should -Be "5.2.5" $resPrerelease.Prerelease | Should -Be "alpha001" } @@ -105,7 +105,6 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $resWithPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $ACRRepoName -Prerelease $resWithPrerelease.Count | Should -BeGreaterOrEqual $resWithoutPrerelease.Count } - #> It "Should not find resource if Name, Version and Tag property are not all satisfied (single tag)" { # FindVersionWithTag() @@ -158,6 +157,15 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Version | Should -Be "2.0.0" } + It "Should find script given Name and Prerelease" { + # latest version is a prerelease version + $res = Find-PSResource -Name $testScript -Prerelease -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $testScript + $res.Version | Should -Be "3.5.0" + $res.Prerelease | Should -Be "alpha" + } + It "Should find script given Name and Version" { # FindVersion() $res = Find-PSResource -Name $testScript -Version "1.0.0" -Repository $ACRRepoName @@ -165,4 +173,13 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Name | Should -BeExactly $testScript $res.Version | Should -Be "1.0.0" } + + It "Should find script given Name, Version and Prerelease" { + # latest version is a prerelease version + $res = Find-PSResource -Name $testScript -Version "3.5.0-alpha" -Prerelease -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $testScript + $res.Version | Should -Be "3.5.0" + $res.Prerelease | Should -Be "alpha" + } } diff --git a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 index f156270bc..273b85c3b 100644 --- a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 @@ -130,7 +130,6 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { $pkg.Version | Should -Be "5.0.0" } - <# TODO: enable when prerelease functionality is implemented It "Install resource with latest (including prerelease) version given Prerelease parameter" { Install-PSResource -Name $testModuleName -Prerelease -Repository $ACRRepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName @@ -138,7 +137,6 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { $pkg.Version | Should -Be "5.2.5" $pkg.Prerelease | Should -Be "alpha001" } - #> It "Install resource via InputObject by piping from Find-PSresource" { Find-PSResource -Name $testModuleName -Repository $ACRRepoName | Install-PSResource -TrustRepository diff --git a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 index 0b366f4da..4c2087db9 100644 --- a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 @@ -371,10 +371,9 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Version | Should -Be $scriptVersion } - <# This test does not work currently due to a bug if the last digit is 0. Link to issue: https://github.com/PowerShell/PSResourceGet/issues/1582 It "Should publish a script without lines in between comment blocks locally" { $scriptName = "ScriptWithoutEmptyLinesBetweenCommentBlocks" - $scriptVersion = "1.0.0" + $scriptVersion = "1.0" $scriptPath = (Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1") Publish-PSResource -Path $scriptPath -Repository $ACRRepoName @@ -384,12 +383,10 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Name | Should -Be $scriptName $results[0].Version | Should -Be $scriptVersion } - #> - <# This test does not work currently due to a bug if the last digit is 0. Link to issue: https://github.com/PowerShell/PSResourceGet/issues/1582 It "Should publish a script without lines in help block locally" { $scriptName = "ScriptWithoutEmptyLinesInMetadata" - $scriptVersion = "1.0.0" + $scriptVersion = "1.0" $scriptPath = (Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1") Publish-PSResource -Path $scriptPath -Repository $ACRRepoName @@ -399,7 +396,6 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Name | Should -Be $scriptName $results[0].Version | Should -Be $scriptVersion } - #> It "Should publish a script with ExternalModuleDependencies that are not published" { $scriptName = "ScriptWithExternalDependencies" From c353ebee336d6f705dccc8e3b1dd4410275b99a4 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Fri, 8 Mar 2024 15:13:07 -0800 Subject: [PATCH 057/160] Add support for Find/Install ACR packages with dependencies (#1587) --- src/code/ACRServerAPICalls.cs | 335 +----------------- src/code/PSResourceInfo.cs | 80 ++++- .../FindPSResourceACRServer.Tests.ps1 | 10 + .../InstallPSResourceACRServer.Tests.ps1 | 13 + .../PublishPSResourceACRServer.Tests.ps1 | 26 +- 5 files changed, 114 insertions(+), 350 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index c189a357c..e2e19b225 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -741,20 +741,6 @@ internal async Task UploadManifest(string pkgName, string p } } - internal async Task UploadDependencyManifest(string pkgName, string referenceSHA, string configPath, bool isManifest, string acrAccessToken) - { - try - { - var createManifestUrl = string.Format(acrManifestUrlTemplate, Registry, pkgName, referenceSHA); - var defaultHeaders = GetDefaultHeaders(acrAccessToken); - return await PutRequestAsync(createManifestUrl, configPath, isManifest, defaultHeaders); - } - catch (HttpRequestException e) - { - throw new HttpRequestException("Error occured while trying to create manifest: " + e.Message); - } - } - internal async Task GetHttpContentResponseJObject(string url, Collection> defaultHeaders) { try @@ -1039,15 +1025,9 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p _cmdletPassedIn.ThrowTerminatingError(metadataCreationError); } - // Create and upload manifest - TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, pkgName, resourceType, metadataJson, configFilePath, - pkgNameLower, pkgVersion, acrAccessToken, out HttpResponseMessage manifestResponse); - - // After manifest is created, see if there are any dependencies that need to be tracked on the server - if (dependencies != null && dependencies.Count > 0) - { - TryProcessDependencies(dependencies, pkgNameLower, acrAccessToken, manifestResponse); - } + // Create and upload manifest + TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, pkgName, resourceType, metadataJson, configFilePath, + pkgNameLower, pkgVersion, acrAccessToken); return true; } @@ -1117,7 +1097,7 @@ private bool TryCreateConfig(string configFilePath, out string configDigest) } private bool TryCreateAndUploadManifest(string fullNupkgFile, string nupkgDigest, string configDigest, string pkgName, ResourceType resourceType, string metadataJson, string configFilePath, - string pkgNameLower, NuGetVersion pkgVersion, string acrAccessToken, out HttpResponseMessage manifestResponse) + string pkgNameLower, NuGetVersion pkgVersion, string acrAccessToken) { FileInfo nupkgFile = new FileInfo(fullNupkgFile); var fileSize = nupkgFile.Length; @@ -1126,7 +1106,7 @@ private bool TryCreateAndUploadManifest(string fullNupkgFile, string nupkgDigest File.WriteAllText(configFilePath, fileContent); _cmdletPassedIn.WriteVerbose("Create the manifest layer"); - manifestResponse = UploadManifest(pkgNameLower, pkgVersion.OriginalVersion, configFilePath, true, acrAccessToken).Result; + HttpResponseMessage manifestResponse = UploadManifest(pkgNameLower, pkgVersion.OriginalVersion, configFilePath, true, acrAccessToken).Result; bool manifestCreated = manifestResponse.IsSuccessStatusCode; if (!manifestCreated) { @@ -1141,175 +1121,6 @@ private bool TryCreateAndUploadManifest(string fullNupkgFile, string nupkgDigest return manifestCreated; } - private bool TryProcessDependencies(Hashtable dependencies, string pkgNameLower, string acrAccessToken, HttpResponseMessage manifestResponse) - { - string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - - try - { - Directory.CreateDirectory(tempPath); - - // Create dependency.json - TryCreateAndUploadDependencyJson(tempPath, dependencies, pkgNameLower, acrAccessToken, out string depJsonContent, out string depDigest, out long depFileSize, out string depFileName); - - // Create and upload an empty file-- needed by ACR server - if (!TryCreateAndUploadEmptyTxtFile(tempPath, pkgNameLower, acrAccessToken)) - { - return false; - } - - // Create artifactconfig.json file - string depConfigFileName = "artifactconfig.json"; - var depConfigFilePath = System.IO.Path.Combine(tempPath, depConfigFileName); - while (File.Exists(depConfigFilePath)) - { - depConfigFilePath = System.IO.Path.Combine(tempPath, Guid.NewGuid().ToString() + ".json"); - } - if (!TryCreateDependencyConfigFile(depConfigFilePath, manifestResponse, depJsonContent, depDigest, depFileSize, depFileName, out string artifactDigest)) - { - return false; - } - - _cmdletPassedIn.WriteVerbose("Create the manifest"); - - // Upload Manifest - var depManifestResponse = UploadDependencyManifest(pkgNameLower, $"sha256:{artifactDigest}", depConfigFilePath, true, acrAccessToken).Result; - bool depManifestCreated = depManifestResponse.IsSuccessStatusCode; - if (!depManifestCreated) - { - _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( - new ArgumentException("Error uploading dependency manifest"), - "DependencyManifestUploadError", - ErrorCategory.InvalidResult, - _cmdletPassedIn)); - return false; - } - - _cmdletPassedIn.WriteVerbose("End of dependency processing"); - } - catch (Exception e) - { - throw new ProcessDependencyException("Error processing dependencies: " + e.Message); - } - finally - { - if (Directory.Exists(tempPath)) - { - // Delete the temp directory and all its contents - _cmdletPassedIn.WriteVerbose($"Attempting to delete '{tempPath}'"); - Utils.DeleteDirectoryWithRestore(tempPath); - } - } - - return true; - } - - private bool TryCreateAndUploadDependencyJson(string tempPath, - Hashtable dependencies, string pkgNameLower, string acrAccessToken, out string depJsonContent, out string depDigest, out long depFileSize, out string depFileName) - { - depFileName = "dependency.json"; - var depFilePath = System.IO.Path.Combine(tempPath, depFileName); - Utils.CreateFile(depFilePath); - FileInfo depFile = new FileInfo(depFilePath); - - depJsonContent = CreateDependencyJsonContent(dependencies); - File.WriteAllText(depFilePath, depJsonContent); - depFileSize = depFile.Length; - - bool depDigestCreated = CreateDigest(depFilePath, out depDigest, out ErrorRecord depDigestError); - if (depDigestError != null) - { - _cmdletPassedIn.ThrowTerminatingError(depDigestError); - } - - // Upload dependency.json - var depLocation = GetStartUploadBlobLocation(pkgNameLower, acrAccessToken).Result; - var depFileResponse = EndUploadBlob(depLocation, depFilePath, depDigest, isManifest: false, acrAccessToken).Result; - - return depFileResponse.IsSuccessStatusCode; - } - - private bool TryCreateAndUploadEmptyTxtFile(string tempPath, string pkgNameLower, string acrAccessToken) - { - _cmdletPassedIn.WriteVerbose("Create an empty artifact file"); - string emptyArtifactFileName = "artifactEmpty.txt"; - var emptyArtifactFilePath = System.IO.Path.Combine(tempPath, emptyArtifactFileName); - // Rename the empty file in case such a file already exists in the temp folder (although highly unlikely) - while (File.Exists(emptyArtifactFilePath)) - { - emptyArtifactFilePath = Guid.NewGuid().ToString() + ".txt"; - } - Utils.CreateFile(emptyArtifactFilePath); - - _cmdletPassedIn.WriteVerbose("Start uploading an empty artifact file"); - var emptyArtifactLocation = GetStartUploadBlobLocation(pkgNameLower, acrAccessToken).Result; - _cmdletPassedIn.WriteVerbose("Computing digest for empty file"); - bool emptyArtifactDigestCreated = CreateDigest(emptyArtifactFilePath, out string emptyArtifactDigest, out ErrorRecord emptyArtifactDigestError); - if (!emptyArtifactDigestCreated) - { - _cmdletPassedIn.ThrowTerminatingError(emptyArtifactDigestError); - } - _cmdletPassedIn.WriteVerbose("Finish uploading empty file"); - var emptyArtifactResponse = EndUploadBlob(emptyArtifactLocation, emptyArtifactFilePath, emptyArtifactDigest, false, acrAccessToken).Result; - - return emptyArtifactResponse.IsSuccessStatusCode; - } - - private bool TryCreateDependencyConfigFile(string depConfigFilePath, HttpResponseMessage manifestResponse, string depJsonContent, string depDigest, long depFileSize, - string depFileName, out string artifactDigest) - { - artifactDigest = string.Empty; - _cmdletPassedIn.WriteVerbose("Create the dependency config file"); - Utils.CreateFile(depConfigFilePath); - - _cmdletPassedIn.WriteVerbose("Computing digest for artifact config"); - bool depConfigDigestCreated = CreateDigest(depConfigFilePath, out string emptyConfigArtifactDigest, out ErrorRecord depConfigDigestError); - if (!depConfigDigestCreated) - { - _cmdletPassedIn.ThrowTerminatingError(depConfigDigestError); - } - FileInfo depConfigFile = new FileInfo(depConfigFilePath); - - - // Can either get the digest/size through response from pushing parent manifest earlier, - string[] parentLocation = manifestResponse.Headers.Location.OriginalString.Split(':'); - if (parentLocation == null && parentLocation.Length < 2) - { - _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( - new ArgumentException("Error creating dependency manifest. Parent manifest location is invalid."), - "DependencyManifestCreationError", - ErrorCategory.InvalidResult, - _cmdletPassedIn)); - return false; - } - - string parentDigest = parentLocation[1]; - var contentLength = manifestResponse.RequestMessage.Content.Headers.ContentLength; - if (contentLength == null) - { - _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( - new ArgumentException("Error creating dependency manifest. Parent manifest size is invalid."), - "DependencyManifestCreationError", - ErrorCategory.InvalidResult, - _cmdletPassedIn)); - } - long parentSize = (long)manifestResponse.RequestMessage.Content.Headers.ContentLength; - - // Create manifest for dependencies - var depJsonStr = depJsonContent.Replace("\r", String.Empty).Replace("\n", String.Empty); - string depFileContent = CreateDependencyManifestContent(emptyConfigArtifactDigest, 0, depDigest, depFileSize, depFileName, depJsonStr, parentDigest, parentSize); - File.WriteAllText(depConfigFilePath, depFileContent); - - var depManifestDigestCreated = CreateDigest(depConfigFilePath, out artifactDigest, out ErrorRecord artifactDigestError); - if (!depManifestDigestCreated) - { - _cmdletPassedIn.ThrowTerminatingError(artifactDigestError); - } - - return depManifestDigestCreated; - } - - private string CreateManifestContent( string nupkgDigest, string configDigest, @@ -1373,98 +1184,6 @@ private string CreateManifestContent( return stringWriter.ToString(); } - private string CreateDependencyManifestContent( - string depConfigDigest, - long depConfigFileSize, - string depDigest, - long depFileSize, - string depFileName, - string dependenciesStr, - string parentDigest, - long parentSize) - { - StringBuilder stringBuilder = new StringBuilder(); - StringWriter stringWriter = new StringWriter(stringBuilder); - JsonTextWriter jsonWriter = new JsonTextWriter(stringWriter); - - jsonWriter.Formatting = Newtonsoft.Json.Formatting.Indented; - - jsonWriter.WriteStartObject(); - - jsonWriter.WritePropertyName("schemaVersion"); - jsonWriter.WriteValue(2); - jsonWriter.WritePropertyName("mediaType"); - jsonWriter.WriteValue("application/vnd.oci.image.manifest.v1+json"); - - jsonWriter.WritePropertyName("config"); - jsonWriter.WriteStartObject(); - jsonWriter.WritePropertyName("mediaType"); - jsonWriter.WriteValue("dependency"); - jsonWriter.WritePropertyName("digest"); - jsonWriter.WriteValue($"sha256:{depConfigDigest}"); - jsonWriter.WritePropertyName("size"); - jsonWriter.WriteValue(depConfigFileSize); - jsonWriter.WriteEndObject(); - - jsonWriter.WritePropertyName("layers"); - jsonWriter.WriteStartArray(); - - jsonWriter.WriteStartObject(); - jsonWriter.WritePropertyName("mediaType"); - jsonWriter.WriteValue("application/vnd.oci.image.layer.v1.tar"); - jsonWriter.WritePropertyName("digest"); - jsonWriter.WriteValue($"sha256:{depDigest}"); - jsonWriter.WritePropertyName("size"); - jsonWriter.WriteValue(depFileSize); - jsonWriter.WritePropertyName("annotations"); - jsonWriter.WriteStartObject(); - jsonWriter.WritePropertyName("org.opencontainers.image.title"); - jsonWriter.WriteValue(depFileName);; - jsonWriter.WritePropertyName("dependencies"); - jsonWriter.WriteValue(dependenciesStr); - jsonWriter.WriteEndObject(); - - jsonWriter.WriteEndObject(); - - jsonWriter.WriteEndArray(); - - - jsonWriter.WritePropertyName("subject"); - jsonWriter.WriteStartObject(); - jsonWriter.WritePropertyName("mediaType"); - jsonWriter.WriteValue("application/vnd.oci.image.manifest.v1+json"); - jsonWriter.WritePropertyName("digest"); - jsonWriter.WriteValue($"sha256:{parentDigest}"); - jsonWriter.WritePropertyName("size"); - jsonWriter.WriteValue(parentSize); - jsonWriter.WriteEndObject(); - - jsonWriter.WriteEndObject(); - - return stringWriter.ToString(); - } - - private string CreateDependencyJsonContent(Hashtable dependencies) - { - StringBuilder stringBuilder = new StringBuilder(); - StringWriter stringWriter = new StringWriter(stringBuilder); - JsonTextWriter jsonWriter = new JsonTextWriter(stringWriter); - - jsonWriter.Formatting = Newtonsoft.Json.Formatting.Indented; - - jsonWriter.WriteStartObject(); - - foreach (string dependencyName in dependencies.Keys) - { - jsonWriter.WritePropertyName(dependencyName); - jsonWriter.WriteValue(dependencies[dependencyName]); - } - - jsonWriter.WriteEndObject(); - - return stringWriter.ToString(); - } - private bool CreateDigest(string fileName, out string digest, out ErrorRecord error) { FileInfo fileInfo = new FileInfo(fileName); @@ -1508,50 +1227,6 @@ private bool CreateDigest(string fileName, out string digest, out ErrorRecord er return true; } - private bool CreateDependencyDigest(out string digest, out ErrorRecord error) - { - SHA256 mySHA256 = SHA256.Create(); - string myGuid = new Guid().ToString(); - byte[] byteArray = Encoding.UTF8.GetBytes(myGuid); - - using (MemoryStream memStream = new MemoryStream(byteArray)) - { - digest = string.Empty; - - try - { - // Create a MemoryStream for the Guid. - // Be sure it's positioned to the beginning of the stream. - memStream.Position = 0; - // Compute the hash of the MemoryStream. - byte[] hashValue = mySHA256.ComputeHash(memStream); - StringBuilder stringBuilder = new StringBuilder(); - foreach (byte b in hashValue) - stringBuilder.AppendFormat("{0:x2}", b); - digest = stringBuilder.ToString(); - // Write the name and hash value of the file to the console. - _cmdletPassedIn.WriteVerbose($"dependency digest: {digest}"); - error = null; - } - catch (IOException ex) - { - var IOError = new ErrorRecord(ex, $"IOException for .nupkg file: {ex.Message}", ErrorCategory.InvalidOperation, null); - error = IOError; - } - catch (UnauthorizedAccessException ex) - { - var AuthorizationError = new ErrorRecord(ex, $"UnauthorizedAccessException for .nupkg file: {ex.Message}", ErrorCategory.PermissionDenied, null); - error = AuthorizationError; - } - } - if (error != null) - { - return false; - } - - return true; - } - private string CreateMetadataContent(string manifestFilePath, ResourceType resourceType, Hashtable parsedMetadata, out ErrorRecord metadataCreationError) { metadataCreationError = null; diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 04f8f646a..c678c8928 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -928,9 +928,6 @@ public static bool TryConvertFromACRJson( metadata["PublishedDate"] = ParseHttpDateTime(publishedElement.ToString()); } - // Dependencies - // TODO - // IsPrerelease if (rootDom.TryGetProperty("IsPrerelease", out JsonElement isPrereleaseElement)) { @@ -966,6 +963,12 @@ public static bool TryConvertFromACRJson( metadata["ReleaseNotes"] = releaseNotesElement.ToString(); } + // Dependencies + if (rootDom.TryGetProperty("RequiredModules", out JsonElement requiredModulesElement)) + { + metadata["Dependencies"] = ParseACRDependencies(requiredModulesElement, out errorMsg).ToArray(); + } + var additionalMetadataHashtable = new Dictionary { { "NormalizedVersion", metadata["NormalizedVersion"].ToString() } @@ -1464,7 +1467,7 @@ private static Version ParseHttpVersion(string versionString, out string prerele return new System.Version(); } - public static Uri ParseHttpUrl(string uriString) + internal static Uri ParseHttpUrl(string uriString) { Uri parsedUri; Uri.TryCreate(uriString, UriKind.Absolute, out parsedUri); @@ -1472,13 +1475,13 @@ public static Uri ParseHttpUrl(string uriString) return parsedUri; } - public static DateTime? ParseHttpDateTime(string publishedString) + internal static DateTime? ParseHttpDateTime(string publishedString) { DateTime.TryParse(publishedString, out DateTime parsedDateTime); return parsedDateTime; } - public static Dependency[] ParseHttpDependencies(string dependencyString) + internal static Dependency[] ParseHttpDependencies(string dependencyString) { /* Az.Profile:[0.1.0, ):|Az.Aks:[0.1.0, ):|Az.AnalysisServices:[0.1.0, ): @@ -1515,6 +1518,71 @@ public static Dependency[] ParseHttpDependencies(string dependencyString) return dependencyList.ToArray(); } + internal static List ParseACRDependencies(JsonElement requiredModulesElement, out string errorMsg) + { + errorMsg = string.Empty; + List pkgDeps = new List(); + if (requiredModulesElement.ValueKind == JsonValueKind.Array) + { + foreach (var dependency in requiredModulesElement.EnumerateArray()) + { + if (dependency.ValueKind == JsonValueKind.String) + { + // Dependency name with no specified version + pkgDeps.Add(new Dependency(dependency.GetString(), VersionRange.All)); + } + else if (dependency.ValueKind == JsonValueKind.Object) + { + // Dependency hashtable + string depName = string.Empty; + VersionRange depVersionRange = VersionRange.All; + if (dependency.TryGetProperty("ModuleName", out JsonElement depNameElement)) + { + depName = depNameElement.ToString(); + } + + if (dependency.TryGetProperty("ModuleVersion", out JsonElement depModuleVersionElement)) + { + // New-ScriptFileInfo will add "RequiredVersion" value as "null" if nothing is explicitly passed in + if (!NuGetVersion.TryParse(depModuleVersionElement.ToString(), out NuGetVersion depNuGetVersion)) + { + errorMsg = string.Format("Error parsing 'ModuleVersion' property from 'RequiredModules' in metadata."); + return pkgDeps; + } + + depVersionRange = new VersionRange( + minVersion: depNuGetVersion, + includeMinVersion: true); + } + else if (dependency.TryGetProperty("RequiredVersion", out JsonElement depRequiredVersionElement)) + { + // New-ScriptFileInfo will add "RequiredVersion" value as "null" if nothing is explicitly passed in, + // Which gets translated to an empty string. + // In this case, we just want the VersionRange to be VersionRange.All + if (!string.Equals(depModuleVersionElement.ToString(), string.Empty)) + { + if (!NuGetVersion.TryParse(depRequiredVersionElement.ToString(), out NuGetVersion depNuGetVersion)) + { + errorMsg = string.Format("Error parsing 'RequiredVersion' property from 'RequiredModules' in metadata."); + return pkgDeps; + } + + depVersionRange = new VersionRange( + minVersion: depNuGetVersion, + includeMinVersion: true, + maxVersion: depNuGetVersion, + includeMaxVersion: true); + } + } + + pkgDeps.Add(new Dependency(depName, depVersionRange)); + } + } + } + + return pkgDeps; + } + private static ResourceType ParseHttpMetadataType( string[] tags, out ArrayList commandNames, diff --git a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 index 27c210b3b..27a6ae020 100644 --- a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 @@ -8,6 +8,8 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { BeforeAll{ $testModuleName = "test_local_mod" + $testModuleParentName = "test_parent_mod" + $testModuleDependencyName = "test_dependency_mod" $testScript = "testscript" $ACRRepoName = "ACRRepo" $ACRRepoUri = "https://psresourcegettest.azurecr.io" @@ -80,6 +82,14 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Count | Should -BeGreaterOrEqual 1 } + It "Find module and dependencies when -IncludeDependencies is specified" { + $res = Find-PSResource -Name $testModuleParentName -Repository $ACRRepoName -IncludeDependencies + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -Be @($testModuleParentName, $testModuleDependencyName) + $res.Version[0].ToString() | Should -Be "1.0.0" + $res.Version[1].ToString() | Should -Be "1.0.0" + } + It "Find resource given specific Name, Version null but allowing Prerelease" { # FindName() $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName -Prerelease diff --git a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 index 273b85c3b..2fcf6f827 100644 --- a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 @@ -10,6 +10,8 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { BeforeAll { $testModuleName = "test_local_mod" $testModuleName2 = "test_local_mod2" + $testModuleParentName = "test_parent_mod" + $testModuleDependencyName = "test_dependency_mod" $testScriptName = "testscript" $ACRRepoName = "ACRRepo" $ACRRepoUri = "https://psresourcegettest.azurecr.io/" @@ -130,6 +132,17 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { $pkg.Version | Should -Be "5.0.0" } + It "Install resource with a dependency (should install both parent and dependency)" { + Install-PSResource -Name $testModuleParentName -Repository $ACRRepoName -TrustRepository + + $parentPkg = Get-InstalledPSResource $testModuleParentName + $parentPkg.Name | Should -Be $testModuleParentName + $parentPkg.Version | Should -Be "1.0.0" + $childPkg = Get-InstalledPSResource $testModuleDependencyName + $childPkg.Name | Should -Be $testModuleDependencyName + $childPkg.Version | Should -Be "1.0.0" + } + It "Install resource with latest (including prerelease) version given Prerelease parameter" { Install-PSResource -Name $testModuleName -Prerelease -Repository $ACRRepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName diff --git a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 index 4c2087db9..668e2a40f 100644 --- a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 @@ -268,11 +268,11 @@ Describe "Test Publish-PSResource" -tags 'CI' { It "Publish a module with one dependency" { $version = "11.0.0" - $dependencyName = 'test_module' - $dependencyVersion = '9.0.0' + $dependencyName = 'test_dependency_mod' + $dependencyVersion = '1.0.0' # New-ModuleManifest requires that the module be installed before it can be added as a dependency - Install-PSResource -Name $dependencyName -Version $dependencyVersion -Repository $ACRRepoName -TrustRepository -Verbose + Install-PSResource -Name $dependencyName -Version $dependencyVersion -Repository $ACRRepoName -TrustRepository New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -RequiredModules @(@{ ModuleName = $dependencyName; ModuleVersion = $dependencyVersion }) Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName @@ -281,20 +281,19 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results | Should -Not -BeNullOrEmpty $results[0].Name | Should -Be $script:PublishModuleName $results[0].Version | Should -Be $version - # TODO: Uncomment when Find-PSResource returns dependencies - #$results[0].Dependencies.Name | Should -Be $dependencyName - #$results[0].Dependencies.VersionRange | Should -Be $dependencyVersion + $results[0].Dependencies.Name | Should -Be $dependencyName + $results[0].Dependencies.VersionRange.MinVersion.ToString() | Should -Be $dependencyVersion } It "Publish a module with multiple dependencies" { $version = "12.0.0" - $dependency1Name = 'testdep' - $dependency2Name = 'test_local_mod' - $dependency2Version = '5.0.0' + $dependency1Name = 'test_dependency_mod2' + $dependency2Name = 'test_dependency_mod' + $dependency2Version = '1.0.0' # New-ModuleManifest requires that the module be installed before it can be added as a dependency - Install-PSResource -Name $dependency1Name -Repository $ACRRepoName -TrustRepository -Verbose - Install-PSResource -Name $dependency2Name -Version $dependency2Version -Repository $ACRRepoName -TrustRepository -verbose + Install-PSResource -Name $dependency1Name -Repository $ACRRepoName -TrustRepository -Verbose -Reinstall + Install-PSResource -Name $dependency2Name -Version $dependency2Version -Repository $ACRRepoName -TrustRepository -Reinstall New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -RequiredModules @( $dependency1Name , @{ ModuleName = $dependency2Name; ModuleVersion = $dependency2Version }) Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName @@ -303,9 +302,8 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results | Should -Not -BeNullOrEmpty $results[0].Name | Should -Be $script:PublishModuleName $results[0].Version | Should -Be $version - # TODO: Uncomment when Find-PSResource returns dependencies - #$results[0].Dependencies.Name | Should -Be $dependency1Name, $dependency2Name - #$results[0].Dependencies.VersionRange | Should -Be $dependency2Version + $results[0].Dependencies.Name | Should -Be $dependency1Name, $dependency2Name + $results[0].Dependencies.VersionRange.MinVersion.OriginalVersion.ToString() | Should -Be $dependency2Version } It "Publish a module and clean up properly when file in module is readonly" { From 1dd4bfbd748fb4984bde7811528478d67e4ee85b Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 12 Mar 2024 12:37:18 -0700 Subject: [PATCH 058/160] ACR: Add resource type to returned PSResourceInfo object (#1594) --- src/code/ACRResponseUtil.cs | 18 +- src/code/ACRServerAPICalls.cs | 164 ++++++++++-------- src/code/ContainerRegistryInfo.cs | 50 ++++++ src/code/PSResourceInfo.cs | 5 +- .../FindPSResourceACRServer.Tests.ps1 | 18 ++ 5 files changed, 172 insertions(+), 83 deletions(-) create mode 100644 src/code/ContainerRegistryInfo.cs diff --git a/src/code/ACRResponseUtil.cs b/src/code/ACRResponseUtil.cs index 9f5fe4217..e3176327f 100644 --- a/src/code/ACRResponseUtil.cs +++ b/src/code/ACRResponseUtil.cs @@ -42,20 +42,24 @@ public override IEnumerable ConvertToPSResourceResult(FindResu string responseConversionError = String.Empty; PSResourceInfo pkg = null; - string packageName = string.Empty; - string packageMetadata = null; + // Hashtable should have keys for Name, Metadata, ResourceType + if (!response.ContainsKey("Name") && string.IsNullOrWhiteSpace(response["Name"].ToString())) + { + yield return new PSResourceResult(returnedObject: pkg, exception: new ConvertToPSResourceException("Error retrieving package name from response."), isTerminatingError: true); + } - foreach (DictionaryEntry entry in response) + if (!response.ContainsKey("Metadata")) { - packageName = (string)entry.Key; - packageMetadata = (string)entry.Value; + yield return new PSResourceResult(returnedObject: pkg, exception: new ConvertToPSResourceException("Error retrieving package metadata from response."), isTerminatingError: true); } + ResourceType? resourceType = response.ContainsKey("ResourceType") ? response["ResourceType"] as ResourceType? : ResourceType.None; + try { - using (JsonDocument pkgVersionEntry = JsonDocument.Parse(packageMetadata)) + using (JsonDocument pkgVersionEntry = JsonDocument.Parse(response["Metadata"].ToString())) { - PSResourceInfo.TryConvertFromACRJson(packageName, pkgVersionEntry, out pkg, Repository, out responseConversionError); + PSResourceInfo.TryConvertFromACRJson(response["Name"].ToString(), pkgVersionEntry, resourceType, out pkg, Repository, out responseConversionError); } } catch (Exception e) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index e2e19b225..3fee380e2 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -243,7 +243,7 @@ public override FindResults FindVersion(string packageName, string version, Reso return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); } - + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{requiredVersion}'"); bool includePrereleaseVersions = requiredVersion.IsPrerelease; @@ -563,88 +563,99 @@ internal Hashtable GetACRMetadata(string registry, string packageName, string ex * } */ - Tuple metadataTuple = GetMetadataProperty(foundTags, packageName, out Exception exception); + var serverPkgInfo = GetMetadataProperty(foundTags, packageName, out Exception exception); if (exception != null) { - errRecord = new ErrorRecord(exception, "FindNameFailure", ErrorCategory.InvalidResult, this); + errRecord = new ErrorRecord(exception, "ParseMetadataFailure", ErrorCategory.InvalidResult, this); return requiredVersionResponse; } - string metadataPkgName = metadataTuple.Item1; - string metadata = metadataTuple.Item2; - string pkgVersionString = String.Empty; - using (JsonDocument metadataJSONDoc = JsonDocument.Parse(metadata)) + try { - JsonElement rootDom = metadataJSONDoc.RootElement; - if (rootDom.TryGetProperty("ModuleVersion", out JsonElement pkgVersionElement)) + using (JsonDocument metadataJSONDoc = JsonDocument.Parse(serverPkgInfo.Metadata)) { - // module metadata will have "ModuleVersion" property - pkgVersionString = pkgVersionElement.ToString(); - if (rootDom.TryGetProperty("PrivateData", out JsonElement pkgPrivateDataElement) && pkgPrivateDataElement.TryGetProperty("PSData", out JsonElement pkgPSDataElement) - && pkgPSDataElement.TryGetProperty("Prerelease", out JsonElement pkgPrereleaseLabelElement) && !String.IsNullOrEmpty(pkgPrereleaseLabelElement.ToString().Trim())) + string pkgVersionString = String.Empty; + JsonElement rootDom = metadataJSONDoc.RootElement; + if (rootDom.TryGetProperty("ModuleVersion", out JsonElement pkgVersionElement)) { - pkgVersionString += $"-{pkgPrereleaseLabelElement.ToString()}"; + // module metadata will have "ModuleVersion" property + pkgVersionString = pkgVersionElement.ToString(); + if (rootDom.TryGetProperty("PrivateData", out JsonElement pkgPrivateDataElement) && pkgPrivateDataElement.TryGetProperty("PSData", out JsonElement pkgPSDataElement) + && pkgPSDataElement.TryGetProperty("Prerelease", out JsonElement pkgPrereleaseLabelElement) && !String.IsNullOrEmpty(pkgPrereleaseLabelElement.ToString().Trim())) + { + pkgVersionString += $"-{pkgPrereleaseLabelElement.ToString()}"; + } } - } - else if(rootDom.TryGetProperty("Version", out pkgVersionElement)) - { - // script metadata will have "Version" property - pkgVersionString = pkgVersionElement.ToString(); - } - else - { - errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain 'ModuleVersion' or 'Version' property in metadata for package '{packageName}' in '{Repository.Name}'."), - "FindNameFailure", - ErrorCategory.InvalidResult, - this); + else if (rootDom.TryGetProperty("Version", out pkgVersionElement)) + { + // script metadata will have "Version" property + pkgVersionString = pkgVersionElement.ToString(); + } + else + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain 'ModuleVersion' or 'Version' property in metadata for package '{packageName}' in '{Repository.Name}'."), + "ParseMetadataFailure", + ErrorCategory.InvalidResult, + this); - return requiredVersionResponse; - } + return requiredVersionResponse; + } - if (!NuGetVersion.TryParse(pkgVersionString, out NuGetVersion pkgVersion)) - { - errRecord = new ErrorRecord( - new ArgumentException($"Version {pkgVersionString} to be parsed from metadata is not a valid NuGet version."), - "FindNameFailure", - ErrorCategory.InvalidArgument, - this); + if (!NuGetVersion.TryParse(pkgVersionString, out NuGetVersion pkgVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Version {pkgVersionString} to be parsed from metadata is not a valid NuGet version."), + "ParseMetadataFailure", + ErrorCategory.InvalidArgument, + this); - return requiredVersionResponse; - } + return requiredVersionResponse; + } - if (!NuGetVersion.TryParse(exactTagVersion, out NuGetVersion requiredVersion)) - { - errRecord = new ErrorRecord( - new ArgumentException($"Version {exactTagVersion} to be parsed from method input is not a valid NuGet version."), - "FindNameFailure", - ErrorCategory.InvalidArgument, - this); + if (!NuGetVersion.TryParse(exactTagVersion, out NuGetVersion requiredVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Version {exactTagVersion} to be parsed from method input is not a valid NuGet version."), + "ParseMetadataFailure", + ErrorCategory.InvalidArgument, + this); - return requiredVersionResponse; - } + return requiredVersionResponse; + } - _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); - if (pkgVersion.ToNormalizedString() == requiredVersion.ToNormalizedString()) - { - requiredVersionResponse.Add(metadataPkgName, metadata); + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + if (pkgVersion.ToNormalizedString() == requiredVersion.ToNormalizedString()) + { + requiredVersionResponse = serverPkgInfo.ToHashtable(); + } } } + catch (Exception e) + { + errRecord = new ErrorRecord( + new ArgumentException($"Error parsing server metadata: {e.Message}"), + "ParseMetadataFailure", + ErrorCategory.InvalidData, + this); + + return requiredVersionResponse; + } return requiredVersionResponse; } - internal Tuple GetMetadataProperty(JObject foundTags, string packageName, out Exception exception) + internal ContainerRegistryInfo GetMetadataProperty(JObject foundTags, string packageName, out Exception exception) { exception = null; - var emptyTuple = new Tuple(string.Empty, string.Empty); + ContainerRegistryInfo serverPkgInfo = null; var layers = foundTags["layers"]; if (layers == null || layers[0] == null) { exception = new InvalidOrEmptyResponse($"Response does not contain 'layers' element in manifest for package '{packageName}' in '{Repository.Name}'."); - return emptyTuple; + return serverPkgInfo; } var annotations = layers[0]["annotations"]; @@ -652,35 +663,40 @@ internal Tuple GetMetadataProperty(JObject foundTags, string pack { exception = new InvalidOrEmptyResponse($"Response does not contain 'annotations' element in manifest for package '{packageName}' in '{Repository.Name}'."); - return emptyTuple; + return serverPkgInfo; } - if (annotations["metadata"] == null) + // Check for package name + var pkgTitleJToken = annotations["org.opencontainers.image.title"]; + if (pkgTitleJToken == null) { - exception = new InvalidOrEmptyResponse($"Response does not contain 'metadata' element in manifest for package '{packageName}' in '{Repository.Name}'."); + exception = new InvalidOrEmptyResponse($"Response does not contain 'org.opencontainers.image.title' element for package '{packageName}' in '{Repository.Name}'."); - return emptyTuple; + return serverPkgInfo; } - - var metadata = annotations["metadata"].ToString(); - - var metadataPkgTitleJToken = annotations["org.opencontainers.image.title"]; - if (metadataPkgTitleJToken == null) + string metadataPkgName = pkgTitleJToken.ToString(); + if (string.IsNullOrWhiteSpace(metadataPkgName)) { - exception = new InvalidOrEmptyResponse($"Response does not contain 'org.opencontainers.image.title' element for package '{packageName}' in '{Repository.Name}'."); + exception = new InvalidOrEmptyResponse($"Response element 'org.opencontainers.image.title' is empty for package '{packageName}' in '{Repository.Name}'."); - return emptyTuple; + return serverPkgInfo; } - string metadataPkgName = metadataPkgTitleJToken.ToString(); - if (string.IsNullOrWhiteSpace(metadataPkgName)) + // Check for package metadata + var pkgMetadataJToken = annotations["metadata"]; + if (pkgMetadataJToken == null) { - exception = new InvalidOrEmptyResponse($"Response element 'org.opencontainers.image.title' is empty for package '{packageName}' in '{Repository.Name}'."); + exception = new InvalidOrEmptyResponse($"Response does not contain 'metadata' element in manifest for package '{packageName}' in '{Repository.Name}'."); - return emptyTuple; + return serverPkgInfo; } + var metadata = pkgMetadataJToken.ToString(); + + // Check for package artifact type + var resourceTypeJToken = annotations["resourceType"]; + var resourceType = resourceTypeJToken != null ? resourceTypeJToken.ToString() : string.Empty; - return new Tuple(metadataPkgName, metadata); + return new ContainerRegistryInfo(metadataPkgName, metadata, resourceType); } internal JObject FindAcrManifest(string registry, string packageName, string version, string acrAccessToken, out ErrorRecord errRecord) @@ -1026,7 +1042,7 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p } // Create and upload manifest - TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, pkgName, resourceType, metadataJson, configFilePath, + TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, pkgName, resourceType, metadataJson, configFilePath, pkgNameLower, pkgVersion, acrAccessToken); return true; @@ -1172,7 +1188,7 @@ private string CreateManifestContent( jsonWriter.WriteValue(fileName); jsonWriter.WritePropertyName("metadata"); jsonWriter.WriteValue(metadata); - jsonWriter.WritePropertyName("artifactType"); + jsonWriter.WritePropertyName("resourceType"); jsonWriter.WriteValue(resourceType.ToString()); jsonWriter.WriteEndObject(); // end of annotations object @@ -1295,7 +1311,7 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp var pkgsInDescendingOrder = sortedQualifyingPkgs.Reverse(); - foreach(var pkgVersionTag in pkgsInDescendingOrder) + foreach (var pkgVersionTag in pkgsInDescendingOrder) { string exactTagVersion = pkgVersionTag.Value.ToString(); Hashtable metadata = GetACRMetadata(Registry, packageNameLowercase, exactTagVersion, acrAccessToken, out errRecord); @@ -1310,7 +1326,7 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp // getOnlyLatest will be true for FindName(), as only the latest criteria satisfying version should be returned break; } - } + } return latestVersionResponse.ToArray(); } diff --git a/src/code/ContainerRegistryInfo.cs b/src/code/ContainerRegistryInfo.cs new file mode 100644 index 000000000..6d8ff5c32 --- /dev/null +++ b/src/code/ContainerRegistryInfo.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections; + +namespace Microsoft.PowerShell.PSResourceGet.UtilClasses +{ + + public sealed class ContainerRegistryInfo + { + #region Properties + + public string Name { get; } + public string Metadata { get; } + public ResourceType ResourceType { get; } + + #endregion + + + #region Constructors + + internal ContainerRegistryInfo(string name, string metadata, string resourceType) + + { + Name = name ?? string.Empty; + Metadata = metadata ?? string.Empty; + ResourceType = string.IsNullOrWhiteSpace(resourceType) ? ResourceType.None : + (ResourceType)Enum.Parse(typeof(ResourceType), resourceType, ignoreCase: true); + } + + #endregion + + #region Methods + + internal Hashtable ToHashtable() + { + Hashtable hashtable = new Hashtable + { + { "Name", Name }, + { "Metadata", Metadata }, + { "ResourceType", ResourceType } + }; + + return hashtable; + } + + #endregion + } +} diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index c678c8928..49c57b8eb 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -810,6 +810,7 @@ public static bool TryConvertFromJson( public static bool TryConvertFromACRJson( string packageName, JsonDocument packageMetadata, + ResourceType? resourceType, out PSResourceInfo psGetInfo, PSRepositoryInfo repository, out string errorMsg) @@ -973,7 +974,7 @@ public static bool TryConvertFromACRJson( { { "NormalizedVersion", metadata["NormalizedVersion"].ToString() } }; - + psGetInfo = new PSResourceInfo( additionalMetadata: additionalMetadataHashtable, author: metadata["Authors"] as String, @@ -996,7 +997,7 @@ public static bool TryConvertFromACRJson( repository: repository.Name, repositorySourceLocation: repository.Uri.ToString(), tags: metadata["Tags"] as string[], - type: ResourceType.None, + type: resourceType ?? ResourceType.None, updatedDate: null, version: metadata["Version"] as Version); diff --git a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 index 27a6ae020..98627d3e8 100644 --- a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 @@ -192,4 +192,22 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Version | Should -Be "3.5.0" $res.Prerelease | Should -Be "alpha" } + + It "Should find and return correct resource type - module" { + $moduleName = "test_dependency_mod" + $res = Find-PSResource -Name $moduleName -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $moduleName + $res.Version | Should -Be "1.0.0" + $res.Type.ToString() | Should -Be "Module" + } + + It "Should find and return correct resource type - script" { + $scriptName = "test-script" + $res = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $scriptName + $res.Version | Should -Be "1.0.0" + $res.Type.ToString() | Should -Be "Script" + } } From 3b117ac46e39455acdeb02488cd913f5074e1e56 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Tue, 12 Mar 2024 15:20:58 -0700 Subject: [PATCH 059/160] Exclude Managed Identity and ShareTokenCache Credential from DefaultAzureCredential options (#1595) --- src/code/Utils.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index b72ad090e..16bbff287 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -648,12 +648,11 @@ public static string GetAzAccessToken() ExcludeVisualStudioCodeCredential = true, ExcludeVisualStudioCredential = true, ExcludeWorkloadIdentityCredential = true, - ExcludeManagedIdentityCredential = false, // ManagedIdentityCredential makes the experience slow - + ExcludeManagedIdentityCredential = true, // ManagedIdentityCredential makes the experience slow + ExcludeSharedTokenCacheCredential = true, // SharedTokenCacheCredential is not supported on macOS ExcludeAzureCliCredential = false, ExcludeAzurePowerShellCredential = false, - ExcludeInteractiveBrowserCredential = false, - ExcludeSharedTokenCacheCredential = false + ExcludeInteractiveBrowserCredential = false }; var dCred = new DefaultAzureCredential(credOptions); From 57e630f87e162e65edc35a6ff5f59f87c4ca77f1 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 12 Mar 2024 18:41:39 -0400 Subject: [PATCH 060/160] Acr tests cleanup (#1590) --- .ci/test.yml | 30 ++++++++++ test/PSGetTestUtils.psm1 | 17 ++++++ .../PublishPSResourceACRServer.Tests.ps1 | 57 +++++++++---------- 3 files changed, 73 insertions(+), 31 deletions(-) diff --git a/.ci/test.yml b/.ci/test.yml index b527e0617..c5a20e40e 100644 --- a/.ci/test.yml +++ b/.ci/test.yml @@ -90,6 +90,19 @@ jobs: displayName: 'Set UsingAzAuth environment variable' condition: eq(${{ parameters.useAzAuth }}, true) + - task: AzurePowerShell@5 + inputs: + azureSubscription: PSResourceGetACR + azurePowerShellVersion: LatestVersion + ScriptType: InlineScript + inline: | + $acrRepositoryNamesFolder = Join-Path -Path ([Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData)) -ChildPath 'TempModules' + Write-Verbose -Verbose "Creating new folder for acr repository names file to be placed with path: $acrRepositoryNamesFolder" + $null = New-Item -Path $acrRepositoryNamesFolder -ItemType Directory + $acrRepositoryNamesFilePath = Join-Path -Path $acrRepositoryNamesFolder -ChildPath 'ACRTestRepositoryNames.txt' + New-Item -Path $acrRepositoryNamesFilePath + displayName: 'Upload empty file for ACR functional tests to write test repository names to' + - task: AzurePowerShell@5 inputs: azureSubscription: PSResourceGetACR @@ -124,3 +137,20 @@ jobs: workingDirectory: ${{ parameters.buildDirectory }} errorActionPreference: continue condition: eq(${{ parameters.useAzAuth }}, false) + + - task: AzurePowerShell@5 + inputs: + azureSubscription: PSResourceGetACR + azurePowerShellVersion: LatestVersion + ScriptType: InlineScript + inline: | + $registryName = 'psresourcegettest' + $acrRepositoryNamesFolder = Join-Path -Path ([Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData)) -ChildPath 'TempModules' + $acrRepositoryNamesFilePath = Join-Path -Path $acrRepositoryNamesFolder -ChildPath 'ACRTestRepositoryNames.txt' + $repositoryNames = Get-Content -Path $acrRepositoryNamesFilePath + foreach ($name in $repositoryNames) + { + # Delete images in the repository (including tags, unique layers, manifests) created for ACR tests + Remove-AzContainerRegistryRepository -Name $name -RegistryName $registryName + } + displayName: 'Delete test repositories from ACR' diff --git a/test/PSGetTestUtils.psm1 b/test/PSGetTestUtils.psm1 index 1d5c51ed6..c9f5006b4 100644 --- a/test/PSGetTestUtils.psm1 +++ b/test/PSGetTestUtils.psm1 @@ -747,3 +747,20 @@ function CheckForExpectedPSGetInfo $psGetInfo.UpdatedDate.Year | Should -BeExactly 1 $psGetInfo.Version | Should -Be "1.1.0" } + +function Set-TestACRRepositories +{ + Param( + [string[]] + $repositoryNames + ) + + $acrRepositoryNamesFolder = Join-Path -Path ([Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData)) -ChildPath 'TempModules' + $acrRepositoryNamesFilePath = Join-Path -Path $acrRepositoryNamesFolder -ChildPath 'ACRTestRepositoryNames.txt' + $fileExists = Test-Path -Path $acrRepositoryNamesFilePath + + if ($fileExists) + { + $repositoryNames | Out-File -FilePath $acrRepositoryNamesFilePath + } +} diff --git a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 index 668e2a40f..59661a028 100644 --- a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 @@ -8,7 +8,7 @@ function CreateTestModule { param ( [string] $Path = "$TestDrive", - [string] $ModuleName = 'temp-psresourcegettemptestmodule' + [string] $ModuleName = 'temp-testmodule' ) $modulePath = Join-Path -Path $Path -ChildPath $ModuleName @@ -63,7 +63,7 @@ Describe "Test Publish-PSResource" -tags 'CI' { # Create module $script:tmpModulesPath = Join-Path -Path $TestDrive -ChildPath "tmpModulesPath" - $script:PublishModuleName = "temp-psresourcegettemptestmodule" + [System.Guid]::NewGuid(); + $script:PublishModuleName = "temp-testmodule" + [System.Guid]::NewGuid(); $script:PublishModuleBase = Join-Path $script:tmpModulesPath -ChildPath $script:PublishModuleName if(!(Test-Path $script:PublishModuleBase)) { @@ -71,13 +71,10 @@ Describe "Test Publish-PSResource" -tags 'CI' { } $script:PublishModuleBaseUNC = $script:PublishModuleBase -Replace '^(.):', '\\localhost\$1$' - #Create dependency module - $script:DependencyModuleName = "TEMP-PackageManagement" - $script:DependencyModuleBase = Join-Path $script:tmpModulesPath -ChildPath $script:DependencyModuleName - if(!(Test-Path $script:DependencyModuleBase)) - { - New-Item -Path $script:DependencyModuleBase -ItemType Directory -Force - } + # create names of other modules and scripts that will be referenced in test + $script:ModuleWithoutRequiredModuleName = "temp-testmodulewithoutrequiredmodule-" + [System.Guid]::NewGuid() + $script:ScriptName = "temp-testscript" + [System.Guid]::NewGuid() + $script:ScriptWithExternalDeps = "temp-testscriptwithexternaldeps" + [System.Guid]::NewGuid() # Create temp destination path $script:destinationPath = [IO.Path]::GetFullPath((Join-Path -Path $TestDrive -ChildPath "tmpDestinationPath")) @@ -107,19 +104,22 @@ Describe "Test Publish-PSResource" -tags 'CI' { } AfterAll { Get-RevertPSResourceRepositoryFile + + # Note: all repository names provided as test packages for ACR, must have lower cased names, otherwise the Az cmdlets will not be able to properly find and delete it. + $acrRepositoryNames = @($script:PublishModuleName, $script:ModuleWithoutRequiredModuleName, $script:ScriptName, $script:ScriptWithExternalDeps) + Set-TestACRRepositories $acrRepositoryNames } It "Publish module with required module not installed on the local machine using -SkipModuleManifestValidate" { - $ModuleName = "modulewithmissingrequiredmodule-" + [System.Guid]::NewGuid() - CreateTestModule -Path $TestDrive -ModuleName $ModuleName + CreateTestModule -Path $TestDrive -ModuleName $script:ModuleWithoutRequiredModuleName # Skip the module manifest validation test, which fails from the missing manifest required module. - $testModulePath = Join-Path -Path $TestDrive -ChildPath $ModuleName + $testModulePath = Join-Path -Path $TestDrive -ChildPath $script:ModuleWithoutRequiredModuleName Publish-PSResource -Path $testModulePath -Repository $ACRRepoName -Confirm:$false -SkipDependenciesCheck -SkipModuleManifestValidate - $results = Find-PSResource -Name $ModuleName -Repository $ACRRepoName + $results = Find-PSResource -Name $script:ModuleWithoutRequiredModuleName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $ModuleName + $results[0].Name | Should -Be $script:ModuleWithoutRequiredModuleName $results[0].Version | Should -Be "1.0.0" } @@ -340,32 +340,29 @@ Describe "Test Publish-PSResource" -tags 'CI' { } It "Publish a script"{ - $scriptBaseName = "temp-testscript" - $scriptName = $scriptBaseName + [System.Guid]::NewGuid(); $scriptVersion = "1.0.0" - $params = @{ Version = $scriptVersion GUID = [guid]::NewGuid() Author = 'Jane' CompanyName = 'Microsoft Corporation' Copyright = '(c) 2024 Microsoft Corporation. All rights reserved.' - Description = "Description for the $scriptBaseName script" - LicenseUri = "https://$scriptBaseName.com/license" - IconUri = "https://$scriptBaseName.com/icon" - ProjectUri = "https://$scriptBaseName.com" - Tags = @('Tag1','Tag2', "Tag-$scriptBaseName-$scriptVersion") - ReleaseNotes = "$scriptBaseName release notes" + Description = "Description for the $script:ScriptName script" + LicenseUri = "https://$script:ScriptName.com/license" + IconUri = "https://$script:ScriptName.com/icon" + ProjectUri = "https://$script:ScriptName.com" + Tags = @('Tag1','Tag2', "Tag-$script:ScriptName-$scriptVersion") + ReleaseNotes = "$script:ScriptName release notes" } - $scriptPath = (Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$scriptName.ps1") + $scriptPath = (Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$script:ScriptName.ps1") New-PSScriptFileInfo @params -Path $scriptPath Publish-PSResource -Path $scriptPath -Repository $ACRRepoName - $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $results = Find-PSResource -Name $script:ScriptName -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $scriptName + $results[0].Name | Should -Be $script:ScriptName $results[0].Version | Should -Be $scriptVersion } @@ -396,16 +393,15 @@ Describe "Test Publish-PSResource" -tags 'CI' { } It "Should publish a script with ExternalModuleDependencies that are not published" { - $scriptName = "ScriptWithExternalDependencies" $scriptVersion = "1.0.0" - $scriptPath = Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1" + $scriptPath = Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$script:ScriptWithExternalDeps.ps1" New-PSScriptFileInfo -Description 'test' -Version $scriptVersion -RequiredModules @{ModuleName='testModule'} -ExternalModuleDependencies 'testModule' -Path $scriptPath -Force Publish-PSResource -Path $scriptPath -Repository $ACRRepoName - $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $results = Find-PSResource -Name $script:ScriptWithExternalDeps -Repository $ACRRepoName $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $scriptName + $results[0].Name | Should -Be $script:ScriptWithExternalDeps $results[0].Version | Should -Be $scriptVersion } @@ -482,7 +478,6 @@ Describe "Test Publish-PSResource" -tags 'CI' { { Publish-PSResource -Path $incorrectmoduleversion -Repository $ACRRepoName -ErrorAction Stop } | Should -Throw -ErrorId "InvalidModuleManifest,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" } - It "Publish a module with a dependency that has an invalid version format, should throw" { $moduleName = "incorrectdepmoduleversion" $incorrectdepmoduleversion = Join-Path -Path $script:testModulesFolderPath -ChildPath $moduleName From d823c7126ba995bcc0fa01dfe5f76a326e65870b Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 13 Mar 2024 13:34:54 -0400 Subject: [PATCH 061/160] Ensure InstallVersion() is case insensitive (#1598) --- src/code/ACRServerAPICalls.cs | 11 ++-- .../FindPSResourceACRServer.Tests.ps1 | 55 ++++++++++++------- .../InstallPSResourceACRServer.Tests.ps1 | 46 +++++++++++----- .../PublishPSResourceACRServer.Tests.ps1 | 40 ++++++++------ 4 files changed, 97 insertions(+), 55 deletions(-) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ACRServerAPICalls.cs index 3fee380e2..b660ef412 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ACRServerAPICalls.cs @@ -305,11 +305,12 @@ public override Stream InstallPackage(string packageName, string packageVersion, } private Stream InstallVersion( - string moduleName, + string packageName, string moduleVersion, out ErrorRecord errRecord) { errRecord = null; + string packageNameLowercase = packageName.ToLower(); string accessToken = string.Empty; string tenantID = string.Empty; string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); @@ -323,8 +324,8 @@ private Stream InstallVersion( } string registry = Repository.Uri.Host; - _cmdletPassedIn.WriteVerbose($"Getting manifest for {moduleName} - {moduleVersion}"); - var manifest = GetAcrRepositoryManifestAsync(registry, moduleName, moduleVersion, acrAccessToken, out errRecord); + _cmdletPassedIn.WriteVerbose($"Getting manifest for {packageNameLowercase} - {moduleVersion}"); + var manifest = GetAcrRepositoryManifestAsync(registry, packageNameLowercase, moduleVersion, acrAccessToken, out errRecord); if (errRecord != null) { return null; @@ -335,9 +336,9 @@ private Stream InstallVersion( return null; } - _cmdletPassedIn.WriteVerbose($"Downloading blob for {moduleName} - {moduleVersion}"); + _cmdletPassedIn.WriteVerbose($"Downloading blob for {packageNameLowercase} - {moduleVersion}"); // TODO: error handling here? - var responseContent = GetAcrBlobAsync(registry, moduleName, digest, acrAccessToken).Result; + var responseContent = GetAcrBlobAsync(registry, packageNameLowercase, digest, acrAccessToken).Result; return responseContent.ReadAsStreamAsync().Result; } diff --git a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 index 98627d3e8..5de4d7c46 100644 --- a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 @@ -7,10 +7,10 @@ Import-Module $modPath -Force -Verbose Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { BeforeAll{ - $testModuleName = "test_local_mod" + $testModuleName = "test-module" $testModuleParentName = "test_parent_mod" $testModuleDependencyName = "test_dependency_mod" - $testScript = "testscript" + $testScriptName = "test-script" $ACRRepoName = "ACRRepo" $ACRRepoUri = "https://psresourcegettest.azurecr.io" Get-NewPSResourceRepositoryFile @@ -95,7 +95,7 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName -Prerelease $res.Name | Should -Be $testModuleName $res.Version | Should -Be "5.2.5" - $res.Prerelease | Should -Be "alpha001" + $res.Prerelease | Should -Be "alpha" } It "Find resource with latest (including prerelease) version given Prerelease parameter" { @@ -106,7 +106,7 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $resPrerelease = Find-PSResource -Name $testModuleName -Prerelease -Repository $ACRRepoName $resPrerelease.Version | Should -Be "5.2.5" - $resPrerelease.Prerelease | Should -Be "alpha001" + $resPrerelease.Prerelease | Should -Be "alpha" } It "Find resources, including Prerelease version resources, when given Prerelease parameter" { @@ -161,44 +161,47 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { It "Should find script given Name" { # FindName() - $res = Find-PSResource -Name $testScript -Repository $ACRRepoName + $res = Find-PSResource -Name $testScriptName -Repository $ACRRepoName $res | Should -Not -BeNullOrEmpty - $res.Name | Should -BeExactly $testScript - $res.Version | Should -Be "2.0.0" + $res.Name | Should -BeExactly $testScriptName + $res.Version | Should -Be "3.0.0" + $res.Type.ToString() | Should -Be "Script" } It "Should find script given Name and Prerelease" { # latest version is a prerelease version - $res = Find-PSResource -Name $testScript -Prerelease -Repository $ACRRepoName + $res = Find-PSResource -Name $testScriptName -Prerelease -Repository $ACRRepoName $res | Should -Not -BeNullOrEmpty - $res.Name | Should -BeExactly $testScript - $res.Version | Should -Be "3.5.0" + $res.Name | Should -BeExactly $testScriptName + $res.Version | Should -Be "5.0.0" $res.Prerelease | Should -Be "alpha" + $res.Type.ToString() | Should -Be "Script" } It "Should find script given Name and Version" { # FindVersion() - $res = Find-PSResource -Name $testScript -Version "1.0.0" -Repository $ACRRepoName + $res = Find-PSResource -Name $testScriptName -Version "1.0.0" -Repository $ACRRepoName $res | Should -Not -BeNullOrEmpty - $res.Name | Should -BeExactly $testScript + $res.Name | Should -BeExactly $testScriptName $res.Version | Should -Be "1.0.0" + $res.Type.ToString() | Should -Be "Script" } It "Should find script given Name, Version and Prerelease" { # latest version is a prerelease version - $res = Find-PSResource -Name $testScript -Version "3.5.0-alpha" -Prerelease -Repository $ACRRepoName + $res = Find-PSResource -Name $testScriptName -Version "5.0.0-alpha" -Prerelease -Repository $ACRRepoName $res | Should -Not -BeNullOrEmpty - $res.Name | Should -BeExactly $testScript - $res.Version | Should -Be "3.5.0" + $res.Name | Should -BeExactly $testScriptName + $res.Version | Should -Be "5.0.0" $res.Prerelease | Should -Be "alpha" + $res.Type.ToString() | Should -Be "Script" } It "Should find and return correct resource type - module" { - $moduleName = "test_dependency_mod" - $res = Find-PSResource -Name $moduleName -Repository $ACRRepoName + $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName $res | Should -Not -BeNullOrEmpty - $res.Name | Should -BeExactly $moduleName - $res.Version | Should -Be "1.0.0" + $res.Name | Should -BeExactly $testModuleName + $res.Version | Should -Be "5.0.0" $res.Type.ToString() | Should -Be "Module" } @@ -207,6 +210,20 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res = Find-PSResource -Name $scriptName -Repository $ACRRepoName $res | Should -Not -BeNullOrEmpty $res.Name | Should -BeExactly $scriptName + $res.Version | Should -Be "3.0.0" + $res.Type.ToString() | Should -Be "Script" + } + + It "Should find module with varying case sensitivity" { + $res = Find-PSResource -Name "test-camelCaseModule" -Repository $ACRRepoName + $res.Name | Should -BeExactly "test-camelCaseModule" + $res.Version | Should -Be "1.0.0" + $res.Type.ToString() | Should -Be "Module" + } + + It "Should find script with varying case sensitivity" { + $res = Find-PSResource -Name "test-camelCaseScript" -Repository $ACRRepoName + $res.Name | Should -BeExactly "test-camelCaseScript" $res.Version | Should -Be "1.0.0" $res.Type.ToString() | Should -Be "Script" } diff --git a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 index 2fcf6f827..835043da7 100644 --- a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 @@ -8,11 +8,13 @@ Import-Module $modPath -Force -Verbose Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { BeforeAll { - $testModuleName = "test_local_mod" - $testModuleName2 = "test_local_mod2" + $testModuleName = "test-module" + $testModuleName2 = "test-module2" + $testCamelCaseModuleName = "test-camelCaseModule" + $testCamelCaseScriptName = "test-camelCaseScript" $testModuleParentName = "test_parent_mod" $testModuleDependencyName = "test_dependency_mod" - $testScriptName = "testscript" + $testScriptName = "test-script" $ACRRepoName = "ACRRepo" $ACRRepoUri = "https://psresourcegettest.azurecr.io/" Get-NewPSResourceRepositoryFile @@ -31,7 +33,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { } AfterEach { - Uninstall-PSResource $testModuleName, $testModuleName2, $testScriptName -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue + Uninstall-PSResource $testModuleName, $testModuleName2, $testCamelCaseModuleName, $testScriptName, $testCamelCaseScriptName -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue } AfterAll { @@ -39,8 +41,8 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { } $testCases = @{Name="*"; ErrorId="NameContainsWildcard"}, - @{Name="Test_local_m*"; ErrorId="NameContainsWildcard"}, - @{Name="Test?local","Test[local"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} + @{Name="Test-mod*"; ErrorId="NameContainsWildcard"}, + @{Name="Test?modu","Test[module"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} It "Should not install resource with wildcard in name" -TestCases $testCases { param($Name, $ErrorId) @@ -62,7 +64,8 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { Install-PSResource -Name $testScriptName -Repository $ACRRepoName -TrustRepository $pkg = Get-InstalledPSResource $testScriptName $pkg.Name | Should -BeExactly $testScriptName - $pkg.Version | Should -Be "2.0.0" + $pkg.Version | Should -Be "3.0.0" + $pkg.Type.ToString() | Should -Be "Script" } It "Install script resource by name and version" { @@ -148,7 +151,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.2.5" - $pkg.Prerelease | Should -Be "alpha001" + $pkg.Prerelease | Should -Be "alpha" } It "Install resource via InputObject by piping from Find-PSresource" { @@ -159,11 +162,10 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { } It "Install resource with copyright, description and repository source location and validate properties" { - $testModule = "test_module" - Install-PSResource -Name $testModule -Version "7.0.0" -Repository $ACRRepoName -TrustRepository - $pkg = Get-InstalledPSResource $testModule - $pkg.Name | Should -Be $testModule - $pkg.Version | Should -Be "7.0.0" + Install-PSResource -Name $testModuleName -Version "3.0.0" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "3.0.0" $pkg.Copyright | Should -Be "(c) Anam Navied. All rights reserved." $pkg.Description | Should -Be "This is a test module, for PSGallery team internal testing. Do not take a dependency on this package. This version contains tags for the package." $pkg.RepositorySourceLocation | Should -Be $ACRRepoUri @@ -231,7 +233,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { } It "Install PSResourceInfo object piped in" { - Find-PSResource -Name $testModuleName -Version "1.0.0.0" -Repository $ACRRepoName | Install-PSResource -TrustRepository + Find-PSResource -Name $testModuleName -Version "1.0.0" -Repository $ACRRepoName | Install-PSResource -TrustRepository $res = Get-InstalledPSResource -Name $testModuleName $res.Name | Should -Be $testModuleName $res.Version | Should -Be "1.0.0" @@ -241,6 +243,22 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { $res = Install-PSResource -Name $testModuleName -Repository $ACRRepoName -PassThru -TrustRepository $res.Name | Should -Contain $testModuleName } + + It "Install module with varying case sensitivity" { + Install-PSResource -Name $testCamelCaseModuleName -Repository $ACRRepoName -TrustRepository + $res = Get-InstalledPSResource -Name $testCamelCaseModuleName + $res.Name | Should -BeExactly $testCamelCaseModuleName + $res.Version | Should -Be "1.0.0" + $res.Type.ToString() | Should -Be "Module" + } + + It "Install script with varying case sensitivity" { + Install-PSResource -Name $testCamelCaseScriptName -Repository $ACRRepoName -TrustRepository + $res = Get-InstalledPSResource -Name $testCamelCaseScriptName + $res.Name | Should -BeExactly $testCamelCaseScriptName + $res.Version | Should -Be "1.0.0" + $res.Type.ToString() | Should -Be "Script" + } } Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidationOnly' { diff --git a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 index 59661a028..8787ee02e 100644 --- a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 @@ -75,6 +75,8 @@ Describe "Test Publish-PSResource" -tags 'CI' { $script:ModuleWithoutRequiredModuleName = "temp-testmodulewithoutrequiredmodule-" + [System.Guid]::NewGuid() $script:ScriptName = "temp-testscript" + [System.Guid]::NewGuid() $script:ScriptWithExternalDeps = "temp-testscriptwithexternaldeps" + [System.Guid]::NewGuid() + $script:ScriptWithoutEmptyLinesInMetadata = "temp-scriptwithoutemptylinesinmetadata" + [System.Guid]::NewGuid() + $script:ScriptWithoutEmptyLinesBetweenCommentBlocks = "temp-scriptwithoutemptylinesbetweencommentblocks" + [System.Guid]::NewGuid() # Create temp destination path $script:destinationPath = [IO.Path]::GetFullPath((Join-Path -Path $TestDrive -ChildPath "tmpDestinationPath")) @@ -106,7 +108,7 @@ Describe "Test Publish-PSResource" -tags 'CI' { Get-RevertPSResourceRepositoryFile # Note: all repository names provided as test packages for ACR, must have lower cased names, otherwise the Az cmdlets will not be able to properly find and delete it. - $acrRepositoryNames = @($script:PublishModuleName, $script:ModuleWithoutRequiredModuleName, $script:ScriptName, $script:ScriptWithExternalDeps) + $acrRepositoryNames = @($script:PublishModuleName, $script:ModuleWithoutRequiredModuleName, $script:ScriptName, $script:ScriptWithExternalDeps, $script:ScriptWithoutEmptyLinesInMetadata, $script:ScriptWithoutEmptyLinesBetweenCommentBlocks) Set-TestACRRepositories $acrRepositoryNames } @@ -360,36 +362,40 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $scriptPath -Repository $ACRRepoName - $results = Find-PSResource -Name $script:ScriptName -Repository $ACRRepoName - $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $script:ScriptName - $results[0].Version | Should -Be $scriptVersion + $result = Find-PSResource -Name $script:ScriptName -Repository $ACRRepoName + $result | Should -Not -BeNullOrEmpty + $result.Name | Should -Be $script:ScriptName + $result.Version | Should -Be $scriptVersion } It "Should publish a script without lines in between comment blocks locally" { $scriptName = "ScriptWithoutEmptyLinesBetweenCommentBlocks" $scriptVersion = "1.0" - $scriptPath = (Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1") + $scriptSrcPath = Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1" + $scriptDestPath = Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$script:ScriptWithoutEmptyLinesBetweenCommentBlocks.ps1" + Copy-Item -Path $scriptSrcPath -Destination $scriptDestPath - Publish-PSResource -Path $scriptPath -Repository $ACRRepoName + Publish-PSResource -Path $scriptDestPath -Repository $ACRRepoName - $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName - $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $scriptName - $results[0].Version | Should -Be $scriptVersion + $result = Find-PSResource -Name $script:ScriptWithoutEmptyLinesBetweenCommentBlocks -Repository $ACRRepoName + $result | Should -Not -BeNullOrEmpty + $result.Name | Should -Be $script:ScriptWithoutEmptyLinesBetweenCommentBlocks + $result.Version | Should -Be $scriptVersion } It "Should publish a script without lines in help block locally" { $scriptName = "ScriptWithoutEmptyLinesInMetadata" $scriptVersion = "1.0" - $scriptPath = (Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1") + $scriptSrcPath = Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1" + $scriptDestPath = Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$script:ScriptWithoutEmptyLinesInMetadata.ps1" + Copy-Item -Path $scriptSrcPath -Destination $scriptDestPath - Publish-PSResource -Path $scriptPath -Repository $ACRRepoName + Publish-PSResource -Path $scriptDestPath -Repository $ACRRepoName - $results = Find-PSResource -Name $scriptName -Repository $ACRRepoName - $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $scriptName - $results[0].Version | Should -Be $scriptVersion + $result = Find-PSResource -Name $script:ScriptWithoutEmptyLinesInMetadata -Repository $ACRRepoName + $result | Should -Not -BeNullOrEmpty + $result.Name | Should -Be $script:ScriptWithoutEmptyLinesInMetadata + $result.Version | Should -Be $scriptVersion } It "Should publish a script with ExternalModuleDependencies that are not published" { From 05faeeac799e759c262b3589cced9565e9f1b23d Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 13 Mar 2024 14:38:05 -0400 Subject: [PATCH 062/160] Rename ACR to ContainerRegistry (#1600) --- ...il.cs => ContainerRegistryResponseUtil.cs} | 12 +- ....cs => ContainerRegistryServerAPICalls.cs} | 222 +++++++++--------- src/code/PSRepositoryInfo.cs | 2 +- src/code/PSResourceInfo.cs | 18 +- src/code/PublishPSResource.cs | 8 +- src/code/ResponseUtilFactory.cs | 2 +- src/code/ServerFactory.cs | 2 +- src/code/Utils.cs | 6 +- ...ResourceContainerRegistryServer.Tests.ps1} | 0 ...ResourceContainerRegistryServer.Tests.ps1} | 0 ...ResourceContainerRegistryServer.Tests.ps1} | 0 11 files changed, 133 insertions(+), 139 deletions(-) rename src/code/{ACRResponseUtil.cs => ContainerRegistryResponseUtil.cs} (80%) rename src/code/{ACRServerAPICalls.cs => ContainerRegistryServerAPICalls.cs} (83%) rename test/FindPSResourceTests/{FindPSResourceACRServer.Tests.ps1 => FindPSResourceContainerRegistryServer.Tests.ps1} (100%) rename test/InstallPSResourceTests/{InstallPSResourceACRServer.Tests.ps1 => InstallPSResourceContainerRegistryServer.Tests.ps1} (100%) rename test/PublishPSResourceTests/{PublishPSResourceACRServer.Tests.ps1 => PublishPSResourceContainerRegistryServer.Tests.ps1} (100%) diff --git a/src/code/ACRResponseUtil.cs b/src/code/ContainerRegistryResponseUtil.cs similarity index 80% rename from src/code/ACRResponseUtil.cs rename to src/code/ContainerRegistryResponseUtil.cs index e3176327f..1bb509d9e 100644 --- a/src/code/ACRResponseUtil.cs +++ b/src/code/ContainerRegistryResponseUtil.cs @@ -9,7 +9,7 @@ namespace Microsoft.PowerShell.PSResourceGet.Cmdlets { - internal class ACRResponseUtil : ResponseUtil + internal class ContainerRegistryResponseUtil : ResponseUtil { #region Members @@ -19,7 +19,7 @@ internal class ACRResponseUtil : ResponseUtil #region Constructor - public ACRResponseUtil(PSRepositoryInfo repository) : base(repository) + public ContainerRegistryResponseUtil(PSRepositoryInfo repository) : base(repository) { Repository = repository; } @@ -30,12 +30,6 @@ public ACRResponseUtil(PSRepositoryInfo repository) : base(repository) public override IEnumerable ConvertToPSResourceResult(FindResults responseResults) { - // in FindHelper: - // serverApi.FindName() -> return responses, and out errRecord - // check outErrorRecord - // - // acrConverter.ConvertToPSResourceInfo(responses) -> return PSResourceResult - // check resourceResult for error, write if needed Hashtable[] responses = responseResults.HashtableResponse; foreach (Hashtable response in responses) { @@ -59,7 +53,7 @@ public override IEnumerable ConvertToPSResourceResult(FindResu { using (JsonDocument pkgVersionEntry = JsonDocument.Parse(response["Metadata"].ToString())) { - PSResourceInfo.TryConvertFromACRJson(response["Name"].ToString(), pkgVersionEntry, resourceType, out pkg, Repository, out responseConversionError); + PSResourceInfo.TryConvertFromContainerRegistryJson(response["Name"].ToString(), pkgVersionEntry, resourceType, out pkg, Repository, out responseConversionError); } } catch (Exception e) diff --git a/src/code/ACRServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs similarity index 83% rename from src/code/ACRServerAPICalls.cs rename to src/code/ContainerRegistryServerAPICalls.cs index b660ef412..f09ff76e2 100644 --- a/src/code/ACRServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -23,7 +23,7 @@ namespace Microsoft.PowerShell.PSResourceGet { - internal class ACRServerAPICalls : ServerApiCall + internal class ContainerRegistryServerAPICalls : ServerApiCall { // Any interface method that is not implemented here should be processed in the parent method and then call one of the implemented // methods below. @@ -34,17 +34,17 @@ internal class ACRServerAPICalls : ServerApiCall private readonly PSCmdlet _cmdletPassedIn; private HttpClient _sessionClient { get; set; } private static readonly Hashtable[] emptyHashResponses = new Hashtable[] { }; - public FindResponseType acrFindResponseType = FindResponseType.ResponseString; - - const string acrRefreshTokenTemplate = "grant_type=access_token&service={0}&tenant={1}&access_token={2}"; // 0 - registry, 1 - tenant, 2 - access token - const string acrAccessTokenTemplate = "grant_type=refresh_token&service={0}&scope=repository:*:*&refresh_token={1}"; // 0 - registry, 1 - refresh token - const string acrOAuthExchangeUrlTemplate = "https://{0}/oauth2/exchange"; // 0 - registry - const string acrOAuthTokenUrlTemplate = "https://{0}/oauth2/token"; // 0 - registry - const string acrManifestUrlTemplate = "https://{0}/v2/{1}/manifests/{2}"; // 0 - registry, 1 - repo(modulename), 2 - tag(version) - const string acrBlobDownloadUrlTemplate = "https://{0}/v2/{1}/blobs/{2}"; // 0 - registry, 1 - repo(modulename), 2 - layer digest - const string acrFindImageVersionUrlTemplate = "https://{0}/acr/v1/{1}/_tags{2}"; // 0 - registry, 1 - repo(modulename), 2 - /tag(version) - const string acrStartUploadTemplate = "https://{0}/v2/{1}/blobs/uploads/"; // 0 - registry, 1 - packagename - const string acrEndUploadTemplate = "https://{0}{1}&digest=sha256:{2}"; // 0 - registry, 1 - location, 2 - digest + public FindResponseType containerRegistryFindResponseType = FindResponseType.ResponseString; + + const string containerRegistryRefreshTokenTemplate = "grant_type=access_token&service={0}&tenant={1}&access_token={2}"; // 0 - registry, 1 - tenant, 2 - access token + const string containerRegistryAccessTokenTemplate = "grant_type=refresh_token&service={0}&scope=repository:*:*&refresh_token={1}"; // 0 - registry, 1 - refresh token + const string containerRegistryOAuthExchangeUrlTemplate = "https://{0}/oauth2/exchange"; // 0 - registry + const string containerRegistryOAuthTokenUrlTemplate = "https://{0}/oauth2/token"; // 0 - registry + const string containerRegistryManifestUrlTemplate = "https://{0}/v2/{1}/manifests/{2}"; // 0 - registry, 1 - repo(modulename), 2 - tag(version) + const string containerRegistryBlobDownloadUrlTemplate = "https://{0}/v2/{1}/blobs/{2}"; // 0 - registry, 1 - repo(modulename), 2 - layer digest + const string containerRegistryFindImageVersionUrlTemplate = "https://{0}/acr/v1/{1}/_tags{2}"; // 0 - registry, 1 - repo(modulename), 2 - /tag(version) + const string containerRegistryStartUploadTemplate = "https://{0}/v2/{1}/blobs/uploads/"; // 0 - registry, 1 - packagename + const string containerRegistryEndUploadTemplate = "https://{0}{1}&digest=sha256:{2}"; // 0 - registry, 1 - location, 2 - digest private static readonly HttpClient s_client = new HttpClient(); @@ -52,7 +52,7 @@ internal class ACRServerAPICalls : ServerApiCall #region Constructor - public ACRServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, NetworkCredential networkCredential, string userAgentString) : base(repository, networkCredential) + public ContainerRegistryServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, NetworkCredential networkCredential, string userAgentString) : base(repository, networkCredential) { Repository = repository; Registry = Repository.Uri.Host; @@ -78,14 +78,14 @@ public ACRServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, N /// public override FindResults FindAll(bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindAll()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindAll()"); errRecord = new ErrorRecord( - new InvalidOperationException($"Find all is not supported for the ACR server protocol repository '{Repository.Name}'"), + new InvalidOperationException($"Find all is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), "FindAllFailure", ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); } /// @@ -96,14 +96,14 @@ public override FindResults FindAll(bool includePrerelease, ResourceType type, o /// public override FindResults FindTags(string[] tags, bool includePrerelease, ResourceType _type, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindTags()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindTags()"); errRecord = new ErrorRecord( - new InvalidOperationException($"Find tags is not supported for the ACR server protocol repository '{Repository.Name}'"), + new InvalidOperationException($"Find tags is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), "FindTagsFailure", ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); } /// @@ -111,14 +111,14 @@ public override FindResults FindTags(string[] tags, bool includePrerelease, Reso /// public override FindResults FindCommandOrDscResource(string[] tags, bool includePrerelease, bool isSearchingForCommands, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindCommandOrDscResource()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindCommandOrDscResource()"); errRecord = new ErrorRecord( - new InvalidOperationException($"Find Command or DSC Resource is not supported for the ACR server protocol repository '{Repository.Name}'"), + new InvalidOperationException($"Find Command or DSC Resource is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), "FindCommandOrDscResourceFailure", ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); } /// @@ -132,16 +132,16 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include /// public override FindResults FindName(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindName()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindName()"); // for FindName(), need to consider all versions (hence VersionType.VersionRange and VersionRange.All, and no requiredVersion) but only pick latest (hence getOnlyLatest: true) Hashtable[] pkgResult = FindPackagesWithVersionHelper(packageName, VersionType.VersionRange, versionRange: VersionRange.All, requiredVersion: null, includePrerelease, getOnlyLatest: true, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); } - return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResult.ToArray(), responseType: acrFindResponseType); + return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResult.ToArray(), responseType: containerRegistryFindResponseType); } /// @@ -152,14 +152,14 @@ public override FindResults FindName(string packageName, bool includePrerelease, /// public override FindResults FindNameWithTag(string packageName, string[] tags, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindNameWithTag()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindNameWithTag()"); errRecord = new ErrorRecord( - new InvalidOperationException($"Find name with tag(s) is not supported for the ACR server protocol repository '{Repository.Name}'"), + new InvalidOperationException($"Find name with tag(s) is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), "FindNameWithTagFailure", ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); } /// @@ -172,14 +172,14 @@ public override FindResults FindNameWithTag(string packageName, string[] tags, b /// public override FindResults FindNameGlobbing(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindNameGlobbing()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindNameGlobbing()"); errRecord = new ErrorRecord( - new InvalidOperationException($"FindNameGlobbing all is not supported for the ACR server protocol repository '{Repository.Name}'"), + new InvalidOperationException($"FindNameGlobbing all is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), "FindNameGlobbingFailure", ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); } /// @@ -190,14 +190,14 @@ public override FindResults FindNameGlobbing(string packageName, bool includePre /// public override FindResults FindNameGlobbingWithTag(string packageName, string[] tags, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindNameGlobbingWithTag()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindNameGlobbingWithTag()"); errRecord = new ErrorRecord( - new InvalidOperationException($"Find name globbing with tag(s) is not supported for the ACR server protocol repository '{Repository.Name}'"), + new InvalidOperationException($"Find name globbing with tag(s) is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), "FindNameGlobbingWithTagFailure", ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); } /// @@ -211,16 +211,16 @@ public override FindResults FindNameGlobbingWithTag(string packageName, string[] /// public override FindResults FindVersionGlobbing(string packageName, VersionRange versionRange, bool includePrerelease, ResourceType type, bool getOnlyLatest, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindVersionGlobbing()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindVersionGlobbing()"); // for FindVersionGlobbing(), need to consider all versions that match version range criteria (hence VersionType.VersionRange and no requiredVersion) Hashtable[] pkgResults = FindPackagesWithVersionHelper(packageName, VersionType.VersionRange, versionRange: versionRange, requiredVersion: null, includePrerelease, getOnlyLatest: false, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); } - return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResults.ToArray(), responseType: acrFindResponseType); + return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResults.ToArray(), responseType: containerRegistryFindResponseType); } /// @@ -232,7 +232,7 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange /// public override FindResults FindVersion(string packageName, string version, ResourceType type, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindVersion()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindVersion()"); if (!NuGetVersion.TryParse(version, out NuGetVersion requiredVersion)) { errRecord = new ErrorRecord( @@ -241,7 +241,7 @@ public override FindResults FindVersion(string packageName, string version, Reso ErrorCategory.InvalidArgument, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); } _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{requiredVersion}'"); @@ -251,10 +251,10 @@ public override FindResults FindVersion(string packageName, string version, Reso Hashtable[] pkgResult = FindPackagesWithVersionHelper(packageName, VersionType.SpecificVersion, versionRange: VersionRange.None, requiredVersion: requiredVersion, includePrereleaseVersions, getOnlyLatest: false, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); } - return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResult.ToArray(), responseType: acrFindResponseType); + return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResult.ToArray(), responseType: containerRegistryFindResponseType); } /// @@ -265,14 +265,14 @@ public override FindResults FindVersion(string packageName, string version, Reso /// public override FindResults FindVersionWithTag(string packageName, string version, string[] tags, ResourceType type, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::FindVersionWithTag()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindVersionWithTag()"); errRecord = new ErrorRecord( - new InvalidOperationException($"Find version with tag(s) is not supported for the ACR server protocol repository '{Repository.Name}'"), + new InvalidOperationException($"Find version with tag(s) is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), "FindVersionWithTagFailure", ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: acrFindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); } /** INSTALL APIS **/ @@ -287,7 +287,7 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// public override Stream InstallPackage(string packageName, string packageVersion, bool includePrerelease, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ACRServerAPICalls::InstallPackage()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::InstallPackage()"); Stream results = new MemoryStream(); if (string.IsNullOrEmpty(packageVersion)) { @@ -317,7 +317,7 @@ private Stream InstallVersion( Directory.CreateDirectory(tempPath); string registryUrl = Repository.Uri.ToString(); - string acrAccessToken = GetAcrAccessToken(Repository, out errRecord); + string containerRegistryAccessToken = GetContainerRegistryAccessToken(Repository, out errRecord); if (errRecord != null) { return null; @@ -325,7 +325,7 @@ private Stream InstallVersion( string registry = Repository.Uri.Host; _cmdletPassedIn.WriteVerbose($"Getting manifest for {packageNameLowercase} - {moduleVersion}"); - var manifest = GetAcrRepositoryManifestAsync(registry, packageNameLowercase, moduleVersion, acrAccessToken, out errRecord); + var manifest = GetContainerRegistryRepositoryManifestAsync(registry, packageNameLowercase, moduleVersion, containerRegistryAccessToken, out errRecord); if (errRecord != null) { return null; @@ -338,7 +338,7 @@ private Stream InstallVersion( _cmdletPassedIn.WriteVerbose($"Downloading blob for {packageNameLowercase} - {moduleVersion}"); // TODO: error handling here? - var responseContent = GetAcrBlobAsync(registry, packageNameLowercase, digest, acrAccessToken).Result; + var responseContent = GetContainerRegistryBlobAsync(registry, packageNameLowercase, digest, containerRegistryAccessToken).Result; return responseContent.ReadAsStreamAsync().Result; } @@ -388,17 +388,17 @@ private string GetDigestFromManifest(JObject manifest, out ErrorRecord errRecord } // access token can be empty if the repository is unauthenticated - internal string GetAcrAccessToken(PSRepositoryInfo repositoryInfo, out ErrorRecord errRecord) + internal string GetContainerRegistryAccessToken(PSRepositoryInfo repositoryInfo, out ErrorRecord errRecord) { string accessToken = string.Empty; - string acrAccessToken = string.Empty; + string containerRegistryAccessToken = string.Empty; string tenantID = string.Empty; errRecord = null; var repositoryCredentialInfo = Repository.CredentialInfo; if (repositoryCredentialInfo != null) { - accessToken = Utils.GetACRAccessTokenFromSecretManagement( + accessToken = Utils.GetContainerRegistryAccessTokenFromSecretManagement( Repository.Name, repositoryCredentialInfo, _cmdletPassedIn); @@ -430,19 +430,19 @@ internal string GetAcrAccessToken(PSRepositoryInfo repositoryInfo, out ErrorReco string registry = repositoryInfo.Uri.Host; - var acrRefreshToken = GetAcrRefreshToken(registry, tenantID, accessToken, out errRecord); + var containerRegistryRefreshToken = GetContainerRegistryRefreshToken(registry, tenantID, accessToken, out errRecord); if (errRecord != null) { return null; } - acrAccessToken = GetAcrAccessTokenByRefreshToken(registry, acrRefreshToken, out errRecord); + containerRegistryAccessToken = GetContainerRegistryAccessTokenByRefreshToken(registry, containerRegistryRefreshToken, out errRecord); if (errRecord != null) { return null; } - return acrAccessToken; + return containerRegistryAccessToken; } internal bool IsContainerRegistryUnauthenticated(string registryUrl) @@ -452,11 +452,11 @@ internal bool IsContainerRegistryUnauthenticated(string registryUrl) return (response.StatusCode == HttpStatusCode.OK); } - internal string GetAcrRefreshToken(string registry, string tenant, string accessToken, out ErrorRecord errRecord) + internal string GetContainerRegistryRefreshToken(string registry, string tenant, string accessToken, out ErrorRecord errRecord) { - string content = string.Format(acrRefreshTokenTemplate, registry, tenant, accessToken); + string content = string.Format(containerRegistryRefreshTokenTemplate, registry, tenant, accessToken); var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; - string exchangeUrl = string.Format(acrOAuthExchangeUrlTemplate, registry); + string exchangeUrl = string.Format(containerRegistryOAuthExchangeUrlTemplate, registry); var results = GetHttpResponseJObjectUsingContentHeaders(exchangeUrl, HttpMethod.Post, content, contentHeaders, out errRecord); if (results != null && results["refresh_token"] != null) @@ -467,11 +467,11 @@ internal string GetAcrRefreshToken(string registry, string tenant, string access return string.Empty; } - internal string GetAcrAccessTokenByRefreshToken(string registry, string refreshToken, out ErrorRecord errRecord) + internal string GetContainerRegistryAccessTokenByRefreshToken(string registry, string refreshToken, out ErrorRecord errRecord) { - string content = string.Format(acrAccessTokenTemplate, registry, refreshToken); + string content = string.Format(containerRegistryAccessTokenTemplate, registry, refreshToken); var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; - string tokenUrl = string.Format(acrOAuthTokenUrlTemplate, registry); + string tokenUrl = string.Format(containerRegistryOAuthTokenUrlTemplate, registry); var results = GetHttpResponseJObjectUsingContentHeaders(tokenUrl, HttpMethod.Post, content, contentHeaders, out errRecord); if (results != null && results["access_token"] != null) @@ -482,24 +482,24 @@ internal string GetAcrAccessTokenByRefreshToken(string registry, string refreshT return string.Empty; } - internal JObject GetAcrRepositoryManifestAsync(string registry, string packageName, string version, string acrAccessToken, out ErrorRecord errRecord) + internal JObject GetContainerRegistryRepositoryManifestAsync(string registry, string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) { - // the packageName parameter here maps to repositoryName in ACR, but to not conflict with PSGet definition of repository we will call it packageName + // the packageName parameter here maps to repositoryName in ContainerRegistry, but to not conflict with PSGet definition of repository we will call it packageName // example of manifestUrl: https://psgetregistry.azurecr.io/hello-world:3.0.0 - string manifestUrl = string.Format(acrManifestUrlTemplate, registry, packageName, version); + string manifestUrl = string.Format(containerRegistryManifestUrlTemplate, registry, packageName, version); - var defaultHeaders = GetDefaultHeaders(acrAccessToken); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); return GetHttpResponseJObjectUsingDefaultHeaders(manifestUrl, HttpMethod.Get, defaultHeaders, out errRecord); } - internal async Task GetAcrBlobAsync(string registry, string repositoryName, string digest, string acrAccessToken) + internal async Task GetContainerRegistryBlobAsync(string registry, string repositoryName, string digest, string containerRegistryAccessToken) { - string blobUrl = string.Format(acrBlobDownloadUrlTemplate, registry, repositoryName, digest); - var defaultHeaders = GetDefaultHeaders(acrAccessToken); + string blobUrl = string.Format(containerRegistryBlobDownloadUrlTemplate, registry, repositoryName, digest); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); return await GetHttpContentResponseJObject(blobUrl, defaultHeaders); } - internal JObject FindAcrImageTags(string registry, string repositoryName, string version, string acrAccessToken, out ErrorRecord errRecord) + internal JObject FindContainerRegistryImageTags(string registry, string repositoryName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) { /* response returned looks something like: * "registry": "myregistry.azurecr.io" @@ -522,21 +522,21 @@ internal JObject FindAcrImageTags(string registry, string repositoryName, string try { string resolvedVersion = string.Equals(version, "*", StringComparison.OrdinalIgnoreCase) ? null : $"/{version}"; - string findImageUrl = string.Format(acrFindImageVersionUrlTemplate, registry, repositoryName, resolvedVersion); - var defaultHeaders = GetDefaultHeaders(acrAccessToken); + string findImageUrl = string.Format(containerRegistryFindImageVersionUrlTemplate, registry, repositoryName, resolvedVersion); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); return GetHttpResponseJObjectUsingDefaultHeaders(findImageUrl, HttpMethod.Get, defaultHeaders, out errRecord); } catch (HttpRequestException e) { - throw new HttpRequestException("Error finding ACR artifact: " + e.Message); + throw new HttpRequestException("Error finding ContainerRegistry artifact: " + e.Message); } } - internal Hashtable GetACRMetadata(string registry, string packageName, string exactTagVersion, string acrAccessToken, out ErrorRecord errRecord) + internal Hashtable GetContainerRegistryMetadata(string registry, string packageName, string exactTagVersion, string containerRegistryAccessToken, out ErrorRecord errRecord) { Hashtable requiredVersionResponse = new Hashtable(); - var foundTags = FindAcrManifest(registry, packageName, exactTagVersion, acrAccessToken, out errRecord); + var foundTags = FindContainerRegistryManifest(registry, packageName, exactTagVersion, containerRegistryAccessToken, out errRecord); if (errRecord != null || foundTags == null) { return requiredVersionResponse; @@ -700,56 +700,56 @@ internal ContainerRegistryInfo GetMetadataProperty(JObject foundTags, string pac return new ContainerRegistryInfo(metadataPkgName, metadata, resourceType); } - internal JObject FindAcrManifest(string registry, string packageName, string version, string acrAccessToken, out ErrorRecord errRecord) + internal JObject FindContainerRegistryManifest(string registry, string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) { try { - var createManifestUrl = string.Format(acrManifestUrlTemplate, registry, packageName, version); + var createManifestUrl = string.Format(containerRegistryManifestUrlTemplate, registry, packageName, version); _cmdletPassedIn.WriteDebug($"GET manifest url: {createManifestUrl}"); - var defaultHeaders = GetDefaultHeaders(acrAccessToken); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); return GetHttpResponseJObjectUsingDefaultHeaders(createManifestUrl, HttpMethod.Get, defaultHeaders, out errRecord); } catch (HttpRequestException e) { - throw new HttpRequestException("Error finding ACR manifest: " + e.Message); + throw new HttpRequestException("Error finding ContainerRegistry manifest: " + e.Message); } } - internal async Task GetStartUploadBlobLocation(string pkgName, string acrAccessToken) + internal async Task GetStartUploadBlobLocation(string pkgName, string containerRegistryAccessToken) { try { - var defaultHeaders = GetDefaultHeaders(acrAccessToken); - var startUploadUrl = string.Format(acrStartUploadTemplate, Registry, pkgName); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + var startUploadUrl = string.Format(containerRegistryStartUploadTemplate, Registry, pkgName); return (await GetHttpResponseHeader(startUploadUrl, HttpMethod.Post, defaultHeaders)).Location.ToString(); } catch (HttpRequestException e) { - throw new HttpRequestException("Error starting publishing to ACR: " + e.Message); + throw new HttpRequestException("Error starting publishing to ContainerRegistry: " + e.Message); } } - internal async Task EndUploadBlob(string location, string filePath, string digest, bool isManifest, string acrAccessToken) + internal async Task EndUploadBlob(string location, string filePath, string digest, bool isManifest, string containerRegistryAccessToken) { try { - var endUploadUrl = string.Format(acrEndUploadTemplate, Registry, location, digest); - var defaultHeaders = GetDefaultHeaders(acrAccessToken); + var endUploadUrl = string.Format(containerRegistryEndUploadTemplate, Registry, location, digest); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); return await PutRequestAsync(endUploadUrl, filePath, isManifest, defaultHeaders); } catch (HttpRequestException e) { - throw new HttpRequestException("Error occured while trying to uploading module to ACR: " + e.Message); + throw new HttpRequestException("Error occured while trying to uploading module to ContainerRegistry: " + e.Message); } } - internal async Task UploadManifest(string pkgName, string pkgVersion, string configPath, bool isManifest, string acrAccessToken) + internal async Task UploadManifest(string pkgName, string pkgVersion, string configPath, bool isManifest, string containerRegistryAccessToken) { try { - var createManifestUrl = string.Format(acrManifestUrlTemplate, Registry, pkgName, pkgVersion); - var defaultHeaders = GetDefaultHeaders(acrAccessToken); + var createManifestUrl = string.Format(containerRegistryManifestUrlTemplate, Registry, pkgName, pkgVersion); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); return await PutRequestAsync(createManifestUrl, configPath, isManifest, defaultHeaders); } catch (HttpRequestException e) @@ -997,18 +997,18 @@ private static async Task PutRequestAsync(string url, strin } catch (HttpRequestException e) { - throw new HttpRequestException("Error occured while trying to uploading module to ACR: " + e.Message); + throw new HttpRequestException("Error occured while trying to uploading module to ContainerRegistry: " + e.Message); } } - private static Collection> GetDefaultHeaders(string acrAccessToken) + private static Collection> GetDefaultHeaders(string containerRegistryAccessToken) { var defaultHeaders = new Collection>(); - if (!string.IsNullOrEmpty(acrAccessToken)) + if (!string.IsNullOrEmpty(containerRegistryAccessToken)) { - defaultHeaders.Add(new KeyValuePair("Authorization", acrAccessToken)); + defaultHeaders.Add(new KeyValuePair("Authorization", containerRegistryAccessToken)); } defaultHeaders.Add(new KeyValuePair("Accept", "application/vnd.oci.image.manifest.v1+json")); @@ -1016,19 +1016,19 @@ private static Collection> GetDefaultHeaders(string return defaultHeaders; } - internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, ResourceType resourceType, Hashtable parsedMetadataHash, Hashtable dependencies, out ErrorRecord errRecord) + internal bool PushNupkgContainerRegistry(string psd1OrPs1File, string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, ResourceType resourceType, Hashtable parsedMetadataHash, Hashtable dependencies, out ErrorRecord errRecord) { string fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, pkgName + "." + pkgVersion.ToNormalizedString() + ".nupkg"); string pkgNameLower = pkgName.ToLower(); // Get access token (includes refresh tokens) - var acrAccessToken = GetAcrAccessToken(Repository, out errRecord); + var containerRegistryAccessToken = GetContainerRegistryAccessToken(Repository, out errRecord); // Upload .nupkg - TryUploadNupkg(pkgNameLower, acrAccessToken, fullNupkgFile, out string nupkgDigest); + TryUploadNupkg(pkgNameLower, containerRegistryAccessToken, fullNupkgFile, out string nupkgDigest); - // Create and upload an empty file-- needed by ACR server - TryCreateAndUploadEmptyFile(outputNupkgDir, pkgNameLower, acrAccessToken); + // Create and upload an empty file-- needed by ContainerRegistry server + TryCreateAndUploadEmptyFile(outputNupkgDir, pkgNameLower, containerRegistryAccessToken); // Create config.json file var configFilePath = System.IO.Path.Combine(outputNupkgDir, "config.json"); @@ -1044,16 +1044,16 @@ internal bool PushNupkgACR(string psd1OrPs1File, string outputNupkgDir, string p // Create and upload manifest TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, pkgName, resourceType, metadataJson, configFilePath, - pkgNameLower, pkgVersion, acrAccessToken); + pkgNameLower, pkgVersion, containerRegistryAccessToken); return true; } - private bool TryUploadNupkg(string pkgNameLower, string acrAccessToken, string fullNupkgFile, out string nupkgDigest) + private bool TryUploadNupkg(string pkgNameLower, string containerRegistryAccessToken, string fullNupkgFile, out string nupkgDigest) { _cmdletPassedIn.WriteVerbose("Start uploading blob"); - // Note: ACR registries will only accept a name that is all lowercase. - var moduleLocation = GetStartUploadBlobLocation(pkgNameLower, acrAccessToken).Result; + // Note: ContainerRegistry registries will only accept a name that is all lowercase. + var moduleLocation = GetStartUploadBlobLocation(pkgNameLower, containerRegistryAccessToken).Result; _cmdletPassedIn.WriteVerbose("Computing digest for .nupkg file"); bool nupkgDigestCreated = CreateDigest(fullNupkgFile, out nupkgDigest, out ErrorRecord nupkgDigestError); @@ -1063,12 +1063,12 @@ private bool TryUploadNupkg(string pkgNameLower, string acrAccessToken, string f } _cmdletPassedIn.WriteVerbose("Finish uploading blob"); - var responseNupkg = EndUploadBlob(moduleLocation, fullNupkgFile, nupkgDigest, false, acrAccessToken).Result; + var responseNupkg = EndUploadBlob(moduleLocation, fullNupkgFile, nupkgDigest, false, containerRegistryAccessToken).Result; return responseNupkg.IsSuccessStatusCode; } - private bool TryCreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLower, string acrAccessToken) + private bool TryCreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLower, string containerRegistryAccessToken) { _cmdletPassedIn.WriteVerbose("Create an empty file"); string emptyFileName = "empty.txt"; @@ -1081,7 +1081,7 @@ private bool TryCreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLo Utils.CreateFile(emptyFilePath); _cmdletPassedIn.WriteVerbose("Start uploading an empty file"); - var emptyLocation = GetStartUploadBlobLocation(pkgNameLower, acrAccessToken).Result; + var emptyLocation = GetStartUploadBlobLocation(pkgNameLower, containerRegistryAccessToken).Result; _cmdletPassedIn.WriteVerbose("Computing digest for empty file"); bool emptyDigestCreated = CreateDigest(emptyFilePath, out string emptyDigest, out ErrorRecord emptyDigestError); if (!emptyDigestCreated) @@ -1089,7 +1089,7 @@ private bool TryCreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLo _cmdletPassedIn.ThrowTerminatingError(emptyDigestError); } _cmdletPassedIn.WriteVerbose("Finish uploading empty file"); - var emptyResponse = EndUploadBlob(emptyLocation, emptyFilePath, emptyDigest, false, acrAccessToken).Result; + var emptyResponse = EndUploadBlob(emptyLocation, emptyFilePath, emptyDigest, false, containerRegistryAccessToken).Result; return emptyResponse.IsSuccessStatusCode; } @@ -1114,7 +1114,7 @@ private bool TryCreateConfig(string configFilePath, out string configDigest) } private bool TryCreateAndUploadManifest(string fullNupkgFile, string nupkgDigest, string configDigest, string pkgName, ResourceType resourceType, string metadataJson, string configFilePath, - string pkgNameLower, NuGetVersion pkgVersion, string acrAccessToken) + string pkgNameLower, NuGetVersion pkgVersion, string containerRegistryAccessToken) { FileInfo nupkgFile = new FileInfo(fullNupkgFile); var fileSize = nupkgFile.Length; @@ -1123,7 +1123,7 @@ private bool TryCreateAndUploadManifest(string fullNupkgFile, string nupkgDigest File.WriteAllText(configFilePath, fileContent); _cmdletPassedIn.WriteVerbose("Create the manifest layer"); - HttpResponseMessage manifestResponse = UploadManifest(pkgNameLower, pkgVersion.OriginalVersion, configFilePath, true, acrAccessToken).Result; + HttpResponseMessage manifestResponse = UploadManifest(pkgNameLower, pkgVersion.OriginalVersion, configFilePath, true, containerRegistryAccessToken).Result; bool manifestCreated = manifestResponse.IsSuccessStatusCode; if (!manifestCreated) { @@ -1289,13 +1289,13 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp string registryUrl = Repository.Uri.ToString(); string packageNameLowercase = packageName.ToLower(); - string acrAccessToken = GetAcrAccessToken(Repository, out errRecord); + string containerRegistryAccessToken = GetContainerRegistryAccessToken(Repository, out errRecord); if (errRecord != null) { return emptyHashResponses; } - var foundTags = FindAcrImageTags(Registry, packageNameLowercase, "*", acrAccessToken, out errRecord); + var foundTags = FindContainerRegistryImageTags(Registry, packageNameLowercase, "*", containerRegistryAccessToken, out errRecord); if (errRecord != null || foundTags == null) { return emptyHashResponses; @@ -1315,7 +1315,7 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp foreach (var pkgVersionTag in pkgsInDescendingOrder) { string exactTagVersion = pkgVersionTag.Value.ToString(); - Hashtable metadata = GetACRMetadata(Registry, packageNameLowercase, exactTagVersion, acrAccessToken, out errRecord); + Hashtable metadata = GetContainerRegistryMetadata(Registry, packageNameLowercase, exactTagVersion, containerRegistryAccessToken, out errRecord); if (errRecord != null || metadata.Count == 0) { return emptyHashResponses; @@ -1335,7 +1335,7 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp private SortedDictionary GetPackagesWithRequiredVersion(List allPkgVersions, VersionType versionType, VersionRange versionRange, NuGetVersion specificVersion, string packageName, bool includePrerelease, out ErrorRecord errRecord) { errRecord = null; - // we need NuGetVersion to sort versions by order, and string pkgVersionString (which is the exact tag from the server) to call GetACRMetadata() later with exact version tag. + // we need NuGetVersion to sort versions by order, and string pkgVersionString (which is the exact tag from the server) to call GetContainerRegistryMetadata() later with exact version tag. SortedDictionary sortedPkgs = new SortedDictionary(VersionComparer.Default); bool isSpecificVersionSearch = versionType == VersionType.SpecificVersion; diff --git a/src/code/PSRepositoryInfo.cs b/src/code/PSRepositoryInfo.cs index df99908f8..7faa02bc4 100644 --- a/src/code/PSRepositoryInfo.cs +++ b/src/code/PSRepositoryInfo.cs @@ -74,7 +74,7 @@ public enum RepositoryProviderType public int Priority { get; } /// - /// the type of repository provider (eg, AzureDevOps, ACR, etc.) + /// the type of repository provider (eg, AzureDevOps, ContainerRegistry, etc.) /// public RepositoryProviderType RepositoryProvider { get; } diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 49c57b8eb..610e36046 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -804,10 +804,10 @@ public static bool TryConvertFromJson( } /// - /// Converts ACR JsonDocument entry to PSResourceInfo instance - /// used for ACR Server API call find response conversion to PSResourceInfo object + /// Converts ContainerRegistry JsonDocument entry to PSResourceInfo instance + /// used for ContainerRegistry Server API call find response conversion to PSResourceInfo object /// - public static bool TryConvertFromACRJson( + public static bool TryConvertFromContainerRegistryJson( string packageName, JsonDocument packageMetadata, ResourceType? resourceType, @@ -820,7 +820,7 @@ public static bool TryConvertFromACRJson( if (packageMetadata == null) { - errorMsg = "TryConvertFromACRJson: Invalid json object. Object cannot be null."; + errorMsg = "TryConvertFromContainerRegistryJson: Invalid json object. Object cannot be null."; return false; } @@ -865,7 +865,7 @@ public static bool TryConvertFromACRJson( { errorMsg = string.Format( CultureInfo.InvariantCulture, - @"TryConvertFromACRJson: Neither 'ModuleVersion' nor 'Version' could be found in package metadata"); + @"TryConvertFromContainerRegistryJson: Neither 'ModuleVersion' nor 'Version' could be found in package metadata"); return false; } @@ -874,7 +874,7 @@ public static bool TryConvertFromACRJson( { errorMsg = string.Format( CultureInfo.InvariantCulture, - @"TryConvertFromACRJson: Cannot parse NormalizedVersion or System.Version from version in metadata."); + @"TryConvertFromContainerRegistryJson: Cannot parse NormalizedVersion or System.Version from version in metadata."); return false; } @@ -967,7 +967,7 @@ public static bool TryConvertFromACRJson( // Dependencies if (rootDom.TryGetProperty("RequiredModules", out JsonElement requiredModulesElement)) { - metadata["Dependencies"] = ParseACRDependencies(requiredModulesElement, out errorMsg).ToArray(); + metadata["Dependencies"] = ParseContainerRegistryDependencies(requiredModulesElement, out errorMsg).ToArray(); } var additionalMetadataHashtable = new Dictionary @@ -1008,7 +1008,7 @@ public static bool TryConvertFromACRJson( { errorMsg = string.Format( CultureInfo.InvariantCulture, - @"TryConvertFromACRJson: Cannot parse PSResourceInfo from json object with error: {0}", + @"TryConvertFromContainerRegistryJson: Cannot parse PSResourceInfo from json object with error: {0}", ex.Message); return false; @@ -1519,7 +1519,7 @@ internal static Dependency[] ParseHttpDependencies(string dependencyString) return dependencyList.ToArray(); } - internal static List ParseACRDependencies(JsonElement requiredModulesElement, out string errorMsg) + internal static List ParseContainerRegistryDependencies(JsonElement requiredModulesElement, out string errorMsg) { errorMsg = string.Empty; List pkgDeps = new List(); diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index 496b1b03d..c27512872 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -312,7 +312,7 @@ out string[] _ { WriteError(new ErrorRecord( manifestReadError, - "ManifestFileReadParseForACRPublishError", + "ManifestFileReadParseForContainerRegistryPublishError", ErrorCategory.ReadError, this)); @@ -496,12 +496,12 @@ out string[] _ if (repository.ApiVersion == PSRepositoryInfo.APIVersion.ContainerRegistry) { - ACRServerAPICalls acrServer = new ACRServerAPICalls(repository, this, _networkCredential, userAgentString); + ContainerRegistryServerAPICalls containerRegistryServer = new ContainerRegistryServerAPICalls(repository, this, _networkCredential, userAgentString); var pkgMetadataFile = (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish; - if (!acrServer.PushNupkgACR(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, repository, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgACRError)) + if (!containerRegistryServer.PushNupkgContainerRegistry(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, repository, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgContainerRegistryError)) { - WriteError(pushNupkgACRError); + WriteError(pushNupkgContainerRegistryError); // exit out of processing return; } diff --git a/src/code/ResponseUtilFactory.cs b/src/code/ResponseUtilFactory.cs index a90091099..c2cf56964 100644 --- a/src/code/ResponseUtilFactory.cs +++ b/src/code/ResponseUtilFactory.cs @@ -31,7 +31,7 @@ public static ResponseUtil GetResponseUtil(PSRepositoryInfo repository) break; case PSRepositoryInfo.APIVersion.ContainerRegistry: - currentResponseUtil = new ACRResponseUtil(repository); + currentResponseUtil = new ContainerRegistryResponseUtil(repository); break; case PSRepositoryInfo.APIVersion.Unknown: diff --git a/src/code/ServerFactory.cs b/src/code/ServerFactory.cs index 40652fe73..5127346ab 100644 --- a/src/code/ServerFactory.cs +++ b/src/code/ServerFactory.cs @@ -60,7 +60,7 @@ public static ServerApiCall GetServer(PSRepositoryInfo repository, PSCmdlet cmdl break; case PSRepositoryInfo.APIVersion.ContainerRegistry: - currentServer = new ACRServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); + currentServer = new ContainerRegistryServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); break; case PSRepositoryInfo.APIVersion.Unknown: diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 16bbff287..d118b6be6 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -661,7 +661,7 @@ public static string GetAzAccessToken() return token.Token; } - public static string GetACRAccessTokenFromSecretManagement( + public static string GetContainerRegistryAccessTokenFromSecretManagement( string repositoryName, PSCredentialInfo repositoryCredentialInfo, PSCmdlet cmdletPassedIn) @@ -701,7 +701,7 @@ public static string GetACRAccessTokenFromSecretManagement( new PSInvalidOperationException( message: $"Microsoft.PowerShell.SecretManagement\\Get-Secret encountered an error while reading secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication.", innerException: terminatingError), - "ACRRepositoryCannotGetSecretFromVault", + "ContainerRegistryRepositoryCannotGetSecretFromVault", ErrorCategory.InvalidOperation, cmdletPassedIn)); } @@ -720,7 +720,7 @@ public static string GetACRAccessTokenFromSecretManagement( cmdletPassedIn.ThrowTerminatingError( new ErrorRecord( new PSNotSupportedException($"Secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" has an invalid type. The only supported type is PSCredential."), - "ACRRepositoryTokenIsInvalidSecretType", + "ContainerRegistryRepositoryTokenIsInvalidSecretType", ErrorCategory.InvalidType, cmdletPassedIn)); diff --git a/test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 similarity index 100% rename from test/FindPSResourceTests/FindPSResourceACRServer.Tests.ps1 rename to test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 diff --git a/test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 similarity index 100% rename from test/InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1 rename to test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 diff --git a/test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 similarity index 100% rename from test/PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1 rename to test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 From 7523ac3acb8250c50dc2edec10954804fef0a87e Mon Sep 17 00:00:00 2001 From: Antony Onipko Date: Wed, 13 Mar 2024 21:25:13 +0000 Subject: [PATCH 063/160] Fix incorrect request url when installing from ADO. (#1597) --- src/code/V2ServerAPICalls.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index 0c6311e1c..b2a16797a 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -1161,7 +1161,7 @@ private Stream InstallVersion(string packageName, string version, out ErrorRecor // eg: https://pkgs.dev.azure.com///_packaging//nuget/v2?id=test_module&version=5.0.0 requestUrlV2 = $"{Repository.Uri}?id={packageName}&version={version}"; } - if (_isJFrogRepo) + else if (_isJFrogRepo) { // eg: https://.jfrog.io/artifactory/api/nuget//Download/test_module/5.0.0 requestUrlV2 = $"{Repository.Uri}/Download/{packageName}/{version}"; From 8730b58c57f3d657db413dd8df999ef40bfbc9ba Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Mon, 18 Mar 2024 16:49:05 -0400 Subject: [PATCH 064/160] ACR: clean up codebase, add error handling and comments (#1602) --- src/code/ContainerRegistryServerAPICalls.cs | 856 +++++++++++++------- src/code/PSGetException.cs | 16 + src/code/PublishPSResource.cs | 2 +- 3 files changed, 590 insertions(+), 284 deletions(-) diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs index f09ff76e2..0a7360109 100644 --- a/src/code/ContainerRegistryServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -20,6 +20,7 @@ using System.Text; using System.Security.Cryptography; using System.Text.Json; +using System.Runtime.InteropServices.WindowsRuntime; namespace Microsoft.PowerShell.PSResourceGet { @@ -34,7 +35,8 @@ internal class ContainerRegistryServerAPICalls : ServerApiCall private readonly PSCmdlet _cmdletPassedIn; private HttpClient _sessionClient { get; set; } private static readonly Hashtable[] emptyHashResponses = new Hashtable[] { }; - public FindResponseType containerRegistryFindResponseType = FindResponseType.ResponseString; + private static FindResponseType containerRegistryFindResponseType = FindResponseType.ResponseString; + private static readonly FindResults emptyResponseResults = new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); const string containerRegistryRefreshTokenTemplate = "grant_type=access_token&service={0}&tenant={1}&access_token={2}"; // 0 - registry, 1 - tenant, 2 - access token const string containerRegistryAccessTokenTemplate = "grant_type=refresh_token&service={0}&scope=repository:*:*&refresh_token={1}"; // 0 - registry, 1 - refresh token @@ -72,9 +74,6 @@ public ContainerRegistryServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmd /// /// Find method which allows for searching for all packages from a repository and returns latest version for each. - /// Examples: Search -Repository PSGallery - /// API call: - /// - No prerelease: http://www.powershellgallery.com/api/v2/Search()?$filter=IsLatestVersion /// public override FindResults FindAll(bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { @@ -85,14 +84,11 @@ public override FindResults FindAll(bool includePrerelease, ResourceType type, o ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + return emptyResponseResults; } /// /// Find method which allows for searching for packages with tag from a repository and returns latest version for each. - /// Examples: Search -Tag "JSON" -Repository PSGallery - /// API call: - /// - Include prerelease: https://www.powershellgallery.com/api/v2/Search()?includePrerelease=true&$filter=IsAbsoluteLatestVersion and substringof('PSModule', Tags) eq true and substringof('CrescendoBuilt', Tags) eq true&$orderby=Id desc&$inlinecount=allpages&$skip=0&$top=6000 /// public override FindResults FindTags(string[] tags, bool includePrerelease, ResourceType _type, out ErrorRecord errRecord) { @@ -103,7 +99,7 @@ public override FindResults FindTags(string[] tags, bool includePrerelease, Reso ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + return emptyResponseResults; } /// @@ -118,16 +114,13 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + return emptyResponseResults; } /// /// Find method which allows for searching for single name and returns latest version. /// Name: no wildcard support /// Examples: Search "PowerShellGet" - /// API call: - /// - No prerelease: http://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet' - /// - Include prerelease: http://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet' /// Implementation Note: Need to filter further for latest version (prerelease or non-prerelease dependening on user preference) /// public override FindResults FindName(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) @@ -138,7 +131,7 @@ public override FindResults FindName(string packageName, bool includePrerelease, Hashtable[] pkgResult = FindPackagesWithVersionHelper(packageName, VersionType.VersionRange, versionRange: VersionRange.All, requiredVersion: null, includePrerelease, getOnlyLatest: true, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + return emptyResponseResults; } return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResult.ToArray(), responseType: containerRegistryFindResponseType); @@ -148,7 +141,6 @@ public override FindResults FindName(string packageName, bool includePrerelease, /// Find method which allows for searching for single name and tag and returns latest version. /// Name: no wildcard support /// Examples: Search "PowerShellGet" -Tag "Provider" - /// Implementation Note: Need to filter further for latest version (prerelease or non-prerelease dependening on user preference) /// public override FindResults FindNameWithTag(string packageName, string[] tags, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { @@ -159,15 +151,13 @@ public override FindResults FindNameWithTag(string packageName, string[] tags, b ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + return emptyResponseResults; } /// /// Find method which allows for searching for single name with wildcards and returns latest version. /// Name: supports wildcards /// Examples: Search "PowerShell*" - /// API call: - /// - No prerelease: http://www.powershellgallery.com/api/v2/Search()?$filter=IsLatestVersion&searchTerm='az*' /// Implementation Note: filter additionally and verify ONLY package name was a match. /// public override FindResults FindNameGlobbing(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) @@ -179,7 +169,7 @@ public override FindResults FindNameGlobbing(string packageName, bool includePre ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + return emptyResponseResults; } /// @@ -197,7 +187,7 @@ public override FindResults FindNameGlobbingWithTag(string packageName, string[] ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + return emptyResponseResults; } /// @@ -206,7 +196,6 @@ public override FindResults FindNameGlobbingWithTag(string packageName, string[] /// Version: supports wildcards /// Examples: Search "PowerShellGet" "[3.0.0.0, 5.0.0.0]" /// Search "PowerShellGet" "3.*" - /// API Call: http://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet' /// Implementation note: Returns all versions, including prerelease ones. Later (in the API client side) we'll do filtering on the versions to satisfy what user provided. /// public override FindResults FindVersionGlobbing(string packageName, VersionRange versionRange, bool includePrerelease, ResourceType type, bool getOnlyLatest, out ErrorRecord errRecord) @@ -217,7 +206,7 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange Hashtable[] pkgResults = FindPackagesWithVersionHelper(packageName, VersionType.VersionRange, versionRange: versionRange, requiredVersion: null, includePrerelease, getOnlyLatest: false, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + return emptyResponseResults; } return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResults.ToArray(), responseType: containerRegistryFindResponseType); @@ -228,7 +217,6 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange /// Name: no wildcard support /// Version: no wildcard support /// Examples: Search "PowerShellGet" "2.2.5" - /// API call: http://www.powershellgallery.com/api/v2/Packages(Id='PowerShellGet', Version='2.2.5') /// public override FindResults FindVersion(string packageName, string version, ResourceType type, out ErrorRecord errRecord) { @@ -241,7 +229,7 @@ public override FindResults FindVersion(string packageName, string version, Reso ErrorCategory.InvalidArgument, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + return emptyResponseResults; } _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{requiredVersion}'"); @@ -251,7 +239,7 @@ public override FindResults FindVersion(string packageName, string version, Reso Hashtable[] pkgResult = FindPackagesWithVersionHelper(packageName, VersionType.SpecificVersion, versionRange: VersionRange.None, requiredVersion: requiredVersion, includePrereleaseVersions, getOnlyLatest: false, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: new string[] { }, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + return emptyResponseResults; } return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResult.ToArray(), responseType: containerRegistryFindResponseType); @@ -272,7 +260,7 @@ public override FindResults FindVersionWithTag(string packageName, string versio ErrorCategory.InvalidOperation, this); - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + return emptyResponseResults; } /** INSTALL APIS **/ @@ -282,7 +270,7 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// User may request to install package with or without providing version (as seen in examples below), but prior to calling this method the package is located and package version determined. /// Therefore, package version should not be null in this method. /// Name: no wildcard support. - /// Examples: Install "PowerShellGet" + /// Examples: Install "PowerShellGet" -Version "3.5.0-alpha" /// Install "PowerShellGet" -Version "3.0.0" /// public override Stream InstallPackage(string packageName, string packageVersion, bool includePrerelease, out ErrorRecord errRecord) @@ -304,9 +292,13 @@ public override Stream InstallPackage(string packageName, string packageVersion, return results; } + /// + /// Installs a package with version specified. + /// Version can be prerelease or stable. + /// private Stream InstallVersion( string packageName, - string moduleVersion, + string packageVersion, out ErrorRecord errRecord) { errRecord = null; @@ -314,18 +306,29 @@ private Stream InstallVersion( string accessToken = string.Empty; string tenantID = string.Empty; string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - Directory.CreateDirectory(tempPath); + try + { + Directory.CreateDirectory(tempPath); + } + catch (Exception e) + { + errRecord = new ErrorRecord( + exception: e, + "InstallVersionTempDirCreationError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); - string registryUrl = Repository.Uri.ToString(); - string containerRegistryAccessToken = GetContainerRegistryAccessToken(Repository, out errRecord); + return null; + } + + string containerRegistryAccessToken = GetContainerRegistryAccessToken(out errRecord); if (errRecord != null) { return null; } - string registry = Repository.Uri.Host; - _cmdletPassedIn.WriteVerbose($"Getting manifest for {packageNameLowercase} - {moduleVersion}"); - var manifest = GetContainerRegistryRepositoryManifestAsync(registry, packageNameLowercase, moduleVersion, containerRegistryAccessToken, out errRecord); + _cmdletPassedIn.WriteVerbose($"Getting manifest for {packageNameLowercase} - {packageVersion}"); + var manifest = GetContainerRegistryRepositoryManifest(packageNameLowercase, packageVersion, containerRegistryAccessToken, out errRecord); if (errRecord != null) { return null; @@ -336,59 +339,37 @@ private Stream InstallVersion( return null; } - _cmdletPassedIn.WriteVerbose($"Downloading blob for {packageNameLowercase} - {moduleVersion}"); - // TODO: error handling here? - var responseContent = GetContainerRegistryBlobAsync(registry, packageNameLowercase, digest, containerRegistryAccessToken).Result; - - return responseContent.ReadAsStreamAsync().Result; - } - - #endregion - - #region Private Methods - - private string GetDigestFromManifest(JObject manifest, out ErrorRecord errRecord) - { - errRecord = null; - string digest = String.Empty; - - if (manifest == null) + _cmdletPassedIn.WriteVerbose($"Downloading blob for {packageNameLowercase} - {packageVersion}"); + HttpContent responseContent; + try { - errRecord = new ErrorRecord( - exception: new ArgumentNullException("Manifest (passed in to determine digest) is null."), - "ManifestNullError", - ErrorCategory.InvalidArgument, - _cmdletPassedIn); - - return digest; + responseContent = GetContainerRegistryBlobAsync(packageNameLowercase, digest, containerRegistryAccessToken).Result; } - - JToken layers = manifest["layers"]; - if (layers == null || !layers.HasValues) + catch (Exception e) { errRecord = new ErrorRecord( - exception: new ArgumentNullException("Manifest 'layers' property (passed in to determine digest) is null or does not have values."), - "ManifestLayersNullOrEmptyError", - ErrorCategory.InvalidArgument, + exception: e, + "InstallVersionGetContainerRegistryBlobAsyncError", + ErrorCategory.InvalidResult, _cmdletPassedIn); - return digest; - } - - foreach (JObject item in layers) - { - if (item.ContainsKey("digest")) - { - digest = item.GetValue("digest").ToString(); - break; - } + return null; } - return digest; + return responseContent.ReadAsStreamAsync().Result; } - // access token can be empty if the repository is unauthenticated - internal string GetContainerRegistryAccessToken(PSRepositoryInfo repositoryInfo, out ErrorRecord errRecord) + #endregion + + #region Authentication and Token Methods + + /// + /// Gets the access token for the container registry by following the below logic: + /// If a credential is provided when registering the repository, retrieve the token from SecretsManagement. + /// If no credential provided at registration then, check if the ACR endpoint can be accessed without a token. If not, try using Azure.Identity to get the az access token, then ACR refresh token and then ACR access token. + /// Note: Access token can be empty if the repository is unauthenticated + /// + internal string GetContainerRegistryAccessToken(out ErrorRecord errRecord) { string accessToken = string.Empty; string containerRegistryAccessToken = string.Empty; @@ -410,7 +391,11 @@ internal string GetContainerRegistryAccessToken(PSRepositoryInfo repositoryInfo, } else { - bool isRepositoryUnauthenticated = IsContainerRegistryUnauthenticated(repositoryInfo.Uri.ToString()); + bool isRepositoryUnauthenticated = IsContainerRegistryUnauthenticated(Repository.Uri.ToString(), out errRecord); + if (errRecord != null) + { + return null; + } if (!isRepositoryUnauthenticated) { @@ -426,17 +411,19 @@ internal string GetContainerRegistryAccessToken(PSRepositoryInfo repositoryInfo, return null; } } + else + { + _cmdletPassedIn.WriteVerbose("Repository is unauthenticated"); + } } - string registry = repositoryInfo.Uri.Host; - - var containerRegistryRefreshToken = GetContainerRegistryRefreshToken(registry, tenantID, accessToken, out errRecord); + var containerRegistryRefreshToken = GetContainerRegistryRefreshToken(tenantID, accessToken, out errRecord); if (errRecord != null) { return null; } - containerRegistryAccessToken = GetContainerRegistryAccessTokenByRefreshToken(registry, containerRegistryRefreshToken, out errRecord); + containerRegistryAccessToken = GetContainerRegistryAccessTokenByRefreshToken(containerRegistryRefreshToken, out errRecord); if (errRecord != null) { return null; @@ -445,61 +432,140 @@ internal string GetContainerRegistryAccessToken(PSRepositoryInfo repositoryInfo, return containerRegistryAccessToken; } - internal bool IsContainerRegistryUnauthenticated(string registryUrl) + /// + /// Checks if container registry repository is unauthenticated. + /// + internal bool IsContainerRegistryUnauthenticated(string containerRegistyUrl, out ErrorRecord errRecord) { - string endpoint = $"{registryUrl}/v2/"; - var response = s_client.SendAsync(new HttpRequestMessage(HttpMethod.Head, endpoint)).Result; + errRecord = null; + string endpoint = $"{containerRegistyUrl}/v2/"; + HttpResponseMessage response; + try + { + response = s_client.SendAsync(new HttpRequestMessage(HttpMethod.Head, endpoint)).Result; + } + catch (Exception e) + { + errRecord = new ErrorRecord( + e, + "RegistryUnauthenticationCheckError", + ErrorCategory.InvalidResult, + this); + + return false; + } + return (response.StatusCode == HttpStatusCode.OK); } - internal string GetContainerRegistryRefreshToken(string registry, string tenant, string accessToken, out ErrorRecord errRecord) + /// + /// Given the access token retrieved from credentials, gets the refresh token. + /// + internal string GetContainerRegistryRefreshToken(string tenant, string accessToken, out ErrorRecord errRecord) { - string content = string.Format(containerRegistryRefreshTokenTemplate, registry, tenant, accessToken); + string content = string.Format(containerRegistryRefreshTokenTemplate, Registry, tenant, accessToken); var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; - string exchangeUrl = string.Format(containerRegistryOAuthExchangeUrlTemplate, registry); + string exchangeUrl = string.Format(containerRegistryOAuthExchangeUrlTemplate, Registry); var results = GetHttpResponseJObjectUsingContentHeaders(exchangeUrl, HttpMethod.Post, content, contentHeaders, out errRecord); - - if (results != null && results["refresh_token"] != null) + if (errRecord != null || results == null || results["refresh_token"] == null) { - return results["refresh_token"].ToString(); + return string.Empty; } - return string.Empty; + return results["refresh_token"].ToString(); } - internal string GetContainerRegistryAccessTokenByRefreshToken(string registry, string refreshToken, out ErrorRecord errRecord) + /// + /// Given the refresh token, gets the new access token with appropriate scope access permissions. + /// + internal string GetContainerRegistryAccessTokenByRefreshToken(string refreshToken, out ErrorRecord errRecord) { - string content = string.Format(containerRegistryAccessTokenTemplate, registry, refreshToken); + string content = string.Format(containerRegistryAccessTokenTemplate, Registry, refreshToken); var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; - string tokenUrl = string.Format(containerRegistryOAuthTokenUrlTemplate, registry); + string tokenUrl = string.Format(containerRegistryOAuthTokenUrlTemplate, Registry); var results = GetHttpResponseJObjectUsingContentHeaders(tokenUrl, HttpMethod.Post, content, contentHeaders, out errRecord); + if (errRecord != null || results == null || results["access_token"] == null) + { + return string.Empty; + } + + return results["access_token"].ToString(); + } + + #endregion + + #region Private Methods + + /// + /// Parses package manifest JObject to find digest entry, which is the SHA needed to identify and get the package. + /// + private string GetDigestFromManifest(JObject manifest, out ErrorRecord errRecord) + { + errRecord = null; + string digest = String.Empty; + + if (manifest == null) + { + errRecord = new ErrorRecord( + exception: new ArgumentNullException("Manifest (passed in to determine digest) is null."), + "ManifestNullError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return digest; + } - if (results != null && results["access_token"] != null) + JToken layers = manifest["layers"]; + if (layers == null || !layers.HasValues) { - return results["access_token"].ToString(); + errRecord = new ErrorRecord( + exception: new ArgumentNullException("Manifest 'layers' property (passed in to determine digest) is null or does not have values."), + "ManifestLayersNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return digest; + } + + foreach (JObject item in layers) + { + if (item.ContainsKey("digest")) + { + digest = item.GetValue("digest").ToString(); + break; + } } - return string.Empty; + return digest; } - internal JObject GetContainerRegistryRepositoryManifestAsync(string registry, string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) + /// + /// Gets the manifest for a package (ie repository in container registry terms) from the repository (ie registry in container registry terms) + /// + internal JObject GetContainerRegistryRepositoryManifest(string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) { - // the packageName parameter here maps to repositoryName in ContainerRegistry, but to not conflict with PSGet definition of repository we will call it packageName // example of manifestUrl: https://psgetregistry.azurecr.io/hello-world:3.0.0 - string manifestUrl = string.Format(containerRegistryManifestUrlTemplate, registry, packageName, version); - + string manifestUrl = string.Format(containerRegistryManifestUrlTemplate, Registry, packageName, version); var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); return GetHttpResponseJObjectUsingDefaultHeaders(manifestUrl, HttpMethod.Get, defaultHeaders, out errRecord); } - internal async Task GetContainerRegistryBlobAsync(string registry, string repositoryName, string digest, string containerRegistryAccessToken) + /// + /// Get the blob for the package (ie repository in container registry terms) from the repositroy (ie registry in container registry terms) + /// Used when installing the package + /// + internal async Task GetContainerRegistryBlobAsync(string packageName, string digest, string containerRegistryAccessToken) { - string blobUrl = string.Format(containerRegistryBlobDownloadUrlTemplate, registry, repositoryName, digest); + string blobUrl = string.Format(containerRegistryBlobDownloadUrlTemplate, Registry, packageName, digest); var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); return await GetHttpContentResponseJObject(blobUrl, defaultHeaders); } - internal JObject FindContainerRegistryImageTags(string registry, string repositoryName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) + /// + /// Gets the image tags associated with the package (i.e repository in container registry terms), where the tag corresponds to the package's versions. + /// If the package version is specified search for that specific tag for the image, if the package version is "*" search for all tags for the image. + /// + internal JObject FindContainerRegistryImageTags(string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) { /* response returned looks something like: * "registry": "myregistry.azurecr.io" @@ -519,25 +585,21 @@ internal JObject FindContainerRegistryImageTags(string registry, string reposito * } * }] */ - try - { - string resolvedVersion = string.Equals(version, "*", StringComparison.OrdinalIgnoreCase) ? null : $"/{version}"; - string findImageUrl = string.Format(containerRegistryFindImageVersionUrlTemplate, registry, repositoryName, resolvedVersion); - var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); - return GetHttpResponseJObjectUsingDefaultHeaders(findImageUrl, HttpMethod.Get, defaultHeaders, out errRecord); - } - catch (HttpRequestException e) - { - throw new HttpRequestException("Error finding ContainerRegistry artifact: " + e.Message); - } + string resolvedVersion = string.Equals(version, "*", StringComparison.OrdinalIgnoreCase) ? null : $"/{version}"; + string findImageUrl = string.Format(containerRegistryFindImageVersionUrlTemplate, Registry, packageName, resolvedVersion); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + return GetHttpResponseJObjectUsingDefaultHeaders(findImageUrl, HttpMethod.Get, defaultHeaders, out errRecord); } - internal Hashtable GetContainerRegistryMetadata(string registry, string packageName, string exactTagVersion, string containerRegistryAccessToken, out ErrorRecord errRecord) + /// + /// Get metadata for a package version. + /// + internal Hashtable GetContainerRegistryMetadata(string packageName, string exactTagVersion, string containerRegistryAccessToken, out ErrorRecord errRecord) { Hashtable requiredVersionResponse = new Hashtable(); - var foundTags = FindContainerRegistryManifest(registry, packageName, exactTagVersion, containerRegistryAccessToken, out errRecord); - if (errRecord != null || foundTags == null) + var foundTags = FindContainerRegistryManifest(packageName, exactTagVersion, containerRegistryAccessToken, out errRecord); + if (errRecord != null) { return requiredVersionResponse; } @@ -564,11 +626,9 @@ internal Hashtable GetContainerRegistryMetadata(string registry, string packageN * } */ - var serverPkgInfo = GetMetadataProperty(foundTags, packageName, out Exception exception); - if (exception != null) + var serverPkgInfo = GetMetadataProperty(foundTags, packageName, out errRecord); + if (errRecord != null) { - errRecord = new ErrorRecord(exception, "ParseMetadataFailure", ErrorCategory.InvalidResult, this); - return requiredVersionResponse; } @@ -636,10 +696,10 @@ internal Hashtable GetContainerRegistryMetadata(string registry, string packageN catch (Exception e) { errRecord = new ErrorRecord( - new ArgumentException($"Error parsing server metadata: {e.Message}"), - "ParseMetadataFailure", - ErrorCategory.InvalidData, - this); + new ArgumentException($"Error parsing server metadata: {e.Message}"), + "ParseMetadataFailure", + ErrorCategory.InvalidData, + this); return requiredVersionResponse; } @@ -647,14 +707,34 @@ internal Hashtable GetContainerRegistryMetadata(string registry, string packageN return requiredVersionResponse; } - internal ContainerRegistryInfo GetMetadataProperty(JObject foundTags, string packageName, out Exception exception) + /// + /// Get the manifest associated with the package version. + /// + internal JObject FindContainerRegistryManifest(string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) { - exception = null; + var createManifestUrl = string.Format(containerRegistryManifestUrlTemplate, Registry, packageName, version); + _cmdletPassedIn.WriteDebug($"GET manifest url: {createManifestUrl}"); + + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + return GetHttpResponseJObjectUsingDefaultHeaders(createManifestUrl, HttpMethod.Get, defaultHeaders, out errRecord); + } + + /// + /// Get metadata for the package by parsing its manifest. + /// + internal ContainerRegistryInfo GetMetadataProperty(JObject foundTags, string packageName, out ErrorRecord errRecord) + { + errRecord = null; ContainerRegistryInfo serverPkgInfo = null; + var layers = foundTags["layers"]; if (layers == null || layers[0] == null) { - exception = new InvalidOrEmptyResponse($"Response does not contain 'layers' element in manifest for package '{packageName}' in '{Repository.Name}'."); + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain 'layers' element in manifest for package '{packageName}' in '{Repository.Name}'."), + "GetMetadataPropertyLayersError", + ErrorCategory.InvalidData, + this); return serverPkgInfo; } @@ -662,7 +742,11 @@ internal ContainerRegistryInfo GetMetadataProperty(JObject foundTags, string pac var annotations = layers[0]["annotations"]; if (annotations == null) { - exception = new InvalidOrEmptyResponse($"Response does not contain 'annotations' element in manifest for package '{packageName}' in '{Repository.Name}'."); + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain 'annotations' element in manifest for package '{packageName}' in '{Repository.Name}'."), + "GetMetadataPropertyAnnotationsError", + ErrorCategory.InvalidData, + this); return serverPkgInfo; } @@ -671,14 +755,23 @@ internal ContainerRegistryInfo GetMetadataProperty(JObject foundTags, string pac var pkgTitleJToken = annotations["org.opencontainers.image.title"]; if (pkgTitleJToken == null) { - exception = new InvalidOrEmptyResponse($"Response does not contain 'org.opencontainers.image.title' element for package '{packageName}' in '{Repository.Name}'."); + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain 'org.opencontainers.image.title' element for package '{packageName}' in '{Repository.Name}'."), + "GetMetadataPropertyOCITitleError", + ErrorCategory.InvalidData, + this); return serverPkgInfo; } + string metadataPkgName = pkgTitleJToken.ToString(); if (string.IsNullOrWhiteSpace(metadataPkgName)) { - exception = new InvalidOrEmptyResponse($"Response element 'org.opencontainers.image.title' is empty for package '{packageName}' in '{Repository.Name}'."); + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response element 'org.opencontainers.image.title' is empty for package '{packageName}' in '{Repository.Name}'."), + "GetMetadataPropertyOCITitleEmptyError", + ErrorCategory.InvalidData, + this); return serverPkgInfo; } @@ -687,68 +780,32 @@ internal ContainerRegistryInfo GetMetadataProperty(JObject foundTags, string pac var pkgMetadataJToken = annotations["metadata"]; if (pkgMetadataJToken == null) { - exception = new InvalidOrEmptyResponse($"Response does not contain 'metadata' element in manifest for package '{packageName}' in '{Repository.Name}'."); + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain 'metadata' element in manifest for package '{packageName}' in '{Repository.Name}'."), + "GetMetadataPropertyMetadataError", + ErrorCategory.InvalidData, + this); return serverPkgInfo; } + var metadata = pkgMetadataJToken.ToString(); // Check for package artifact type var resourceTypeJToken = annotations["resourceType"]; - var resourceType = resourceTypeJToken != null ? resourceTypeJToken.ToString() : string.Empty; + var resourceType = resourceTypeJToken != null ? resourceTypeJToken.ToString() : "None"; return new ContainerRegistryInfo(metadataPkgName, metadata, resourceType); } - internal JObject FindContainerRegistryManifest(string registry, string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) - { - try - { - var createManifestUrl = string.Format(containerRegistryManifestUrlTemplate, registry, packageName, version); - _cmdletPassedIn.WriteDebug($"GET manifest url: {createManifestUrl}"); - - var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); - return GetHttpResponseJObjectUsingDefaultHeaders(createManifestUrl, HttpMethod.Get, defaultHeaders, out errRecord); - } - catch (HttpRequestException e) - { - throw new HttpRequestException("Error finding ContainerRegistry manifest: " + e.Message); - } - } - - internal async Task GetStartUploadBlobLocation(string pkgName, string containerRegistryAccessToken) - { - try - { - var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); - var startUploadUrl = string.Format(containerRegistryStartUploadTemplate, Registry, pkgName); - return (await GetHttpResponseHeader(startUploadUrl, HttpMethod.Post, defaultHeaders)).Location.ToString(); - } - catch (HttpRequestException e) - { - throw new HttpRequestException("Error starting publishing to ContainerRegistry: " + e.Message); - } - } - - internal async Task EndUploadBlob(string location, string filePath, string digest, bool isManifest, string containerRegistryAccessToken) - { - try - { - var endUploadUrl = string.Format(containerRegistryEndUploadTemplate, Registry, location, digest); - var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); - return await PutRequestAsync(endUploadUrl, filePath, isManifest, defaultHeaders); - } - catch (HttpRequestException e) - { - throw new HttpRequestException("Error occured while trying to uploading module to ContainerRegistry: " + e.Message); - } - } - - internal async Task UploadManifest(string pkgName, string pkgVersion, string configPath, bool isManifest, string containerRegistryAccessToken) + /// + /// Upload manifest for the package, used for publishing. + /// + internal async Task UploadManifest(string packageName, string packageVersion, string configPath, bool isManifest, string containerRegistryAccessToken) { try { - var createManifestUrl = string.Format(containerRegistryManifestUrlTemplate, Registry, pkgName, pkgVersion); + var createManifestUrl = string.Format(containerRegistryManifestUrlTemplate, Registry, packageName, packageVersion); var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); return await PutRequestAsync(createManifestUrl, configPath, isManifest, defaultHeaders); } @@ -772,6 +829,9 @@ internal async Task GetHttpContentResponseJObject(string url, Colle } } + /// + /// Get response object when using default headers in the request. + /// internal JObject GetHttpResponseJObjectUsingDefaultHeaders(string url, HttpMethod method, Collection> defaultHeaders, out ErrorRecord errRecord) { try @@ -818,16 +878,25 @@ internal JObject GetHttpResponseJObjectUsingDefaultHeaders(string url, HttpMetho return null; } + /// + /// Get response object when using content headers in the request. + /// internal JObject GetHttpResponseJObjectUsingContentHeaders(string url, HttpMethod method, string content, Collection> contentHeaders, out ErrorRecord errRecord) { + errRecord = null; try { - errRecord = null; HttpRequestMessage request = new HttpRequestMessage(method, url); if (string.IsNullOrEmpty(content)) { - throw new ArgumentNullException("content"); + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Content is null or empty and cannot be used to make a request as its content headers."), + "RequestContentHeadersNullOrEmpty", + ErrorCategory.InvalidData, + _cmdletPassedIn); + + return null; } request.Content = new StringContent(content); @@ -878,6 +947,9 @@ internal JObject GetHttpResponseJObjectUsingContentHeaders(string url, HttpMetho return null; } + /// + /// Get response headers. + /// internal static async Task GetHttpResponseHeader(string url, HttpMethod method, Collection> defaultHeaders) { try @@ -892,6 +964,9 @@ internal static async Task GetHttpResponseHeader(string url } } + /// + /// Set default headers for HttpClient. + /// private static void SetDefaultHeaders(Collection> defaultHeaders) { s_client.DefaultRequestHeaders.Clear(); @@ -915,6 +990,9 @@ private static void SetDefaultHeaders(Collection> d } } + /// + /// Sends request for content. + /// private static async Task SendContentRequestAsync(HttpRequestMessage message) { try @@ -923,42 +1001,48 @@ private static async Task SendContentRequestAsync(HttpRequestMessag response.EnsureSuccessStatusCode(); return response.Content; } - catch (HttpRequestException e) + catch (Exception e) { - throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + throw new SendRequestException($"Error occured while sending request to Container Registry server for content with: {e.GetType()} '{e.Message}'", e); } } + /// + /// Sends HTTP request. + /// private static async Task SendRequestAsync(HttpRequestMessage message) { + HttpResponseMessage response; try { - HttpResponseMessage response = await s_client.SendAsync(message); - - switch (response.StatusCode) - { - case HttpStatusCode.OK: - break; + response = await s_client.SendAsync(message); + } + catch (Exception e) + { + throw new SendRequestException($"Error occured while sending request to Container Registry server with: {e.GetType()} '{e.Message}'", e); + } - case HttpStatusCode.Unauthorized: - throw new UnauthorizedException($"Response unauthorized: {response.ReasonPhrase}."); + switch (response.StatusCode) + { + case HttpStatusCode.OK: + break; - case HttpStatusCode.NotFound: - throw new ResourceNotFoundException($"Package not found: {response.ReasonPhrase}."); + case HttpStatusCode.Unauthorized: + throw new UnauthorizedException($"Response returned status code: {response.ReasonPhrase}."); - // all other errors - default: - throw new HttpRequestException($"Response returned error with status code {response.StatusCode}: {response.ReasonPhrase}."); - } + case HttpStatusCode.NotFound: + throw new ResourceNotFoundException($"Response returned status code package: {response.ReasonPhrase}."); - return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - } - catch (HttpRequestException e) - { - throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + default: + throw new Exception($"Response returned error with status code {response.StatusCode}: {response.ReasonPhrase}."); } + + return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); } + /// + /// Send request to get response headers. + /// private static async Task SendRequestHeaderAsync(HttpRequestMessage message) { try @@ -973,6 +1057,9 @@ private static async Task SendRequestHeaderAsync(HttpReques } } + /// + /// Sends a PUT request, used for publishing to container registry. + /// private static async Task PutRequestAsync(string url, string filePath, bool isManifest, Collection> contentHeaders) { try @@ -992,16 +1079,18 @@ private static async Task PutRequestAsync(string url, strin httpContent.Headers.Add("Content-Type", "application/octet-stream"); } - return await s_client.PutAsync(url, httpContent); ; + return await s_client.PutAsync(url, httpContent); } } - catch (HttpRequestException e) + catch (Exception e) { - throw new HttpRequestException("Error occured while trying to uploading module to ContainerRegistry: " + e.Message); + throw new SendRequestException($"Error occured while uploading module to ContainerRegistry: {e.GetType()} '{e.Message}'", e); } - } + /// + /// Get the default headers associated with the access token. + /// private static Collection> GetDefaultHeaders(string containerRegistryAccessToken) { var defaultHeaders = new Collection>(); @@ -1016,128 +1105,269 @@ private static Collection> GetDefaultHeaders(string return defaultHeaders; } - internal bool PushNupkgContainerRegistry(string psd1OrPs1File, string outputNupkgDir, string pkgName, NuGetVersion pkgVersion, PSRepositoryInfo repository, ResourceType resourceType, Hashtable parsedMetadataHash, Hashtable dependencies, out ErrorRecord errRecord) + #endregion + + #region Publish Methods + + /// + /// Helper method that publishes a package to the container registry. + /// This gets called from Publish-PSResource. + /// + internal bool PushNupkgContainerRegistry(string psd1OrPs1File, + string outputNupkgDir, + string packageName, + NuGetVersion packageVersion, + ResourceType resourceType, + Hashtable parsedMetadataHash, + Hashtable dependencies, + out ErrorRecord errRecord) { - string fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, pkgName + "." + pkgVersion.ToNormalizedString() + ".nupkg"); - string pkgNameLower = pkgName.ToLower(); + string fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, packageName + "." + packageVersion.ToNormalizedString() + ".nupkg"); + string packageNameLowercase = packageName.ToLower(); // Get access token (includes refresh tokens) - var containerRegistryAccessToken = GetContainerRegistryAccessToken(Repository, out errRecord); + var containerRegistryAccessToken = GetContainerRegistryAccessToken(out errRecord); + if (errRecord != null) + { + return false; + } // Upload .nupkg - TryUploadNupkg(pkgNameLower, containerRegistryAccessToken, fullNupkgFile, out string nupkgDigest); + string nupkgDigest = UploadNupkgFile(packageNameLowercase, containerRegistryAccessToken, fullNupkgFile, out errRecord); + if (errRecord != null) + { + return false; + } // Create and upload an empty file-- needed by ContainerRegistry server - TryCreateAndUploadEmptyFile(outputNupkgDir, pkgNameLower, containerRegistryAccessToken); + CreateAndUploadEmptyFile(outputNupkgDir, packageNameLowercase, containerRegistryAccessToken, out errRecord); + if (errRecord != null) + { + return false; + } // Create config.json file var configFilePath = System.IO.Path.Combine(outputNupkgDir, "config.json"); - TryCreateConfig(configFilePath, out string configDigest); + string configDigest = CreateConfigFile(configFilePath, out errRecord); + if (errRecord != null) + { + return false; + } _cmdletPassedIn.WriteVerbose("Create package version metadata as JSON string"); // Create module metadata string - string metadataJson = CreateMetadataContent(psd1OrPs1File, resourceType, parsedMetadataHash, out ErrorRecord metadataCreationError); - if (metadataCreationError != null) + string metadataJson = CreateMetadataContent(resourceType, parsedMetadataHash, out errRecord); + if (errRecord != null) { - _cmdletPassedIn.ThrowTerminatingError(metadataCreationError); + return false; } // Create and upload manifest - TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, pkgName, resourceType, metadataJson, configFilePath, - pkgNameLower, pkgVersion, containerRegistryAccessToken); + TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, packageName, resourceType, metadataJson, configFilePath, packageVersion, containerRegistryAccessToken, out errRecord); + if (errRecord != null) + { + return false; + } return true; } - private bool TryUploadNupkg(string pkgNameLower, string containerRegistryAccessToken, string fullNupkgFile, out string nupkgDigest) + /// + /// Upload the nupkg file, by creating a digest for it and uploading as blob. + /// Note: ContainerRegistry registries will only accept a name that is all lowercase. + /// + private string UploadNupkgFile(string packageNameLowercase, string containerRegistryAccessToken, string fullNupkgFile, out ErrorRecord errRecord) { _cmdletPassedIn.WriteVerbose("Start uploading blob"); - // Note: ContainerRegistry registries will only accept a name that is all lowercase. - var moduleLocation = GetStartUploadBlobLocation(pkgNameLower, containerRegistryAccessToken).Result; + string nupkgDigest = string.Empty; + errRecord = null; + string moduleLocation; + try + { + moduleLocation = GetStartUploadBlobLocation(packageNameLowercase, containerRegistryAccessToken).Result; + } + catch (Exception startUploadException) + { + errRecord = new ErrorRecord( + startUploadException, + "StartUploadBlobLocationError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return nupkgDigest; + } _cmdletPassedIn.WriteVerbose("Computing digest for .nupkg file"); - bool nupkgDigestCreated = CreateDigest(fullNupkgFile, out nupkgDigest, out ErrorRecord nupkgDigestError); - if (!nupkgDigestCreated) + nupkgDigest = CreateDigest(fullNupkgFile, out errRecord); + if (errRecord != null) { - _cmdletPassedIn.ThrowTerminatingError(nupkgDigestError); + return nupkgDigest; } _cmdletPassedIn.WriteVerbose("Finish uploading blob"); - var responseNupkg = EndUploadBlob(moduleLocation, fullNupkgFile, nupkgDigest, false, containerRegistryAccessToken).Result; + try + { + var responseNupkg = EndUploadBlob(moduleLocation, fullNupkgFile, nupkgDigest, isManifest: false, containerRegistryAccessToken).Result; + bool uploadSuccessful = responseNupkg.IsSuccessStatusCode; + + if (!uploadSuccessful) + { + errRecord = new ErrorRecord( + new UploadBlobException("Uploading of blob for publish failed."), + "EndUploadBlobError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); - return responseNupkg.IsSuccessStatusCode; + return nupkgDigest; + } + } + catch (Exception endUploadException) + { + errRecord = new ErrorRecord( + endUploadException, + "EndUploadBlobError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return nupkgDigest; + } + + return nupkgDigest; } - private bool TryCreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLower, string containerRegistryAccessToken) + /// + /// Uploads an empty file at the start of publish as is needed. + /// + private void CreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLower, string containerRegistryAccessToken, out ErrorRecord errRecord) { _cmdletPassedIn.WriteVerbose("Create an empty file"); - string emptyFileName = "empty.txt"; + string emptyFileName = "empty" + Guid.NewGuid().ToString() + ".txt"; var emptyFilePath = System.IO.Path.Combine(outputNupkgDir, emptyFileName); - // Rename the empty file in case such a file already exists in the temp folder (although highly unlikely) - while (File.Exists(emptyFilePath)) - { - emptyFilePath = Guid.NewGuid().ToString() + ".txt"; - } - Utils.CreateFile(emptyFilePath); - _cmdletPassedIn.WriteVerbose("Start uploading an empty file"); - var emptyLocation = GetStartUploadBlobLocation(pkgNameLower, containerRegistryAccessToken).Result; - _cmdletPassedIn.WriteVerbose("Computing digest for empty file"); - bool emptyDigestCreated = CreateDigest(emptyFilePath, out string emptyDigest, out ErrorRecord emptyDigestError); - if (!emptyDigestCreated) + try { - _cmdletPassedIn.ThrowTerminatingError(emptyDigestError); + Utils.CreateFile(emptyFilePath); + + _cmdletPassedIn.WriteVerbose("Start uploading an empty file"); + string emptyLocation = GetStartUploadBlobLocation(pkgNameLower, containerRegistryAccessToken).Result; + + _cmdletPassedIn.WriteVerbose("Computing digest for empty file"); + string emptyFileDigest = CreateDigest(emptyFilePath, out errRecord); + if (errRecord != null) + { + return; + } + + _cmdletPassedIn.WriteVerbose("Finish uploading empty file"); + var emptyResponse = EndUploadBlob(emptyLocation, emptyFilePath, emptyFileDigest, false, containerRegistryAccessToken).Result; + bool uploadSuccessful = emptyResponse.IsSuccessStatusCode; + + if (!uploadSuccessful) + { + errRecord = new ErrorRecord( + new UploadBlobException($"Error occurred while uploading blob, response code was: {emptyResponse.StatusCode} with reason {emptyResponse.ReasonPhrase}"), + "UploadEmptyFileError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return; + } } - _cmdletPassedIn.WriteVerbose("Finish uploading empty file"); - var emptyResponse = EndUploadBlob(emptyLocation, emptyFilePath, emptyDigest, false, containerRegistryAccessToken).Result; + catch (Exception e) + { + errRecord = new ErrorRecord( + e, + "UploadEmptyFileError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); - return emptyResponse.IsSuccessStatusCode; + return; + } } - private bool TryCreateConfig(string configFilePath, out string configDigest) + /// + /// Create config file associated with the package (i.e repository in container registry terms) as is needed for the package's manifest config layer + /// + private string CreateConfigFile(string configFilePath, out ErrorRecord errRecord) { + string configFileDigest = string.Empty; _cmdletPassedIn.WriteVerbose("Create the config file"); while (File.Exists(configFilePath)) { configFilePath = Guid.NewGuid().ToString() + ".json"; } - Utils.CreateFile(configFilePath); - _cmdletPassedIn.WriteVerbose("Computing digest for config"); - bool configDigestCreated = CreateDigest(configFilePath, out configDigest, out ErrorRecord configDigestError); - if (!configDigestCreated) + try { - _cmdletPassedIn.ThrowTerminatingError(configDigestError); + Utils.CreateFile(configFilePath); + + _cmdletPassedIn.WriteVerbose("Computing digest for config"); + configFileDigest = CreateDigest(configFilePath, out errRecord); + if (errRecord != null) + { + return configFileDigest; + } } + catch (Exception e) + { + errRecord = new ErrorRecord( + e, + "CreateConfigFileError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); - return configDigestCreated; + return configFileDigest; + } + + return configFileDigest; } - private bool TryCreateAndUploadManifest(string fullNupkgFile, string nupkgDigest, string configDigest, string pkgName, ResourceType resourceType, string metadataJson, string configFilePath, - string pkgNameLower, NuGetVersion pkgVersion, string containerRegistryAccessToken) + /// + /// Create the manifest for the package and upload it + /// + private bool TryCreateAndUploadManifest(string fullNupkgFile, + string nupkgDigest, + string configDigest, + string packageName, + ResourceType resourceType, + string metadataJson, + string configFilePath, + NuGetVersion pkgVersion, + string containerRegistryAccessToken, + out ErrorRecord errRecord) { + errRecord = null; + string packageNameLowercase = packageName.ToLower(); FileInfo nupkgFile = new FileInfo(fullNupkgFile); var fileSize = nupkgFile.Length; var fileName = System.IO.Path.GetFileName(fullNupkgFile); - string fileContent = CreateManifestContent(nupkgDigest, configDigest, fileSize, fileName, pkgName, resourceType, metadataJson); + string fileContent = CreateManifestContent(nupkgDigest, configDigest, fileSize, fileName, packageName, resourceType, metadataJson); File.WriteAllText(configFilePath, fileContent); _cmdletPassedIn.WriteVerbose("Create the manifest layer"); - HttpResponseMessage manifestResponse = UploadManifest(pkgNameLower, pkgVersion.OriginalVersion, configFilePath, true, containerRegistryAccessToken).Result; - bool manifestCreated = manifestResponse.IsSuccessStatusCode; - if (!manifestCreated) + bool manifestCreated = false; + try { - _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( - new ArgumentException("Error uploading package manifest"), - "PackageManifestUploadError", - ErrorCategory.InvalidResult, - _cmdletPassedIn)); - return false; + HttpResponseMessage manifestResponse = UploadManifest(packageNameLowercase, pkgVersion.OriginalVersion, configFilePath, true, containerRegistryAccessToken).Result; + manifestCreated = manifestResponse.IsSuccessStatusCode; + } + catch (Exception e) + { + errRecord = new ErrorRecord( + new UploadBlobException($"Error occured while uploading package manifest to ContainerRegistry: {e.GetType()} '{e.Message}'", e), + "PackageManifestUploadError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return manifestCreated; } return manifestCreated; } + /// + /// Create the content for the manifest for the packge. + /// private string CreateManifestContent( string nupkgDigest, string configDigest, @@ -1201,15 +1431,18 @@ private string CreateManifestContent( return stringWriter.ToString(); } - private bool CreateDigest(string fileName, out string digest, out ErrorRecord error) + /// + /// Create SHA256 digest that will be associated with .nupkg, config file or empty file. + /// + private string CreateDigest(string fileName, out ErrorRecord errRecord) { + errRecord = null; + string digest = string.Empty; FileInfo fileInfo = new FileInfo(fileName); SHA256 mySHA256 = SHA256.Create(); using (FileStream fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read)) { - digest = string.Empty; - try { // Create a fileStream for the file. @@ -1219,39 +1452,50 @@ private bool CreateDigest(string fileName, out string digest, out ErrorRecord er byte[] hashValue = mySHA256.ComputeHash(fileStream); StringBuilder stringBuilder = new StringBuilder(); foreach (byte b in hashValue) + { stringBuilder.AppendFormat("{0:x2}", b); + } + digest = stringBuilder.ToString(); // Write the name and hash value of the file to the console. _cmdletPassedIn.WriteVerbose($"{fileInfo.Name}: {digest}"); - error = null; } catch (IOException ex) { - var IOError = new ErrorRecord(ex, $"IOException for .nupkg file: {ex.Message}", ErrorCategory.InvalidOperation, null); - error = IOError; + errRecord = new ErrorRecord(ex, $"IOException for .nupkg file: {ex.Message}", ErrorCategory.InvalidOperation, null); + return digest; } catch (UnauthorizedAccessException ex) { - var AuthorizationError = new ErrorRecord(ex, $"UnauthorizedAccessException for .nupkg file: {ex.Message}", ErrorCategory.PermissionDenied, null); - error = AuthorizationError; + errRecord = new ErrorRecord(ex, $"UnauthorizedAccessException for .nupkg file: {ex.Message}", ErrorCategory.PermissionDenied, null); + return digest; + } + catch (Exception ex) + { + errRecord = new ErrorRecord(ex, $"Exception when creating digest: {ex.Message}", ErrorCategory.PermissionDenied, null); + return digest; } } - if (error != null) + + if (String.IsNullOrEmpty(digest)) { - return false; + errRecord = new ErrorRecord(new ArgumentNullException("Digest created was null or empty."), "DigestNullOrEmptyError.", ErrorCategory.InvalidResult, null); } - return true; + return digest; } - private string CreateMetadataContent(string manifestFilePath, ResourceType resourceType, Hashtable parsedMetadata, out ErrorRecord metadataCreationError) + /// + /// Create metadata for the package that will be populated in the manifest. + /// + private string CreateMetadataContent(ResourceType resourceType, Hashtable parsedMetadata, out ErrorRecord errRecord) { - metadataCreationError = null; + errRecord = null; string jsonString = string.Empty; if (parsedMetadata == null || parsedMetadata.Count == 0) { - metadataCreationError = new ErrorRecord( + errRecord = new ErrorRecord( new ArgumentException("Hashtable created from .ps1 or .psd1 containing package metadata was null or empty"), "MetadataHashtableEmptyError", ErrorCategory.InvalidArgument, @@ -1264,7 +1508,9 @@ private string CreateMetadataContent(string manifestFilePath, ResourceType resou if (parsedMetadata.ContainsKey("Version") && parsedMetadata["Version"] is NuGetVersion pkgNuGetVersion) { - // do not serialize NuGetVersion, this will populate more metadata than is needed and makes it harder to deserialize later + // For scripts, 'Version' entry will be present in hashtable and if it is of type NuGetVersion do not serialize NuGetVersion + // as this will populate more metadata than is needed and makes it harder to deserialize later. + // For modules, 'ModuleVersion' entry will already be present as type string which is correct. parsedMetadata.Remove("Version"); parsedMetadata["Version"] = pkgNuGetVersion.ToString(); } @@ -1275,13 +1521,54 @@ private string CreateMetadataContent(string manifestFilePath, ResourceType resou } catch (Exception ex) { - metadataCreationError = new ErrorRecord(ex, "JsonSerializationError", ErrorCategory.InvalidResult, _cmdletPassedIn); + errRecord = new ErrorRecord(ex, "JsonSerializationError", ErrorCategory.InvalidResult, _cmdletPassedIn); return jsonString; } return jsonString; } + /// + /// Get start location when uploading blob, used during publish. + /// + internal async Task GetStartUploadBlobLocation(string packageName, string containerRegistryAccessToken) + { + try + { + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + var startUploadUrl = string.Format(containerRegistryStartUploadTemplate, Registry, packageName); + return (await GetHttpResponseHeader(startUploadUrl, HttpMethod.Post, defaultHeaders)).Location.ToString(); + } + catch (Exception e) + { + throw new UploadBlobException($"Error occured while starting to upload the blob location used for publishing to ContainerRegistry: {e.GetType()} '{e.Message}'", e); + } + } + + /// + /// Upload blob, used for publishing + /// + internal async Task EndUploadBlob(string location, string filePath, string digest, bool isManifest, string containerRegistryAccessToken) + { + try + { + var endUploadUrl = string.Format(containerRegistryEndUploadTemplate, Registry, location, digest); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + return await PutRequestAsync(endUploadUrl, filePath, isManifest, defaultHeaders); + } + catch (Exception e) + { + throw new UploadBlobException($"Error occured while uploading module to ContainerRegistry: {e.GetType()} '{e.Message}'", e); + } + } + + #endregion + + #region Find Helper Methods + + /// + /// Helper method for find scenarios. + /// private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionType versionType, VersionRange versionRange, NuGetVersion requiredVersion, bool includePrerelease, bool getOnlyLatest, out ErrorRecord errRecord) { string accessToken = string.Empty; @@ -1289,13 +1576,13 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp string registryUrl = Repository.Uri.ToString(); string packageNameLowercase = packageName.ToLower(); - string containerRegistryAccessToken = GetContainerRegistryAccessToken(Repository, out errRecord); + string containerRegistryAccessToken = GetContainerRegistryAccessToken(out errRecord); if (errRecord != null) { return emptyHashResponses; } - var foundTags = FindContainerRegistryImageTags(Registry, packageNameLowercase, "*", containerRegistryAccessToken, out errRecord); + var foundTags = FindContainerRegistryImageTags(packageNameLowercase, "*", containerRegistryAccessToken, out errRecord); if (errRecord != null || foundTags == null) { return emptyHashResponses; @@ -1315,7 +1602,7 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp foreach (var pkgVersionTag in pkgsInDescendingOrder) { string exactTagVersion = pkgVersionTag.Value.ToString(); - Hashtable metadata = GetContainerRegistryMetadata(Registry, packageNameLowercase, exactTagVersion, containerRegistryAccessToken, out errRecord); + Hashtable metadata = GetContainerRegistryMetadata(packageNameLowercase, exactTagVersion, containerRegistryAccessToken, out errRecord); if (errRecord != null || metadata.Count == 0) { return emptyHashResponses; @@ -1332,6 +1619,9 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp return latestVersionResponse.ToArray(); } + /// + /// Helper method used for find scenarios that resolves versions required from all versions found. + /// private SortedDictionary GetPackagesWithRequiredVersion(List allPkgVersions, VersionType versionType, VersionRange versionRange, NuGetVersion specificVersion, string packageName, bool includePrerelease, out ErrorRecord errRecord) { errRecord = null; diff --git a/src/code/PSGetException.cs b/src/code/PSGetException.cs index 6b37c42e7..8385d1cdf 100644 --- a/src/code/PSGetException.cs +++ b/src/code/PSGetException.cs @@ -76,4 +76,20 @@ public ProcessDependencyException(string message, Exception innerException = nul { } } + + public class SendRequestException : Exception + { + public SendRequestException(string message, Exception innerException = null) + : base(message) + { + } + } + + public class UploadBlobException : Exception + { + public UploadBlobException(string message, Exception innerException = null) + : base(message) + { + } + } } diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index c27512872..d3ba80d4a 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -499,7 +499,7 @@ out string[] _ ContainerRegistryServerAPICalls containerRegistryServer = new ContainerRegistryServerAPICalls(repository, this, _networkCredential, userAgentString); var pkgMetadataFile = (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish; - if (!containerRegistryServer.PushNupkgContainerRegistry(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, repository, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgContainerRegistryError)) + if (!containerRegistryServer.PushNupkgContainerRegistry(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgContainerRegistryError)) { WriteError(pushNupkgContainerRegistryError); // exit out of processing From ee882774915bc697f61a6ba611cfb07deca4828c Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Mon, 18 Mar 2024 13:54:11 -0700 Subject: [PATCH 065/160] ACR: Parse 'ModuleList' from ACR server metadata to populate 'Dependencies' in PSResourceInfo object (#1604) --- src/code/PSResourceInfo.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 610e36046..9e2fc6bc2 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -969,6 +969,16 @@ public static bool TryConvertFromContainerRegistryJson( { metadata["Dependencies"] = ParseContainerRegistryDependencies(requiredModulesElement, out errorMsg).ToArray(); } + if (string.Equals(packageName, "Az", StringComparison.OrdinalIgnoreCase) || packageName.StartsWith("Az.", StringComparison.OrdinalIgnoreCase)) + { + if (rootDom.TryGetProperty("PrivateData", out JsonElement privateDataElement) && privateDataElement.TryGetProperty("PSData", out JsonElement psDataElement)) + { + if (psDataElement.TryGetProperty("ModuleList", out JsonElement moduleListDepsElement)) + { + metadata["Dependencies"] = ParseContainerRegistryDependencies(moduleListDepsElement, out errorMsg).ToArray(); + } + } + } var additionalMetadataHashtable = new Dictionary { From abfbf4222ef9a4c170b42e9c77df801040c69937 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Fri, 22 Mar 2024 09:17:39 -0700 Subject: [PATCH 066/160] Create OneBranch build and release pipeline (#1605) --- .config/tsaoptions.json | 10 + .pipelines/PSResourceGet-Official.yml | 334 ++++++++++++++++++ global.json | 5 + .../PublishPSResource.Tests.ps1 | 4 +- 4 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 .config/tsaoptions.json create mode 100644 .pipelines/PSResourceGet-Official.yml create mode 100644 global.json diff --git a/.config/tsaoptions.json b/.config/tsaoptions.json new file mode 100644 index 000000000..692eaec1f --- /dev/null +++ b/.config/tsaoptions.json @@ -0,0 +1,10 @@ +{ + "instanceUrl": "https://msazure.visualstudio.com", + "projectName": "One", + "areaPath": "One\\MGMT\\Compute\\Powershell\\Powershell\\PowerShell Core", + "notificationAliases": [ + "adityap@microsoft.com", + "americks@microsoft.com", + "annavied@microsoft.com" + ] +} diff --git a/.pipelines/PSResourceGet-Official.yml b/.pipelines/PSResourceGet-Official.yml new file mode 100644 index 000000000..c4b39f20b --- /dev/null +++ b/.pipelines/PSResourceGet-Official.yml @@ -0,0 +1,334 @@ +################################################################################# +# OneBranch Pipelines # +# This pipeline was created by EasyStart from a sample located at: # +# https://aka.ms/obpipelines/easystart/samples # +# Documentation: https://aka.ms/obpipelines # +# Yaml Schema: https://aka.ms/obpipelines/yaml/schema # +# Retail Tasks: https://aka.ms/obpipelines/tasks # +# Support: https://aka.ms/onebranchsup # +################################################################################# +name: PSResourceGet-Release-$(Build.BuildId) +trigger: none # https://aka.ms/obpipelines/triggers +pr: + branches: + include: + - main + - release* +parameters: # parameters are shown up in ADO UI in a build queue time +- name: 'debug' + displayName: 'Enable debug output' + type: boolean + default: false + +variables: + - name: DOTNET_CLI_TELEMETRY_OPTOUT + value: 1 + - name: POWERSHELL_TELEMETRY_OPTOUT + value: 1 + - name: WindowsContainerImage + value: onebranch.azurecr.io/windows/ltsc2022/vse2022:latest # Docker image which is used to build the project https://aka.ms/obpipelines/containers + +resources: + repositories: + - repository: onebranchTemplates + type: git + name: OneBranch.Pipelines/GovernedTemplates + ref: refs/heads/main + +extends: + template: v2/OneBranch.Official.CrossPlat.yml@onebranchTemplates # https://aka.ms/obpipelines/templates + parameters: + featureFlags: + WindowsHostVersion: '1ESWindows2022' + customTags: 'ES365AIMigrationTooling' + globalSdl: + disableLegacyManifest: true + sbom: + enabled: true + packageName: Microsoft.PowerShell.PSResourceGet + codeql: + compiled: + enabled: true + asyncSdl: # https://aka.ms/obpipelines/asyncsdl + enabled: true + forStages: [stagebuild] + credscan: + enabled: true + scanFolder: $(Build.SourcesDirectory)\PSResourceGet + binskim: + enabled: true + apiscan: + enabled: false + + stages: + - stage: stagebuild + displayName: Build and Package Microsoft.PowerShell.PSResourceGet + jobs: + - job: jobbuild + displayName: Build Microsoft.PowerShell.PSResourceGet Files + variables: # More settings at https://aka.ms/obpipelines/yaml/jobs + - name: ob_outputDirectory + value: '$(Build.ArtifactStagingDirectory)/ONEBRANCH_ARTIFACT' + - name: repoRoot + value: $(Build.SourcesDirectory)\PSResourceGet + - name: ob_sdl_tsa_configFile + value: $(Build.SourcesDirectory)\PSResourceGet\.config\tsaoptions.json + - name: signSrcPath + value: $(repoRoot)/out + - name: depsPath + value: $(signSrcPath)\Microsoft.PowerShell.PSResourceGet\Dependencies + - name: ob_sdl_sbom_enabled + value: true + - name: ob_signing_setup_enabled + value: true + #CodeQL tasks added manually to workaround signing failures + - name: ob_sdl_codeql_compiled_enabled + value: false + pool: + type: windows + steps: + - checkout: self + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + + - pwsh: | + if (-not (Test-Path $(repoRoot)/.config/tsaoptions.json)) { + Get-ChildItem $(Build.SourcesDirectory) -recurse -ErrorAction SilentlyContinue + throw "tsaoptions.json does not exist under $(repoRoot)/.config" + } + displayName: Test if tsaoptions.json exists + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + + - pwsh: | + Get-ChildItem env: + displayName: Capture Environment + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + + - task: UseDotNet@2 + displayName: 'Install .NET dependencies' + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + inputs: + packageType: 'sdk' + useGlobalJson: true + # this is to ensure that we are installing the dotnet at the same location as container by default install the dotnet sdks + installationPath: 'C:\Program Files\dotnet\' + workingDirectory: $(repoRoot) + + - task: CodeQL3000Init@0 # Add CodeQL Init task right before your 'Build' step. + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + inputs: + Enabled: true + AnalyzeInPipeline: true + Language: csharp + + - pwsh: | + $module = 'Microsoft.PowerShell.PSResourceGet' + Write-Verbose "installing $module..." -verbose + $ProgressPreference = 'SilentlyContinue' + Install-Module $module -AllowClobber -Force + displayName: Install PSResourceGet 0.9.0 or above for build.psm1 + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + + # this is installing .NET + - pwsh: | + Set-Location "$(repoRoot)" + try { ./build.ps1 -Build -Clean -BuildConfiguration Release -BuildFramework 'net472'} catch { throw $_ } + displayName: Execute build + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + + - task: CodeQL3000Finalize@0 # Add CodeQL Finalize task right after your 'Build' step. + condition: always() + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + + - task: onebranch.pipeline.signing@1 + displayName: Sign 1st party files + inputs: + command: 'sign' + signing_profile: external_distribution + files_to_sign: '**\*.psd1;**\*.psm1;**\*.ps1xml;**\Microsoft*.dll' + search_root: $(signSrcPath) + + - pwsh: | + $unsignedDepsPath = Join-Path -Path $(signSrcPath) -ChildPath "Microsoft.PowerShell.PSResourceGet" -AdditionalChildPath "UnsignedDependencies" + New-Item -Path $unsignedDepsPath -ItemType Directory -Force + + Get-ChildItem -Path $(depsPath) -Filter '*.dll' | Foreach-Object { + $sig = Get-AuthenticodeSignature -FilePath $_.FullName + if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notlike '*Microsoft*' -or $sig.SignerCertificate.Issuer -notlike '*Microsoft Code Signing PCA*') { + # Copy for third party signing + Copy-Item -Path $_.FullName -Dest $unsignedDepsPath -Force -Verbose + } + } + displayName: Find all 3rd party files that need to be signed + + - task: onebranch.pipeline.signing@1 + displayName: Sign 3rd Party files + inputs: + command: 'sign' + signing_profile: 135020002 + files_to_sign: '*.dll' + search_root: $(signSrcPath)/Microsoft.PowerShell.PSResourceGet/UnsignedDependencies + + - pwsh: | + $newlySignedDepsPath = Join-Path -Path $(signSrcPath) -ChildPath "Microsoft.PowerShell.PSResourceGet" -AdditionalChildPath "UnsignedDependencies" + Get-ChildItem -Path $newlySignedDepsPath -Filter '*.dll' | Foreach-Object { + $sig = Get-AuthenticodeSignature -FilePath $_.FullName + if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notlike '*Microsoft*' -or $sig.SignerCertificate.Issuer -notlike '*Microsoft Windows Production PCA*') { + Write-Error "File $($_.FileName) is not signed by Microsoft" + } + else { + Copy-Item -Path $_.FullName -Dest $(depsPath) -Force -Verbose + } + } + Remove-Item -Path $newlySignedDepsPath -Recurse -Force + displayName: Validate 3rd party files were signed + + - task: CopyFiles@2 + displayName: "Copy signed files to ob_outputDirectory - '$(ob_outputDirectory)'" + inputs: + SourceFolder: "$(signSrcPath)" + Contents: '**' + TargetFolder: $(ob_outputDirectory) + + - pwsh: | + Write-Host "Displaying contents of signSrcPath:" + Get-ChildItem $(signSrcPath) -Recurse + Write-Host "Displaying contents of ob_outputDirectory:" + Get-ChildItem $(ob_outputDirectory) -Recurse + displayName: Get contents of dirs with signed files + + - job: nupkg + dependsOn: jobbuild + displayName: Package Microsoft.PowerShell.PSResourceGet + variables: + - name: ob_outputDirectory + value: '$(Build.ArtifactStagingDirectory)/ONEBRANCH_ARTIFACT' + - name: repoRoot + value: $(Build.SourcesDirectory)\PSResourceGet + - name: ob_sdl_tsa_configFile + value: $(Build.SourcesDirectory)\PSResourceGet\.config\tsaoptions.json + # Disable because SBOM was already built in the previous job + - name: ob_sdl_sbom_enabled + value: false + - name: signOutPath + value: $(repoRoot)/signed + - name: ob_signing_setup_enabled + value: true + # This job is not compiling code, so disable codeQL + - name: ob_sdl_codeql_compiled_enabled + value: false + + pool: + type: windows + steps: + - checkout: self + + - pwsh: | + if (-not (Test-Path $(repoRoot)/.config/tsaoptions.json)) { + Get-ChildItem $(Build.SourcesDirectory) -recurse -ErrorAction SilentlyContinue + throw "tsaoptions.json does not exist under $(repoRoot)/.config" + } + displayName: Test if tsaoptions.json exists + + - task: DownloadPipelineArtifact@2 + displayName: 'Download build files' + inputs: + targetPath: $(signOutPath) + artifact: drop_stagebuild_jobbuild + + - pwsh: | + Set-Location "$(signOutPath)" + Write-Host "Contents of signOutPath:" + Get-ChildItem $(signOutPath) -Recurse + displayName: Capture artifacts directory structure + + - pwsh: | + $module = 'Microsoft.PowerShell.PSResourceGet' + Write-Verbose "installing $module..." -verbose + $ProgressPreference = 'SilentlyContinue' + Install-Module $module -AllowClobber -Force + displayName: Install PSResourceGet 0.9.0 or above for build.psm1 + + - pwsh: | + Set-Location "$(signOutPath)\Microsoft.PowerShell.PSResourceGet" + New-Item -ItemType Directory -Path "$(signOutPath)\PublishedNupkg" -Force + Register-PSResourceRepository -Name 'localRepo' -Uri "$(signOutPath)\PublishedNupkg" + Publish-PSResource -Path "$(signOutPath)\Microsoft.PowerShell.PSResourceGet" -Repository 'localRepo' -Verbose + displayName: Create nupkg for publishing + + - task: onebranch.pipeline.signing@1 + displayName: Sign nupkg + inputs: + command: 'sign' + signing_profile: external_distribution + files_to_sign: '**\*.nupkg' + search_root: "$(signOutPath)\PublishedNupkg" + + - pwsh: | + Set-Location "$(signOutPath)\PublishedNupkg" + Write-Host "Contents of signOutPath:" + Get-ChildItem "$(signOutPath)" -Recurse + displayName: Find Nupkg + + - task: CopyFiles@2 + displayName: "Copy nupkg to ob_outputDirectory - '$(ob_outputDirectory)'" + inputs: + Contents: $(signOutPath)\PublishedNupkg\Microsoft.PowerShell.PSResourceGet.*.nupkg + TargetFolder: $(ob_outputDirectory) + + - pwsh: | + Write-Host "Contents of ob_outputDirectory:" + Get-ChildItem "$(ob_outputDirectory)" -Recurse + displayName: Find Signed Nupkg + + - stage: release + displayName: Release PSResourceGet + dependsOn: stagebuild + variables: + version: $[ stageDependencies.build.main.outputs['package.version'] ] + drop: $(Pipeline.Workspace)/drop_build_main + jobs: + - job: validation + displayName: Manual validation + pool: + type: agentless + timeoutInMinutes: 1440 + steps: + - task: ManualValidation@0 + displayName: Wait 24 hours for validation + inputs: + instructions: Please validate the release + timeoutInMinutes: 1440 + - job: PSGalleryPublish + displayName: Publish to PSGallery + dependsOn: validation + pool: + type: windows + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)/ONEBRANCH_ARTIFACT' + steps: + - download: current + displayName: Download artifact + + - pwsh: | + Get-ChildItem $(Pipeline.Workspace) -Recurse + displayName: Capture environment + + - pwsh: | + Get-ChildItem "$(Pipeline.Workspace)/drop_stagebuild_nupkg" -Recurse + displayName: Find signed Nupkg + + - task: NuGetCommand@2 + displayName: Push PowerShellGet module artifacts to PSGallery feed + inputs: + command: push + packagesToPush: '$(Pipeline.Workspace)\drop_stagebuild_nupkg\PSResourceGet\signed\PublishedNupkg\Microsoft.PowerShell.PSResourceGet.*.nupkg' + nuGetFeedType: external + publishFeedCredentials: PSGet-PSGalleryPush diff --git a/global.json b/global.json new file mode 100644 index 000000000..65405ec9c --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "sdk": { + "version": "8.0.202" + } +} diff --git a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 index 35ba14881..0decd5011 100644 --- a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 @@ -433,8 +433,8 @@ Describe "Test Publish-PSResource" -tags 'CI' { It "Publish a module to PSGallery using incorrect API key, should throw" { $version = "1.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" - - Publish-PSResource -Path $script:PublishModuleBase -Repository PSGallery -APIKey "123456789" -ErrorAction SilentlyContinue + $APIKey = New-Guid + Publish-PSResource -Path $script:PublishModuleBase -Repository PSGallery -APIKey $APIKey -ErrorAction SilentlyContinue $Error[0].FullyQualifiedErrorId | Should -be "403Error,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" } From b28fba0fecd6736bad1a40640b9814b2bbf27cf5 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Mon, 1 Apr 2024 14:17:42 -0400 Subject: [PATCH 067/160] Add verbose and debug messages for Container Registry Server (#1615) --- src/code/ContainerRegistryServerAPICalls.cs | 34 +++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs index 0a7360109..5bf5872ad 100644 --- a/src/code/ContainerRegistryServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -301,6 +301,7 @@ private Stream InstallVersion( string packageVersion, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::InstallVersion()"); errRecord = null; string packageNameLowercase = packageName.ToLower(); string accessToken = string.Empty; @@ -371,6 +372,7 @@ private Stream InstallVersion( /// internal string GetContainerRegistryAccessToken(out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryAccessToken()"); string accessToken = string.Empty; string containerRegistryAccessToken = string.Empty; string tenantID = string.Empty; @@ -387,7 +389,6 @@ internal string GetContainerRegistryAccessToken(out ErrorRecord errRecord) _cmdletPassedIn.WriteVerbose("Access token retrieved."); tenantID = repositoryCredentialInfo.SecretName; - _cmdletPassedIn.WriteVerbose($"Tenant ID: {tenantID}"); } else { @@ -437,6 +438,7 @@ internal string GetContainerRegistryAccessToken(out ErrorRecord errRecord) /// internal bool IsContainerRegistryUnauthenticated(string containerRegistyUrl, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::IsContainerRegistryUnauthenticated()"); errRecord = null; string endpoint = $"{containerRegistyUrl}/v2/"; HttpResponseMessage response; @@ -463,6 +465,7 @@ internal bool IsContainerRegistryUnauthenticated(string containerRegistyUrl, out /// internal string GetContainerRegistryRefreshToken(string tenant, string accessToken, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryRefreshToken()"); string content = string.Format(containerRegistryRefreshTokenTemplate, Registry, tenant, accessToken); var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; string exchangeUrl = string.Format(containerRegistryOAuthExchangeUrlTemplate, Registry); @@ -480,6 +483,7 @@ internal string GetContainerRegistryRefreshToken(string tenant, string accessTok /// internal string GetContainerRegistryAccessTokenByRefreshToken(string refreshToken, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryAccessTokenByRefreshToken()"); string content = string.Format(containerRegistryAccessTokenTemplate, Registry, refreshToken); var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; string tokenUrl = string.Format(containerRegistryOAuthTokenUrlTemplate, Registry); @@ -501,6 +505,7 @@ internal string GetContainerRegistryAccessTokenByRefreshToken(string refreshToke /// private string GetDigestFromManifest(JObject manifest, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetDigestFromManifest()"); errRecord = null; string digest = String.Empty; @@ -544,6 +549,7 @@ private string GetDigestFromManifest(JObject manifest, out ErrorRecord errRecord /// internal JObject GetContainerRegistryRepositoryManifest(string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryRepositoryManifest()"); // example of manifestUrl: https://psgetregistry.azurecr.io/hello-world:3.0.0 string manifestUrl = string.Format(containerRegistryManifestUrlTemplate, Registry, packageName, version); var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); @@ -556,6 +562,7 @@ internal JObject GetContainerRegistryRepositoryManifest(string packageName, stri /// internal async Task GetContainerRegistryBlobAsync(string packageName, string digest, string containerRegistryAccessToken) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryBlobAsync()"); string blobUrl = string.Format(containerRegistryBlobDownloadUrlTemplate, Registry, packageName, digest); var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); return await GetHttpContentResponseJObject(blobUrl, defaultHeaders); @@ -585,6 +592,7 @@ internal JObject FindContainerRegistryImageTags(string packageName, string versi * } * }] */ + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindContainerRegistryImageTags()"); string resolvedVersion = string.Equals(version, "*", StringComparison.OrdinalIgnoreCase) ? null : $"/{version}"; string findImageUrl = string.Format(containerRegistryFindImageVersionUrlTemplate, Registry, packageName, resolvedVersion); var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); @@ -596,6 +604,7 @@ internal JObject FindContainerRegistryImageTags(string packageName, string versi /// internal Hashtable GetContainerRegistryMetadata(string packageName, string exactTagVersion, string containerRegistryAccessToken, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryMetadata()"); Hashtable requiredVersionResponse = new Hashtable(); var foundTags = FindContainerRegistryManifest(packageName, exactTagVersion, containerRegistryAccessToken, out errRecord); @@ -712,6 +721,7 @@ internal Hashtable GetContainerRegistryMetadata(string packageName, string exact /// internal JObject FindContainerRegistryManifest(string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindContainerRegistryManifest()"); var createManifestUrl = string.Format(containerRegistryManifestUrlTemplate, Registry, packageName, version); _cmdletPassedIn.WriteDebug($"GET manifest url: {createManifestUrl}"); @@ -724,6 +734,7 @@ internal JObject FindContainerRegistryManifest(string packageName, string versio /// internal ContainerRegistryInfo GetMetadataProperty(JObject foundTags, string packageName, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetMetadataProperty()"); errRecord = null; ContainerRegistryInfo serverPkgInfo = null; @@ -803,6 +814,7 @@ internal ContainerRegistryInfo GetMetadataProperty(JObject foundTags, string pac /// internal async Task UploadManifest(string packageName, string packageVersion, string configPath, bool isManifest, string containerRegistryAccessToken) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::UploadManifest()"); try { var createManifestUrl = string.Format(containerRegistryManifestUrlTemplate, Registry, packageName, packageVersion); @@ -817,6 +829,7 @@ internal async Task UploadManifest(string packageName, stri internal async Task GetHttpContentResponseJObject(string url, Collection> defaultHeaders) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetHttpContentResponseJObject()"); try { HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url); @@ -834,6 +847,7 @@ internal async Task GetHttpContentResponseJObject(string url, Colle /// internal JObject GetHttpResponseJObjectUsingDefaultHeaders(string url, HttpMethod method, Collection> defaultHeaders, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetHttpResponseJObjectUsingDefaultHeaders()"); try { errRecord = null; @@ -883,6 +897,7 @@ internal JObject GetHttpResponseJObjectUsingDefaultHeaders(string url, HttpMetho /// internal JObject GetHttpResponseJObjectUsingContentHeaders(string url, HttpMethod method, string content, Collection> contentHeaders, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetHttpResponseJObjectUsingContentHeaders()"); errRecord = null; try { @@ -1122,10 +1137,12 @@ internal bool PushNupkgContainerRegistry(string psd1OrPs1File, Hashtable dependencies, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::PushNupkgContainerRegistry()"); string fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, packageName + "." + packageVersion.ToNormalizedString() + ".nupkg"); string packageNameLowercase = packageName.ToLower(); // Get access token (includes refresh tokens) + _cmdletPassedIn.WriteVerbose($"Get access token for container registry server."); var containerRegistryAccessToken = GetContainerRegistryAccessToken(out errRecord); if (errRecord != null) { @@ -1133,6 +1150,7 @@ internal bool PushNupkgContainerRegistry(string psd1OrPs1File, } // Upload .nupkg + _cmdletPassedIn.WriteVerbose($"Upload .nupkg file: {fullNupkgFile}"); string nupkgDigest = UploadNupkgFile(packageNameLowercase, containerRegistryAccessToken, fullNupkgFile, out errRecord); if (errRecord != null) { @@ -1148,6 +1166,7 @@ internal bool PushNupkgContainerRegistry(string psd1OrPs1File, // Create config.json file var configFilePath = System.IO.Path.Combine(outputNupkgDir, "config.json"); + _cmdletPassedIn.WriteVerbose($"Create config.json file at path: {configFilePath}"); string configDigest = CreateConfigFile(configFilePath, out errRecord); if (errRecord != null) { @@ -1178,6 +1197,7 @@ internal bool PushNupkgContainerRegistry(string psd1OrPs1File, /// private string UploadNupkgFile(string packageNameLowercase, string containerRegistryAccessToken, string fullNupkgFile, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::UploadNupkgFile()"); _cmdletPassedIn.WriteVerbose("Start uploading blob"); string nupkgDigest = string.Empty; errRecord = null; @@ -1240,6 +1260,7 @@ private string UploadNupkgFile(string packageNameLowercase, string containerRegi /// private void CreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLower, string containerRegistryAccessToken, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateAndUploadEmptyFile()"); _cmdletPassedIn.WriteVerbose("Create an empty file"); string emptyFileName = "empty" + Guid.NewGuid().ToString() + ".txt"; var emptyFilePath = System.IO.Path.Combine(outputNupkgDir, emptyFileName); @@ -1290,6 +1311,7 @@ private void CreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLower /// private string CreateConfigFile(string configFilePath, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateConfigFile()"); string configFileDigest = string.Empty; _cmdletPassedIn.WriteVerbose("Create the config file"); while (File.Exists(configFilePath)) @@ -1336,6 +1358,7 @@ private bool TryCreateAndUploadManifest(string fullNupkgFile, string containerRegistryAccessToken, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::TryCreateAndUploadManifest()"); errRecord = null; string packageNameLowercase = packageName.ToLower(); FileInfo nupkgFile = new FileInfo(fullNupkgFile); @@ -1377,6 +1400,7 @@ private string CreateManifestContent( ResourceType resourceType, string metadata) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateManifestContent()"); StringBuilder stringBuilder = new StringBuilder(); StringWriter stringWriter = new StringWriter(stringBuilder); JsonTextWriter jsonWriter = new JsonTextWriter(stringWriter); @@ -1436,6 +1460,7 @@ private string CreateManifestContent( /// private string CreateDigest(string fileName, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateDigest()"); errRecord = null; string digest = string.Empty; FileInfo fileInfo = new FileInfo(fileName); @@ -1457,8 +1482,6 @@ private string CreateDigest(string fileName, out ErrorRecord errRecord) } digest = stringBuilder.ToString(); - // Write the name and hash value of the file to the console. - _cmdletPassedIn.WriteVerbose($"{fileInfo.Name}: {digest}"); } catch (IOException ex) { @@ -1490,6 +1513,7 @@ private string CreateDigest(string fileName, out ErrorRecord errRecord) /// private string CreateMetadataContent(ResourceType resourceType, Hashtable parsedMetadata, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateMetadataContent()"); errRecord = null; string jsonString = string.Empty; @@ -1533,6 +1557,7 @@ private string CreateMetadataContent(ResourceType resourceType, Hashtable parsed /// internal async Task GetStartUploadBlobLocation(string packageName, string containerRegistryAccessToken) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetStartUploadBlobLocation()"); try { var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); @@ -1550,6 +1575,7 @@ internal async Task GetStartUploadBlobLocation(string packageName, strin /// internal async Task EndUploadBlob(string location, string filePath, string digest, bool isManifest, string containerRegistryAccessToken) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::EndUploadBlob()"); try { var endUploadUrl = string.Format(containerRegistryEndUploadTemplate, Registry, location, digest); @@ -1571,6 +1597,7 @@ internal async Task EndUploadBlob(string location, string f /// private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionType versionType, VersionRange versionRange, NuGetVersion requiredVersion, bool includePrerelease, bool getOnlyLatest, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindPackagesWithVersionHelper()"); string accessToken = string.Empty; string tenantID = string.Empty; string registryUrl = Repository.Uri.ToString(); @@ -1624,6 +1651,7 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp /// private SortedDictionary GetPackagesWithRequiredVersion(List allPkgVersions, VersionType versionType, VersionRange versionRange, NuGetVersion specificVersion, string packageName, bool includePrerelease, out ErrorRecord errRecord) { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetPackagesWithRequiredVersion()"); errRecord = null; // we need NuGetVersion to sort versions by order, and string pkgVersionString (which is the exact tag from the server) to call GetContainerRegistryMetadata() later with exact version tag. SortedDictionary sortedPkgs = new SortedDictionary(VersionComparer.Default); From d08d5fd1b87db0ebdc3e3fc5daa22f74a131b7f8 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 2 Apr 2024 08:45:14 -0700 Subject: [PATCH 068/160] Update version, changelog, and release notes for 1.1.0-preview1 release (#1616) --- CHANGELOG/1.0.md | 5 ++ CHANGELOG/preview.md | 13 ++++- src/Microsoft.PowerShell.PSResourceGet.psd1 | 49 ++++++++++++++++++- .../Microsoft.PowerShell.PSResourceGet.csproj | 6 +-- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/CHANGELOG/1.0.md b/CHANGELOG/1.0.md index 8477b723c..63909e508 100644 --- a/CHANGELOG/1.0.md +++ b/CHANGELOG/1.0.md @@ -1,5 +1,10 @@ # 1.0 Changelog +## [1.0.3](https://github.com/PowerShell/PSResourceGet/compare/v1.0.2...v1.0.3) - 2024-03-13 + +### Bug Fixes +- Bug fix for null package version in `Install-PSResource` + ## [1.0.2](https://github.com/PowerShell/PSResourceGet/compare/v1.0.1...v1.0.2) - 2024-02-06 ### Bug Fixes diff --git a/CHANGELOG/preview.md b/CHANGELOG/preview.md index 978dd5dfa..3e4653abb 100644 --- a/CHANGELOG/preview.md +++ b/CHANGELOG/preview.md @@ -1 +1,12 @@ -This file will contain changelog updates for preview releases for Microsoft.PowerShell.PSResourceGet \ No newline at end of file +## [1.1.0-preview1](https://github.com/PowerShell/PSResourceGet/compare/v1.0.3...v1.1.0-preview1) - 2024-04-01 + +### New Features + +- Support for Azure Container Registries (#1495, #1497-#1499, #1501, #1502, #1505, #1522, #1545, #1548, #1550, #1554, #1560, #1567, +#1573, #1576, #1587, #1588, #1589, #1594, #1598, #1600, #1602, #1604, #1615) + +### Bug Fixes + +- Fix incorrect request URL when installing resources from ADO (#1597 Thanks @anytonyoni!) +- Fix for swallowed exceptions (#1569) +- Fix for PSResourceGet not working in Constrained Languange Mode (#1564) diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index e4e01cfbb..b5abe9ae6 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -4,7 +4,7 @@ @{ RootModule = './Microsoft.PowerShell.PSResourceGet.dll' NestedModules = @('./Microsoft.PowerShell.PSResourceGet.psm1') - ModuleVersion = '1.0.0' + ModuleVersion = '1.1.0' CompatiblePSEditions = @('Core', 'Desktop') GUID = 'e4e0bda1-0703-44a5-b70d-8fe704cd0643' Author = 'Microsoft Corporation' @@ -45,7 +45,7 @@ 'udres') PrivateData = @{ PSData = @{ - #Prerelease = '' + Prerelease = 'preview1' Tags = @('PackageManagement', 'PSEdition_Desktop', 'PSEdition_Core', @@ -55,6 +55,51 @@ ProjectUri = 'https://go.microsoft.com/fwlink/?LinkId=828955' LicenseUri = 'https://go.microsoft.com/fwlink/?LinkId=829061' ReleaseNotes = @' +## 1.1.0-preview1 + +### New Features + +- Support for Azure Container Registries (#1495, #1497-#1499, #1501, #1502, #1505, #1522, #1545, #1548, #1550, #1554, #1560, #1567, +#1573, #1576, #1587, #1588, #1589, #1594, #1598, #1600, #1602, #1604, #1615) + +### Bug Fixes + +- Fix incorrect request URL when installing resources from ADO (#1597 Thanks @anytonyoni!) +- Fix for swallowed exceptions (#1569) +- Fix for PSResourceGet not working in Constrained Languange Mode (#1564) + +## 1.0.3 + +### Bug Fixes +- Bug fix for null package version in `Install-PSResource` + +## 1.0.2 + +### Bug Fixes + +- Bug fix for `Update-PSResource` not updating from correct repository (#1549) +- Bug fix for creating temp home directory on Unix (#1544) +- Bug fix for creating `InstalledScriptInfos` directory when it does not exist (#1542) +- Bug fix for `Update-ModuleManifest` throwing null pointer exception (#1538) +- Bug fix for `name` property not populating in `PSResourceInfo` object when using `Find-PSResource` with JFrog Artifactory (#1535) +- Bug fix for incorrect configuration of requests to JFrog Artifactory v2 endpoints (#1533 Thanks @sean-r-williams!) +- Bug fix for determining JFrog Artifactory repositories (#1532 Thanks @sean-r-williams!) +- Bug fix for v2 server repositories incorrectly adding script endpoint (1526) +- Bug fixes for null references (#1525) +- Typo fixes in message prompts in `Install-PSResource` (#1510 Thanks @NextGData!) +- Bug fix to add `NormalizedVersion` property to `AdditionalMetadata` only when it exists (#1503 Thanks @sean-r-williams!) +- Bug fix to verify whether `Uri` is a UNC path and set respective `ApiVersion` (#1479 Thanks @kborowinski!) + +## 1.0.1 + +### Bug Fixes + +- Bugfix to update Unix local user installation paths to be compatible with .NET 7 and .NET 8 (#1464) +- Bugfix for Import-PSGetRepository in Windows PowerShell (#1460) +- Bugfix for `Test-PSScriptFileInfo`` to be less sensitive to whitespace (#1457) +- Bugfix to overwrite rels/rels directory on net472 when extracting nupkg to directory (#1456) +- Bugfix to add pipeline by property name support for Name and Repository properties for Find-PSResource (#1451 Thanks @ThomasNieto!) + ## 1.0.0 ### New Features diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index cdf0feb9d..90364354e 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -5,9 +5,9 @@ Library Microsoft.PowerShell.PSResourceGet Microsoft.PowerShell.PSResourceGet - 1.0.0.0 - 1.0.0 - 1.0.0 + 1.1.0.1 + 1.1.0.1 + 1.1.0.1 net472;netstandard2.0 9.0 true From ce1d459e28c07c2cf35cdf75b1244695e0a3d70e Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 2 Apr 2024 10:15:50 -0700 Subject: [PATCH 069/160] Update branches included in release pipeline (#1617) --- .pipelines/PSResourceGet-Official.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pipelines/PSResourceGet-Official.yml b/.pipelines/PSResourceGet-Official.yml index c4b39f20b..bc6a10f14 100644 --- a/.pipelines/PSResourceGet-Official.yml +++ b/.pipelines/PSResourceGet-Official.yml @@ -12,7 +12,7 @@ trigger: none # https://aka.ms/obpipelines/triggers pr: branches: include: - - main + - master - release* parameters: # parameters are shown up in ADO UI in a build queue time - name: 'debug' From 44449a22d63bd6607eef983362efc55269efe40a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Apr 2024 12:43:25 -0700 Subject: [PATCH 070/160] Bump Azure.Identity from 1.10.4 to 1.11.0 in /src/code (#1631) --- src/code/Microsoft.PowerShell.PSResourceGet.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index 90364354e..f2b0a327c 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -24,7 +24,7 @@ - + From 150caac59bdbe949027cc28bd55a0ca5dbfc9814 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Apr 2024 12:43:51 -0700 Subject: [PATCH 071/160] Bump Microsoft.PowerShell.SDK in /test/perf/benchmarks (#1632) --- test/perf/benchmarks/benchmarks.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/perf/benchmarks/benchmarks.csproj b/test/perf/benchmarks/benchmarks.csproj index 2a6f727cb..d34e3f9b2 100644 --- a/test/perf/benchmarks/benchmarks.csproj +++ b/test/perf/benchmarks/benchmarks.csproj @@ -20,6 +20,6 @@ - + From 56dfb336aaf2136be0bc2d4cb318e20763fd083f Mon Sep 17 00:00:00 2001 From: evelyn-bi <165832757+evelyn-bi@users.noreply.github.com> Date: Fri, 12 Apr 2024 13:44:37 -0600 Subject: [PATCH 072/160] Fix requiring 'tags' in server response (#1627) --- src/code/V3ServerAPICalls.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index b7f580f2a..66e55d447 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -492,7 +492,7 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); } - if (!rootDom.TryGetProperty(tagsName, out JsonElement tagsItem)) + if (!rootDom.TryGetProperty(tagsName, out JsonElement tagsItem) && tags.Length != 0) { errRecord = new ErrorRecord( new InvalidOrEmptyResponse($"Response does not contain '{tagsName}' element for search with Name '{packageName}' in '{Repository.Name}'."), @@ -602,7 +602,7 @@ private FindResults FindVersionHelper(string packageName, string version, string return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); } - if (!rootDom.TryGetProperty(tagsName, out JsonElement tagsItem)) + if (!rootDom.TryGetProperty(tagsName, out JsonElement tagsItem) && tags.Length != 0) { errRecord = new ErrorRecord( new InvalidOrEmptyResponse($"Response does not contain '{tagsName}' element for search with name '{packageName}' and version '{version}' in repository '{Repository.Name}'."), From 7d88cf49655f2007f764befa56b0f6d5caba78b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20R=C3=B8nnestad=20Birkeland?= <6450056+o-l-a-v@users.noreply.github.com> Date: Fri, 12 Apr 2024 21:45:04 +0200 Subject: [PATCH 073/160] Attempt fix save script without -includexml (#1609) (#1614) --- src/code/InstallHelper.cs | 6 ++++-- .../SavePSResourceV2Tests.ps1 | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 6ea1cd9ca..cd86fe180 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -522,8 +522,10 @@ private void MoveFilesIntoInstallPath( } } else { - _cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, scriptXML))); - Utils.MoveFiles(Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, scriptXML)); + if (_includeXml) { + _cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, scriptXML))); + Utils.MoveFiles(Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, scriptXML)); + } } _cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", scriptPath, Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt))); diff --git a/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 b/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 index 4d15e5b3b..8f7d540c5 100644 --- a/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 +++ b/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 @@ -125,7 +125,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { $pkgDirVersion.Name | Should -Be "5.2.5" } - It "Save a module with a dependency" { + It "Save a module with a dependency" { Save-PSResource -Name "TestModuleWithDependencyE" -Version "1.0.0.0" -Repository $PSGalleryName -Path $SaveDir -TrustRepository $pkgDirs = Get-ChildItem -Path $SaveDir | Where-Object { $_.Name -eq "TestModuleWithDependencyE" -or $_.Name -eq "TestModuleWithDependencyC" -or $_.Name -eq "TestModuleWithDependencyB" -or $_.Name -eq "TestModuleWithDependencyD"} $pkgDirs.Count | Should -BeGreaterThan 1 @@ -148,7 +148,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { Find-PSResource -Name $testModuleName -Version "5.2.5-alpha001" -Repository $PSGalleryName | Save-PSResource -Path $SaveDir -TrustRepository -Verbose $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName $pkgDir | Should -Not -BeNullOrEmpty - (Get-ChildItem -Path $pkgDir.FullName) | Should -HaveCount 1 + (Get-ChildItem -Path $pkgDir.FullName) | Should -HaveCount 1 } It "Save module as a nupkg" { @@ -173,10 +173,17 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { $res.Version | Should -Be "1.0.0.0" } + It "Save script without using -IncludeXML" { + Save-PSResource -Name $testScriptName -Repository $PSGalleryName -Path $SaveDir -TrustRepository | Should -Not -Throw + + $SavedScriptFile = Join-Path -Path $SaveDir -ChildPath "$testScriptName.ps1" + Test-Path -Path $SavedScriptFile -PathType 'Leaf' | Should -BeTrue + } + It "Save script using -IncludeXML" { - Save-PSResource -Name $testScriptName -Repository $PSGalleryName -Path $SaveDir -TrustRepository + Save-PSResource -Name $testScriptName -Repository $PSGalleryName -Path $SaveDir -TrustRepository -IncludeXml - $scriptXML = $testScriptNamen + "_InstalledScriptInfo.xml" + $scriptXML = $testScriptName + "_InstalledScriptInfo.xml" $savedScriptFile = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "test_script.ps1" $savedScriptXML = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $scriptXML $savedScriptFile | Should -Not -BeNullOrEmpty @@ -192,4 +199,4 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { $err.Count | Should -BeGreaterThan 0 $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" } -} \ No newline at end of file +} From 07896d1de0a7d4316bf2d0506a31fdf6f1047862 Mon Sep 17 00:00:00 2001 From: gerryleys <43810381+gerryleys@users.noreply.github.com> Date: Fri, 12 Apr 2024 21:49:45 +0200 Subject: [PATCH 074/160] Pat token fix (#1599) --- src/code/ServerApiCall.cs | 25 ++++++++++++++++++++++--- src/code/Utils.cs | 2 +- src/code/V2ServerAPICalls.cs | 24 +++++++++++++++++++++--- src/code/V3ServerAPICalls.cs | 26 ++++++++++++++++++++++---- 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/code/ServerApiCall.cs b/src/code/ServerApiCall.cs index 047d1e1e2..2c811ad41 100644 --- a/src/code/ServerApiCall.cs +++ b/src/code/ServerApiCall.cs @@ -2,10 +2,12 @@ // Licensed under the MIT License. using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using System; using System.IO; using System.Net.Http; using NuGet.Versioning; using System.Net; +using System.Text; using System.Runtime.ExceptionServices; using System.Management.Automation; @@ -25,12 +27,29 @@ internal abstract class ServerApiCall : IServerAPICalls public ServerApiCall(PSRepositoryInfo repository, NetworkCredential networkCredential) { this.Repository = repository; - HttpClientHandler handler = new HttpClientHandler() + + HttpClientHandler handler = new HttpClientHandler(); + bool token = false; + + if(networkCredential != null) { - Credentials = networkCredential + token = String.Equals("token", networkCredential.UserName) ? true : false; }; - _sessionClient = new HttpClient(handler); + if (token) + { + string credString = string.Format(":{0}", networkCredential.Password); + byte[] byteArray = Encoding.ASCII.GetBytes(credString); + + _sessionClient = new HttpClient(handler); + _sessionClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); + + } else { + + handler.Credentials = networkCredential; + + _sessionClient = new HttpClient(handler); + }; } #endregion diff --git a/src/code/Utils.cs b/src/code/Utils.cs index d118b6be6..e2dc8406c 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -611,7 +611,7 @@ public static PSCredential GetRepositoryCredentialFromSecretManagement( } else if (secretObject.BaseObject is SecureString secretString) { - return new PSCredential(repositoryCredentialInfo.SecretName, secretString); + return new PSCredential("token", secretString); } } diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index b2a16797a..3303b1b23 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using System.Xml; using System.Net; +using System.Text; using System.Runtime.ExceptionServices; using System.Management.Automation; using System.Reflection; @@ -51,12 +52,29 @@ public V2ServerAPICalls (PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, N { this.Repository = repository; _cmdletPassedIn = cmdletPassedIn; - HttpClientHandler handler = new HttpClientHandler() + HttpClientHandler handler = new HttpClientHandler(); + bool token = false; + + if(networkCredential != null) + { + token = String.Equals("token", networkCredential.UserName) ? true : false; + }; + + if (token) { - Credentials = networkCredential + string credString = string.Format(":{0}", networkCredential.Password); + byte[] byteArray = Encoding.ASCII.GetBytes(credString); + + _sessionClient = new HttpClient(handler); + _sessionClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); + + } else { + + handler.Credentials = networkCredential; + + _sessionClient = new HttpClient(handler); }; - _sessionClient = new HttpClient(handler); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); var repoURL = repository.Uri.ToString().ToLower(); _isADORepo = repoURL.Contains("pkgs.dev.azure.com") || repoURL.Contains("pkgs.visualstudio.com"); diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index 66e55d447..242a4cd44 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -9,6 +9,7 @@ using System.Net.Http; using System.Linq; using System.Net; +using System.Text; using System.Text.Json; using System.Threading.Tasks; using System.Collections; @@ -50,13 +51,30 @@ public V3ServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, Ne { this.Repository = repository; _cmdletPassedIn = cmdletPassedIn; - HttpClientHandler handler = new HttpClientHandler() + HttpClientHandler handler = new HttpClientHandler(); + handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; + bool token = false; + + if(networkCredential != null) + { + token = String.Equals("token", networkCredential.UserName) ? true : false; + }; + + if (token) { - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, - Credentials = networkCredential + string credString = string.Format(":{0}", networkCredential.Password); + byte[] byteArray = Encoding.ASCII.GetBytes(credString); + + _sessionClient = new HttpClient(handler); + _sessionClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); + + } else { + + handler.Credentials = networkCredential; + + _sessionClient = new HttpClient(handler); }; - _sessionClient = new HttpClient(handler); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); _isNuGetRepo = String.Equals(Repository.Uri.AbsoluteUri, nugetRepoUri, StringComparison.InvariantCultureIgnoreCase); From bb4c0d0402a43cb561b33c681024ec6312a8474d Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Mon, 15 Apr 2024 11:41:47 -0700 Subject: [PATCH 075/160] Update version of CodeQL (#1620) --- .github/workflows/codeql-analysis.yml | 78 +++++++++++++-------------- 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d261f7cdb..66fede3bf 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,62 +1,60 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. name: "CodeQL" on: push: - branches: [master] + branches: [ master ] pull_request: - # The branches below must be a subset of the branches above - branches: [master] - #schedule: - # - cron: '0 7 * * 0' + branches: [ master ] + +defaults: + run: + shell: pwsh + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + +permissions: + contents: read jobs: analyze: + permissions: + actions: read # for github/codeql-action/init to get workflow details + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/analyze to upload SARIF results name: Analyze runs-on: ubuntu-latest - + strategy: fail-fast: false matrix: - # Override automatic language detection by changing the below list - # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] - language: ['csharp'] - # Learn more... - # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection - + include: + - language: csharp + build-mode: manual + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + - run: | + Get-ChildItem . + name: Capture env + + - run: | + .\build.ps1 -Clean -Build + name: Build + - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" From 3cf77c2794c9f875331d91e40ff6159515f4bf64 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Mon, 29 Apr 2024 11:34:47 -0700 Subject: [PATCH 076/160] Add 10 minute timeout to HTTPClient (#1626) --- src/code/ContainerRegistryServerAPICalls.cs | 34 ++++++++++----------- src/code/NuGetServerAPICalls.cs | 1 + src/code/ServerApiCall.cs | 4 ++- src/code/V2ServerAPICalls.cs | 2 ++ src/code/V3ServerAPICalls.cs | 2 ++ 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs index 5bf5872ad..7d9998777 100644 --- a/src/code/ContainerRegistryServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -20,7 +20,6 @@ using System.Text; using System.Security.Cryptography; using System.Text.Json; -using System.Runtime.InteropServices.WindowsRuntime; namespace Microsoft.PowerShell.PSResourceGet { @@ -48,8 +47,6 @@ internal class ContainerRegistryServerAPICalls : ServerApiCall const string containerRegistryStartUploadTemplate = "https://{0}/v2/{1}/blobs/uploads/"; // 0 - registry, 1 - packagename const string containerRegistryEndUploadTemplate = "https://{0}{1}&digest=sha256:{2}"; // 0 - registry, 1 - location, 2 - digest - private static readonly HttpClient s_client = new HttpClient(); - #endregion #region Constructor @@ -65,6 +62,7 @@ public ContainerRegistryServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmd }; _sessionClient = new HttpClient(handler); + _sessionClient.Timeout = TimeSpan.FromMinutes(10); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); } @@ -444,7 +442,7 @@ internal bool IsContainerRegistryUnauthenticated(string containerRegistyUrl, out HttpResponseMessage response; try { - response = s_client.SendAsync(new HttpRequestMessage(HttpMethod.Head, endpoint)).Result; + response = _sessionClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, endpoint)).Result; } catch (Exception e) { @@ -965,7 +963,7 @@ internal JObject GetHttpResponseJObjectUsingContentHeaders(string url, HttpMetho /// /// Get response headers. /// - internal static async Task GetHttpResponseHeader(string url, HttpMethod method, Collection> defaultHeaders) + internal async Task GetHttpResponseHeader(string url, HttpMethod method, Collection> defaultHeaders) { try { @@ -982,24 +980,24 @@ internal static async Task GetHttpResponseHeader(string url /// /// Set default headers for HttpClient. /// - private static void SetDefaultHeaders(Collection> defaultHeaders) + private void SetDefaultHeaders(Collection> defaultHeaders) { - s_client.DefaultRequestHeaders.Clear(); + _sessionClient.DefaultRequestHeaders.Clear(); if (defaultHeaders != null) { foreach (var header in defaultHeaders) { if (string.Equals(header.Key, "Authorization", StringComparison.OrdinalIgnoreCase)) { - s_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", header.Value); + _sessionClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", header.Value); } else if (string.Equals(header.Key, "Accept", StringComparison.OrdinalIgnoreCase)) { - s_client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(header.Value)); + _sessionClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(header.Value)); } else { - s_client.DefaultRequestHeaders.Add(header.Key, header.Value); + _sessionClient.DefaultRequestHeaders.Add(header.Key, header.Value); } } } @@ -1008,11 +1006,11 @@ private static void SetDefaultHeaders(Collection> d /// /// Sends request for content. /// - private static async Task SendContentRequestAsync(HttpRequestMessage message) + private async Task SendContentRequestAsync(HttpRequestMessage message) { try { - HttpResponseMessage response = await s_client.SendAsync(message); + HttpResponseMessage response = await _sessionClient.SendAsync(message); response.EnsureSuccessStatusCode(); return response.Content; } @@ -1025,12 +1023,12 @@ private static async Task SendContentRequestAsync(HttpRequestMessag /// /// Sends HTTP request. /// - private static async Task SendRequestAsync(HttpRequestMessage message) + private async Task SendRequestAsync(HttpRequestMessage message) { HttpResponseMessage response; try { - response = await s_client.SendAsync(message); + response = await _sessionClient.SendAsync(message); } catch (Exception e) { @@ -1058,11 +1056,11 @@ private static async Task SendRequestAsync(HttpRequestMessage message) /// /// Send request to get response headers. /// - private static async Task SendRequestHeaderAsync(HttpRequestMessage message) + private async Task SendRequestHeaderAsync(HttpRequestMessage message) { try { - HttpResponseMessage response = await s_client.SendAsync(message); + HttpResponseMessage response = await _sessionClient.SendAsync(message); response.EnsureSuccessStatusCode(); return response.Headers; } @@ -1075,7 +1073,7 @@ private static async Task SendRequestHeaderAsync(HttpReques /// /// Sends a PUT request, used for publishing to container registry. /// - private static async Task PutRequestAsync(string url, string filePath, bool isManifest, Collection> contentHeaders) + private async Task PutRequestAsync(string url, string filePath, bool isManifest, Collection> contentHeaders) { try { @@ -1094,7 +1092,7 @@ private static async Task PutRequestAsync(string url, strin httpContent.Headers.Add("Content-Type", "application/octet-stream"); } - return await s_client.PutAsync(url, httpContent); + return await _sessionClient.PutAsync(url, httpContent); } } catch (Exception e) diff --git a/src/code/NuGetServerAPICalls.cs b/src/code/NuGetServerAPICalls.cs index 2c5df2a5b..c56110f39 100644 --- a/src/code/NuGetServerAPICalls.cs +++ b/src/code/NuGetServerAPICalls.cs @@ -40,6 +40,7 @@ public NuGetServerAPICalls (PSRepositoryInfo repository, PSCmdlet cmdletPassedIn }; _sessionClient = new HttpClient(handler); + _sessionClient.Timeout = TimeSpan.FromMinutes(10); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); } diff --git a/src/code/ServerApiCall.cs b/src/code/ServerApiCall.cs index 2c811ad41..9f8bf4fe9 100644 --- a/src/code/ServerApiCall.cs +++ b/src/code/ServerApiCall.cs @@ -10,6 +10,7 @@ using System.Text; using System.Runtime.ExceptionServices; using System.Management.Automation; +using System; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets { @@ -43,13 +44,14 @@ public ServerApiCall(PSRepositoryInfo repository, NetworkCredential networkCrede _sessionClient = new HttpClient(handler); _sessionClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); - } else { handler.Credentials = networkCredential; _sessionClient = new HttpClient(handler); }; + _sessionClient.Timeout = TimeSpan.FromMinutes(10); + } #endregion diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index 3303b1b23..a3fdf2d5d 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -75,6 +75,8 @@ public V2ServerAPICalls (PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, N _sessionClient = new HttpClient(handler); }; + _sessionClient = new HttpClient(handler); + _sessionClient.Timeout = TimeSpan.FromMinutes(10); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); var repoURL = repository.Uri.ToString().ToLower(); _isADORepo = repoURL.Contains("pkgs.dev.azure.com") || repoURL.Contains("pkgs.visualstudio.com"); diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index 242a4cd44..a6b8d3620 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -75,6 +75,8 @@ public V3ServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, Ne _sessionClient = new HttpClient(handler); }; + _sessionClient = new HttpClient(handler); + _sessionClient.Timeout = TimeSpan.FromMinutes(10); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); _isNuGetRepo = String.Equals(Repository.Uri.AbsoluteUri, nugetRepoUri, StringComparison.InvariantCultureIgnoreCase); From 4237d15a1628cd731cc36822a0e91e7a222068f9 Mon Sep 17 00:00:00 2001 From: StepSecurity Bot Date: Tue, 30 Apr 2024 14:32:00 -0700 Subject: [PATCH 077/160] [StepSecurity] Apply security best practices (#1647) --- .github/dependabot.yml | 15 ++++++ .github/workflows/codeql-analysis.yml | 6 +-- .github/workflows/dependency-review.yml | 22 ++++++++ .github/workflows/scorecards.yml | 71 +++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/scorecards.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cce6e4180..1bb37493a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,3 +14,18 @@ updates: directory: "test/perf/benchmarks" # Location of package manifests schedule: interval: "daily" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + + - package-ecosystem: nuget + directory: / + schedule: + interval: daily + + - package-ecosystem: nuget + directory: /test/perf/benchmarks + schedule: + interval: daily diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 66fede3bf..e92f0d635 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,11 +36,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 with: languages: ${{ matrix.language }} # ℹ️ Command-line programs to run using the OS shell. @@ -55,6 +55,6 @@ jobs: name: Build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..a52521621 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,22 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, +# surfacing known-vulnerable versions of the packages declared or updated in the PR. +# Once installed, if the workflow run is marked as required, +# PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - name: 'Dependency Review' + uses: actions/dependency-review-action@0efb1d1d84fc9633afcdaad14c485cbbc90ef46c # v2.5.1 diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml new file mode 100644 index 000000000..75ca49424 --- /dev/null +++ b/.github/workflows/scorecards.yml @@ -0,0 +1,71 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '20 7 * * 2' + push: + branches: ["master"] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + contents: read + actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@99c53751e09b9529366343771cc321ec74e9bd3d # v2.0.6 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecards on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@ceaec5c11a131e0d282ff3b6f095917d234caace # v2.25.3 + with: + sarif_file: results.sarif From 9d164585c888404ffec68981f30b0fffda2f172e Mon Sep 17 00:00:00 2001 From: Sean Williams <72675818+sean-r-williams@users.noreply.github.com> Date: Tue, 7 May 2024 13:45:26 -0700 Subject: [PATCH 078/160] V2ServerAPICalls: fix unnecessary `and` in FindVersionGlobbing w/ Artifactory (#1644) --- src/code/V2ServerAPICalls.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index a3fdf2d5d..1e7943f47 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -1152,7 +1152,7 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange // Single case where version is "*" (or "[,]") and includePrerelease is true, then we do not want to add "$filter" to the requestUrl. // Note: could be null/empty if Version was "*" -> [,] - filterQuery += $"{andOperator}{versionFilterParts}"; + filterQuery += $"{(filterQuery.EndsWith("=") ? String.Empty : andOperator)}{versionFilterParts}"; } string paginationParam = $"$inlinecount=allpages&$skip={skip}"; From bab809531323473de0bc36331c264d76b11db7b6 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Tue, 7 May 2024 16:29:40 -0700 Subject: [PATCH 079/160] Update `nuget.config` to use CFS feed (#1649) --- nuget.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nuget.config b/nuget.config index 654858614..5c704bf46 100644 --- a/nuget.config +++ b/nuget.config @@ -2,7 +2,7 @@ - + From 666b6806444bd4aed6dc3e0e9f1a46a1b4d2f574 Mon Sep 17 00:00:00 2001 From: Sean Williams <72675818+sean-r-williams@users.noreply.github.com> Date: Thu, 9 May 2024 15:26:54 -0700 Subject: [PATCH 080/160] Refactor V2/NuGetServerAPICalls to use object-oriented query/filter builder (#1645) --- .../Microsoft.PowerShell.PSResourceGet.csproj | 1 + src/code/NuGetServerAPICalls.cs | 212 ++++++++---- src/code/V2QueryBuilder.cs | 224 +++++++++++++ src/code/V2ServerAPICalls.cs | 312 ++++++++++++------ 4 files changed, 586 insertions(+), 163 deletions(-) create mode 100644 src/code/V2QueryBuilder.cs diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index f2b0a327c..d4692e4c4 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -25,6 +25,7 @@ + diff --git a/src/code/NuGetServerAPICalls.cs b/src/code/NuGetServerAPICalls.cs index c56110f39..2ecabd001 100644 --- a/src/code/NuGetServerAPICalls.cs +++ b/src/code/NuGetServerAPICalls.cs @@ -164,14 +164,20 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include public override FindResults FindName(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindName()"); - // Make sure to include quotations around the package name - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"; - // This should return the latest stable version or the latest prerelease version (respectively) // https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet'&$filter=IsLatestVersion and substringof('PSModule', Tags) eq true + + // Make sure to include quotations around the package name + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + + filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"); + // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. - string idFilterPart = $" and Id eq '{packageName}'"; - var requestUrl = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$filter={prerelease}{idFilterPart}"; + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + var requestUrl = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrl, out errRecord); return new FindResults(stringResponse: new string[]{ response }, hashtableResponse: emptyHashResponses, responseType: FindResponseType); @@ -186,20 +192,26 @@ public override FindResults FindName(string packageName, bool includePrerelease, public override FindResults FindNameWithTag(string packageName, string[] tags, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindNameWithTag()"); - // Make sure to include quotations around the package name - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"; // This should return the latest stable version or the latest prerelease version (respectively) // https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet'&$filter=IsLatestVersion and substringof('PSModule', Tags) eq true + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. - string idFilterPart = $" and Id eq '{packageName}'"; - string tagFilterPart = String.Empty; + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + + filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"); + foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrl = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$filter={prerelease}{idFilterPart}{tagFilterPart}"; + var requestUrl = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrl, out errRecord); return new FindResults(stringResponse: new string[] { response }, hashtableResponse: emptyHashResponses, responseType: FindResponseType); @@ -360,9 +372,17 @@ public override FindResults FindVersion(string packageName, string version, Reso _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindVersion()"); // https://www.powershellgallery.com/api/v2/FindPackagesById()?id='blah'&includePrerelease=false&$filter= NormalizedVersion eq '1.1.0' and substringof('PSModule', Tags) eq true // Quotations around package name and version do not matter, same metadata gets returned. + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. - string idFilterPart = $" and Id eq '{packageName}'"; - var requestUrl = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$filter= NormalizedVersion eq '{version}'{idFilterPart}"; + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + filterBuilder.AddCriterion($"NormalizedVersion eq '{packageName}'"); + + var requestUrl = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrl, out errRecord); return new FindResults(stringResponse: new string[] { response }, hashtableResponse: emptyHashResponses, responseType: FindResponseType); @@ -377,15 +397,22 @@ public override FindResults FindVersion(string packageName, string version, Reso public override FindResults FindVersionWithTag(string packageName, string version, string[] tags, ResourceType type, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindVersionWithTag()"); + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. - string idFilterPart = $" and Id eq '{packageName}'"; - string tagFilterPart = String.Empty; + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + filterBuilder.AddCriterion($"NormalizedVersion eq '{packageName}'"); + foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrl = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$filter= NormalizedVersion eq '{version}'{idFilterPart}{tagFilterPart}"; + var requestUrl = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrl, out errRecord); return new FindResults(stringResponse: new string[] { response }, hashtableResponse: emptyHashResponses, responseType: FindResponseType); @@ -527,9 +554,24 @@ private HttpContent HttpRequestCallForContent(string requestUrl, out ErrorRecord private string FindAllFromEndPoint(bool includePrerelease, int skip, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindAllFromEndPoint()"); - string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; - var prereleaseFilter = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; - var requestUrl = $"{Repository.Uri}/Search()?$filter={prereleaseFilter}{paginationParam}"; + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString() }, + { "$top", "6000" }, + { "$orderBy", "Id desc" }, + }); + + var filterBuilder = queryBuilder.FilterBuilder; + + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + + var requestUrl = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrl, out errRecord); } @@ -540,16 +582,29 @@ private string FindAllFromEndPoint(bool includePrerelease, int skip, out ErrorRe private string FindTagFromEndpoint(string[] tags, bool includePrerelease, int skip, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindTagFromEndpoint()"); - string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; - var prereleaseFilter = includePrerelease ? "$filter=IsAbsoluteLatestVersion&includePrerelease=true" : "$filter=IsLatestVersion"; - string tagFilterPart = String.Empty; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString() }, + { "$top", "6000" }, + { "$orderBy", "Id desc" }, + }); + + var filterBuilder = queryBuilder.FilterBuilder; + + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrl = $"{Repository.Uri}/Search()?{prereleaseFilter}{tagFilterPart}{paginationParam}"; + var requestUrl = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrl, out errRecord); } @@ -563,9 +618,22 @@ private string FindNameGlobbing(string packageName, bool includePrerelease, int // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and startswith(Id, 'PowerShell') and IsLatestVersion (stable) // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and IsAbsoluteLatestVersion&includePrerelease=true - string extraParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100"; - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; - string nameFilter; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString() }, + { "$top", "100" }, + { "$orderBy", "NormalizedVersion desc" }, + }); + + var filterBuilder = queryBuilder.FilterBuilder; + + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + var names = packageName.Split(new char[] {'*'}, StringSplitOptions.RemoveEmptyEntries); @@ -584,17 +652,17 @@ private string FindNameGlobbing(string packageName, bool includePrerelease, int if (packageName.StartsWith("*") && packageName.EndsWith("*")) { // *get* - nameFilter = $"substringof('{names[0]}', Id)"; + filterBuilder.AddCriterion($"substringof('{names[0]}', Id)"); } else if (packageName.EndsWith("*")) { // PowerShell* - nameFilter = $"startswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}')"); } else { // *ShellGet - nameFilter = $"endswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"endswith(Id, '{names[0]}')"); } } else if (names.Length == 2 && !packageName.StartsWith("*") && !packageName.EndsWith("*")) @@ -603,7 +671,7 @@ private string FindNameGlobbing(string packageName, bool includePrerelease, int // pow*get -> only support this // pow*get* // *pow*get - nameFilter = $"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"); } else { @@ -616,7 +684,7 @@ private string FindNameGlobbing(string packageName, bool includePrerelease, int return string.Empty; } - var requestUrl = $"{Repository.Uri}/Search()?$filter={nameFilter} and {prerelease}{extraParam}"; + var requestUrl = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrl, out errRecord); } @@ -630,9 +698,21 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, bool i // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and startswith(Id, 'PowerShell') and IsLatestVersion (stable) // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and IsAbsoluteLatestVersion&includePrerelease=true - string extraParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100"; - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; - string nameFilter; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString() }, + { "$top", "100" }, + { "$orderBy", "Id desc" }, + }); + + var filterBuilder = queryBuilder.FilterBuilder; + + if (includePrerelease) { + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } var names = packageName.Split(new char[] {'*'}, StringSplitOptions.RemoveEmptyEntries); @@ -651,17 +731,17 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, bool i if (packageName.StartsWith("*") && packageName.EndsWith("*")) { // *get* - nameFilter = $"substringof('{names[0]}', Id)"; + filterBuilder.AddCriterion($"substringof('{names[0]}', Id)"); } else if (packageName.EndsWith("*")) { // PowerShell* - nameFilter = $"startswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}')"); } else { // *ShellGet - nameFilter = $"endswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"endswith(Id, '{names[0]}')"); } } else if (names.Length == 2 && !packageName.StartsWith("*") && !packageName.EndsWith("*")) @@ -670,7 +750,7 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, bool i // pow*get -> only support this // pow*get* // *pow*get - nameFilter = $"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"); } else { @@ -683,13 +763,12 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, bool i return string.Empty; } - string tagFilterPart = String.Empty; foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrl = $"{Repository.Uri}/Search()?$filter={nameFilter}{tagFilterPart} and {prerelease}{extraParam}"; + var requestUrl = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrl, out errRecord); } @@ -708,6 +787,16 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange // Current bug: Find PSGet -Version "2.0.*" -> https://www.powershellgallery.com/api/v2//FindPackagesById()?id='PowerShellGet'&includePrerelease=false&$filter= Version gt '2.0.*' and Version lt '2.1' // Make sure to include quotations around the package name + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + { "id", $"'{packageName}'" }, + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString() }, + { "$top", getOnlyLatest ? "1" : "100" }, + { "$orderBy", "NormalizedVersion desc" }, + }); + + var filterBuilder = queryBuilder.FilterBuilder; + //and IsPrerelease eq false // ex: // (2.0.0, 3.0.0) @@ -742,42 +831,23 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange } string versionFilterParts = String.Empty; - if (!String.IsNullOrEmpty(minPart) && !String.IsNullOrEmpty(maxPart)) + if (!String.IsNullOrEmpty(minPart)) { - versionFilterParts += minPart + " and " + maxPart; + filterBuilder.AddCriterion(minPart); } - else if (!String.IsNullOrEmpty(minPart)) + if (!String.IsNullOrEmpty(maxPart)) { - versionFilterParts += minPart; + filterBuilder.AddCriterion(maxPart); } - else if (!String.IsNullOrEmpty(maxPart)) - { - versionFilterParts += maxPart; - } - - string filterQuery = "&$filter="; - filterQuery += includePrerelease ? string.Empty : "IsPrerelease eq false"; - string andOperator = " and "; - string joiningOperator = filterQuery.EndsWith("=") ? String.Empty : andOperator; - // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. - string idFilterPart = $"{joiningOperator}Id eq '{packageName}'"; - filterQuery += idFilterPart; - - if (!String.IsNullOrEmpty(versionFilterParts)) - { - // Check if includePrerelease is true, if it is we want to add "$filter" - // Single case where version is "*" (or "[,]") and includePrerelease is true, then we do not want to add "$filter" to the requestUrl. - - // Note: could be null/empty if Version was "*" -> [,] - filterQuery += $"{andOperator}{versionFilterParts}"; + if (!includePrerelease) { + filterBuilder.AddCriterion("IsPrerelease eq false"); } - string topParam = getOnlyLatest ? "$top=1" : "$top=100"; // only need 1 package if interested in latest - string paginationParam = $"$inlinecount=allpages&$skip={skip}&{topParam}"; + // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. + filterBuilder.AddCriterion($"Id eq '{packageName}'"); - filterQuery = filterQuery.EndsWith("=") ? string.Empty : filterQuery; - var requestUrl = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$orderby=NormalizedVersion desc&{paginationParam}{filterQuery}"; + var requestUrl = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrl, out errRecord); } diff --git a/src/code/V2QueryBuilder.cs b/src/code/V2QueryBuilder.cs new file mode 100644 index 000000000..c4891d5dd --- /dev/null +++ b/src/code/V2QueryBuilder.cs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Configuration.Assemblies; +using System.IO; +using System.Linq; +using System.Text; +using System.Web; + +namespace Microsoft.PowerShell.PSResourceGet.Cmdlets +{ + internal class NuGetV2QueryBuilder + { + + internal Dictionary AdditionalParameters { get; private set; } + + /// + /// The filter to use when querying the NuGet API (query parameter $filter), if needed. + /// + /// + /// If no criteria are added with , the built query string will not contain a $filter parameter unless is true. + /// + internal NuGetV2FilterBuilder FilterBuilder { get; private set; } + + /// + /// Indicates whether an empty $filter parameter should be emitted if contains no criteria. + /// + internal bool ShouldEmitEmptyFilter = false; + + /// + /// The search term to pass to NuGet (searchTerm parameter), if needed. + /// + /// + /// No additional quote-encapsulation is performed on the string. A string will cause the parameter to be omitted. + /// + internal string SearchTerm; + + /// + /// Construct a new with no additional query parameters. + /// + internal NuGetV2QueryBuilder() + { + + FilterBuilder = new NuGetV2FilterBuilder(); + AdditionalParameters = new Dictionary { }; + } + + /// + /// Construct a new with a user-specified collection of query parameters. + /// + /// + /// The set of additional parameters to provide. + /// + internal NuGetV2QueryBuilder(Dictionary parameters) : this() + { + AdditionalParameters = new Dictionary(parameters); + } + + /// + /// Serialize the instance to an HTTP-compatible query string. + /// + /// + /// Query key-value pairs from will take precedence. + /// + /// + /// A containing URL-encoded query parameters separated by . No ? is prefixed at the beginning of the string. + /// + internal string BuildQueryString() + { + + var QueryParameters = HttpUtility.ParseQueryString(""); + + + if (FilterBuilder.CriteriaCount > 0 || ShouldEmitEmptyFilter) + { + QueryParameters["$filter"] = FilterBuilder.BuildFilterString(); + } + + if (SearchTerm != null) { + QueryParameters["searchTerm"] = SearchTerm; + } + + foreach (var parameter in AdditionalParameters) + { + QueryParameters[parameter.Key] = parameter.Value; + } + + return QueryParameters.ToString(); + + } + + } + + /// + /// Helper class for building NuGet v2 (OData) filter strings based on a set of criteria + /// + internal class NuGetV2FilterBuilder + { + + /// + /// Construct a new with an empty set of criteria. + /// + internal NuGetV2FilterBuilder() + { + + } + + private HashSet FilterCriteria = new HashSet(); + + /// + /// Convert the builder's provided set of filter criteria into an OData-compatible filter string. + /// + /// + /// Criteria order is not guaranteed. Filter criteria are combined with the and operator. + /// + /// + /// Filter criteria combined into a single string. + /// + /// + /// The following example will emit one of the two values: + /// + /// + /// IsPrerelease eq false and Id eq 'Microsoft.PowerShell.PSResourceGet' + /// + /// + /// Id eq 'Microsoft.PowerShell.PSResourceGet' and IsPrerelease eq false + /// + /// + /// + /// var filter = new NuGetV2FilterBuilder(); + /// filter.AddCriteria("IsPrerelease eq false"); + /// filter.AddCriteria("Id eq 'Microsoft.PowerShell.PSResourceGet'"); + /// return filter.BuildFilterString(); + /// + /// + public string BuildFilterString() + { + + if (FilterCriteria.Count == 0) + { + return ""; + } + + // Parenthesizing binary criteria (like "Id eq 'Foo'") would ideally provide better isolation/debuggability of mis-built filters. + // However, a $filter like "(IsLatestVersion)" appears to be rejected by PSGallery (possibly because grouping operators cannot be used with single unary operators). + // Parenthesizing only binary criteria requires more introspection into the underlying criteria, which we don't currently have with string-form criteria. + + // Figure out the expected size of our filter string, based on: + int ExpectedSize = FilterCriteria.Select(x => x.Length).Sum() // The length of the filter criteria themselves. + + 5 * (FilterCriteria.Count - 1); // The length of the combining string, " and ", interpolated between the filters. + + // Allocate a StringBuilder with our calculated capacity. + // This helps right-size memory allocation and reduces performance impact from resizing the builder's internal capacity. + StringBuilder sb = new StringBuilder(ExpectedSize); + + // StringBuilder.AppendJoin() is not available in .NET 4.8.1/.NET Standard 2, + // so we have to make do with repeated calls to Append(). + + + int CriteriaAdded = 0; + + foreach (string filter in FilterCriteria) + { + sb.Append(filter); + CriteriaAdded++; + if (CriteriaAdded < FilterCriteria.Count) + { + sb.Append(" and "); + } + } + + return sb.ToString(); + + } + + /// + /// Add a given OData-compatible criterion to the object's internal criteria set. + /// + /// + /// The criterion to add, e.g. IsLatestVersion or Id eq 'Foo'. + /// + /// + /// A boolean indicating whether the criterion was added to the set. false indicates the criteria set already contains the given string. + /// + /// + /// This method encapsulates over . Similar comparison and equality semantics apply. + /// + /// + /// The provided criterion string was null or empty. + /// + public bool AddCriterion(string criterion) + { + if (string.IsNullOrEmpty(criterion)) + { + throw new ArgumentException("Criteria cannot be null or empty.", nameof(criterion)); + } + else + { + return FilterCriteria.Add(criterion); + } + } + + /// + /// Remove a criterion from the instance's internal criteria set. + /// + /// + /// The criteria to remove. + /// + /// + /// true if the criterion was removed, false if it was not found. + /// + /// + /// This method encapsulates over . Similar comparison and equality semantics apply. + /// + public bool RemoveCriterion(string criterion) => FilterCriteria.Remove(criterion); + + public int CriteriaCount => FilterCriteria.Count; + + } +} \ No newline at end of file diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index 1e7943f47..e2f7b9440 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -16,6 +16,7 @@ using System.Management.Automation; using System.Reflection; using System.Data.Common; +using System.Linq; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets { @@ -338,17 +339,29 @@ public override FindResults FindName(string packageName, bool includePrerelease, { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindName()"); // Make sure to include quotations around the package name - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"; // This should return the latest stable version or the latest prerelease version (respectively) // https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet'&$filter=IsLatestVersion and substringof('PSModule', Tags) eq true // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // If it's a JFrog repository do not include the Id filter portion since JFrog uses 'Title' instead of 'Id', // however filtering on 'and Title eq '' returns "Response status code does not indicate success: 500". - string idFilterPart = _isJFrogRepo ? "": $" and Id eq '{packageName}'"; - string typeFilterPart = GetTypeFilterForRequest(type); - var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$inlinecount=allpages&$filter={prerelease}{idFilterPart}{typeFilterPart}"; + if (!_isJFrogRepo) { + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + } + + filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"); + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + + var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { @@ -384,23 +397,35 @@ public override FindResults FindNameWithTag(string packageName, string[] tags, b { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindNameWithTag()"); // Make sure to include quotations around the package name - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"; // This should return the latest stable version or the latest prerelease version (respectively) // https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet'&$filter=IsLatestVersion and substringof('PSModule', Tags) eq true // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // If it's a JFrog repository do not include the Id filter portion since JFrog uses 'Title' instead of 'Id', // however filtering on 'and Title eq '' returns "Response status code does not indicate success: 500". - string idFilterPart = _isJFrogRepo ? "" : $" and Id eq '{packageName}'"; - string typeFilterPart = GetTypeFilterForRequest(type); - string tagFilterPart = String.Empty; + if (!_isJFrogRepo) { + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + } + + filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"); + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$inlinecount=allpages&$filter={prerelease}{idFilterPart}{typeFilterPart}{tagFilterPart}"; + var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; + string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { @@ -603,11 +628,24 @@ public override FindResults FindVersion(string packageName, string version, Reso // Quotations around package name and version do not matter, same metadata gets returned. // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // If it's a JFrog repository do not include the Id filter portion since JFrog uses 'Title' instead of 'Id', // however filtering on 'and Title eq '' returns "Response status code does not indicate success: 500". - string idFilterPart = _isJFrogRepo ? "" : $" and Id eq '{packageName}'"; - string typeFilterPart = GetTypeFilterForRequest(type); - var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$inlinecount=allpages&$filter= NormalizedVersion eq '{version}'{idFilterPart}{typeFilterPart}"; + if (!_isJFrogRepo) { + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + } + + filterBuilder.AddCriterion($"NormalizedVersion eq '{version}'"); + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + + var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { @@ -644,19 +682,31 @@ public override FindResults FindVersion(string packageName, string version, Reso public override FindResults FindVersionWithTag(string packageName, string version, string[] tags, ResourceType type, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindVersionWithTag()"); + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. // If it's a JFrog repository do not include the Id filter portion since JFrog uses 'Title' instead of 'Id', // however filtering on 'and Title eq '' returns "Response status code does not indicate success: 500". - string idFilterPart = _isJFrogRepo ? "" : $" and Id eq '{packageName}'"; - string typeFilterPart = GetTypeFilterForRequest(type); - string tagFilterPart = String.Empty; + if (!_isJFrogRepo) { + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + } + + filterBuilder.AddCriterion($"NormalizedVersion eq '{version}'"); + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$inlinecount=allpages&$filter= NormalizedVersion eq '{version}'{idFilterPart}{typeFilterPart}{tagFilterPart}"; + var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { @@ -829,12 +879,30 @@ private string FindAllFromTypeEndPoint(bool includePrerelease, bool isSearchingM { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindAllFromTypeEndPoint()"); string typeEndpoint = _isPSGalleryRepo && !isSearchingModule ? "/items/psscript" : String.Empty; - string paginationParam = _isPSGalleryRepo ? $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000" : $"&$inlinecount=allpages&$skip={skip}&$top=6000"; + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString()}, + { "$top", "6000"} + }); + var filterBuilder = queryBuilder.FilterBuilder; + + if (_isPSGalleryRepo) { + queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; + } + // JFrog/Artifactory requires an empty search term to enumerate all packages in the feed - string searchTerm = _isJFrogRepo ? "&searchTerm=''" : ""; - var prereleaseFilter = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; + if (_isJFrogRepo) { + queryBuilder.SearchTerm = "''"; + } - var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?$filter={prereleaseFilter}{searchTerm}{paginationParam}"; + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?$filter={queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrlV2, out errRecord); } @@ -851,19 +919,38 @@ private string FindTagFromEndpoint(string[] tags, bool includePrerelease, bool i // type: DSCResource -> just search Modules // type: Command -> just search Modules string typeEndpoint = _isPSGalleryRepo && !isSearchingModule ? "/items/psscript" : String.Empty; - string paginationParam = _isPSGalleryRepo ? $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000" : $"&$inlinecount=allpages&$skip={skip}&$top=6000"; + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString()}, + { "$top", "6000"} + }); + var filterBuilder = queryBuilder.FilterBuilder; + + if (_isPSGalleryRepo) { + queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; + } + // JFrog/Artifactory requires an empty search term to enumerate all packages in the feed - string searchTerm = _isJFrogRepo ? "&searchTerm=''" : ""; - var prereleaseFilter = includePrerelease ? "includePrerelease=true&$filter=IsAbsoluteLatestVersion" : "$filter=IsLatestVersion"; - string typeFilterPart = isSearchingModule ? $" and substringof('PSModule', Tags) eq true" : $" and substringof('PSScript', Tags) eq true"; + if (_isJFrogRepo) { + queryBuilder.SearchTerm = "''"; + } - string tagFilterPart = String.Empty; + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + + filterBuilder.AddCriterion($"substringof('PS{(isSearchingModule ? "Module" : "Script")}', Tags) eq true"); + foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?{prereleaseFilter}{searchTerm}{typeFilterPart}{tagFilterPart}{paginationParam}"; + var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrlV2: requestUrlV2, out errRecord); } @@ -874,23 +961,36 @@ private string FindTagFromEndpoint(string[] tags, bool includePrerelease, bool i private string FindCommandOrDscResource(string[] tags, bool includePrerelease, bool isSearchingForCommands, int skip, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindCommandOrDscResource()"); - // can only find from Modules endpoint - string paginationParam = _isPSGalleryRepo ? $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000" : $"&$inlinecount=allpages&$skip={skip}&$top=6000"; - var prereleaseFilter = includePrerelease ? "$filter=IsAbsoluteLatestVersion&includePrerelease=true" : "$filter=IsLatestVersion"; - var tagPrefix = isSearchingForCommands ? "PSCommand_" : "PSDscResource_"; - string tagSearchTermPart = String.Empty; - foreach (string tag in tags) - { - if (!String.IsNullOrEmpty(tagSearchTermPart)) - { - tagSearchTermPart += " "; - } + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString()}, + { "$top", "6000"} + }); + var filterBuilder = queryBuilder.FilterBuilder; + + if (_isPSGalleryRepo) { + queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; + } - tagSearchTermPart += $"tag:{tagPrefix}{tag}"; + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); } - var requestUrlV2 = $"{Repository.Uri}/Search()?{prereleaseFilter}&searchTerm='{tagSearchTermPart}'{paginationParam}"; + + // can only find from Modules endpoint + var tagPrefix = isSearchingForCommands ? "PSCommand_" : "PSDscResource_"; + + queryBuilder.SearchTerm = "'" + string.Join( + " ", + tags.Select(tag => $"tag:{tagPrefix}{tag}") + ) + "'"; + + + var requestUrlV2 = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrlV2, out errRecord); } @@ -904,9 +1004,24 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and startswith(Id, 'PowerShell') and IsLatestVersion (stable) // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and IsAbsoluteLatestVersion&includePrerelease=true - string extraParam = _isPSGalleryRepo ? $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100" : $"&$inlinecount=allpages&$skip={skip}&$top=100"; - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; - string nameFilter; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString()}, + { "$top", "100"} + }); + var filterBuilder = queryBuilder.FilterBuilder; + + if (_isPSGalleryRepo) { + queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; + } + + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + var names = packageName.Split(new char[] {'*'}, StringSplitOptions.RemoveEmptyEntries); @@ -925,17 +1040,17 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl if (packageName.StartsWith("*") && packageName.EndsWith("*")) { // *get* - nameFilter = $"substringof('{names[0]}', Id)"; + filterBuilder.AddCriterion($"substringof('{names[0]}', Id)"); } else if (packageName.EndsWith("*")) { // PowerShell* - nameFilter = $"startswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}')"); } else { // *ShellGet - nameFilter = $"endswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"endswith(Id, '{names[0]}')"); } } else if (names.Length == 2 && !packageName.StartsWith("*") && !packageName.EndsWith("*")) @@ -944,7 +1059,7 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl // pow*get -> only support this // pow*get* // *pow*get - nameFilter = $"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"); } else { @@ -967,8 +1082,10 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl return string.Empty; } - string typeFilterPart = GetTypeFilterForRequest(type); - var requestUrlV2 = $"{Repository.Uri}/Search()?$filter={nameFilter}{typeFilterPart} and {prerelease}{extraParam}"; + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + var requestUrlV2 = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrlV2, out errRecord); } @@ -982,9 +1099,24 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and startswith(Id, 'PowerShell') and IsLatestVersion (stable) // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and IsAbsoluteLatestVersion&includePrerelease=true - string extraParam = _isPSGalleryRepo ? $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100" : $"&$inlinecount=allpages&$skip={skip}&$top=100"; - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; - string nameFilter; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString()}, + { "$top", "100"} + }); + var filterBuilder = queryBuilder.FilterBuilder; + + if (_isPSGalleryRepo) { + queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; + } + + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + var names = packageName.Split(new char[] {'*'}, StringSplitOptions.RemoveEmptyEntries); @@ -1012,18 +1144,17 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour { if (packageName.StartsWith("*") && packageName.EndsWith("*")) { - // *get* - nameFilter = $"substringof('{names[0]}', Id)"; + filterBuilder.AddCriterion($"substringof('{names[0]}', Id)"); } else if (packageName.EndsWith("*")) { // PowerShell* - nameFilter = $"startswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}')"); } else { // *ShellGet - nameFilter = $"endswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"endswith(Id, '{names[0]}')"); } } else if (names.Length == 2 && !packageName.StartsWith("*") && !packageName.EndsWith("*")) @@ -1032,7 +1163,7 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour // pow*get -> only support this // pow*get* // *pow*get - nameFilter = $"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"); } else { @@ -1048,11 +1179,13 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour string tagFilterPart = String.Empty; foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - string typeFilterPart = GetTypeFilterForRequest(type); - var requestUrlV2 = $"{Repository.Uri}/Search()?$filter={nameFilter}{tagFilterPart}{typeFilterPart} and {prerelease}{extraParam}"; + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + var requestUrlV2 = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrlV2, out errRecord); } @@ -1092,6 +1225,15 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange string minPart = String.Empty; string maxPart = String.Empty; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + {"$inlinecount", "allpages"}, + {"$skip", skip.ToString()}, + {"$orderby", "NormalizedVersion desc"}, + {"id", $"'{packageName}'"} + }); + + var filterBuilder = queryBuilder.FilterBuilder; + if (versionRange.MinVersion != null) { string operation = versionRange.IsMinInclusive ? "ge" : "gt"; @@ -1119,46 +1261,32 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange } string versionFilterParts = String.Empty; - if (!String.IsNullOrEmpty(minPart) && !String.IsNullOrEmpty(maxPart)) + if (!String.IsNullOrEmpty(minPart)) { - versionFilterParts += minPart + " and " + maxPart; + filterBuilder.AddCriterion(minPart); } - else if (!String.IsNullOrEmpty(minPart)) + if (!String.IsNullOrEmpty(maxPart)) { - versionFilterParts += minPart; + filterBuilder.AddCriterion(maxPart); } - else if (!String.IsNullOrEmpty(maxPart)) - { - versionFilterParts += maxPart; + if (!includePrerelease) { + filterBuilder.AddCriterion("IsPrerelease eq false"); } - - string filterQuery = "&$filter="; - filterQuery += includePrerelease ? string.Empty : "IsPrerelease eq false"; - - string andOperator = " and "; - string joiningOperator = filterQuery.EndsWith("=") ? String.Empty : andOperator; + // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. // If it's a JFrog repository do not include the Id filter portion since JFrog uses 'Title' instead of 'Id', // however filtering on 'and Title eq '' returns "Response status code does not indicate success: 500". - string idFilterPart = $"{joiningOperator}"; - idFilterPart += _isJFrogRepo ? "" : $"Id eq '{packageName}'"; - filterQuery += idFilterPart; - filterQuery += type == ResourceType.Script ? $"{andOperator}substringof('PS{type.ToString()}', Tags) eq true" : String.Empty; - - if (!String.IsNullOrEmpty(versionFilterParts)) - { - // Check if includePrerelease is true, if it is we want to add "$filter" - // Single case where version is "*" (or "[,]") and includePrerelease is true, then we do not want to add "$filter" to the requestUrl. - - // Note: could be null/empty if Version was "*" -> [,] - filterQuery += $"{(filterQuery.EndsWith("=") ? String.Empty : andOperator)}{versionFilterParts}"; + if (!_isJFrogRepo) { + filterBuilder.AddCriterion($"Id eq '{packageName}'"); } - string paginationParam = $"$inlinecount=allpages&$skip={skip}"; + if (type == ResourceType.Script) { + filterBuilder.AddCriterion($"substringof('PS{type.ToString()}', Tags) eq true"); + } + - filterQuery = filterQuery.EndsWith("=") ? string.Empty : filterQuery; - var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$orderby=NormalizedVersion desc&{paginationParam}{filterQuery}"; + var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrlV2, out errRecord); } @@ -1216,11 +1344,11 @@ private string GetTypeFilterForRequest(ResourceType type) { string typeFilterPart = string.Empty; if (type == ResourceType.Script) { - typeFilterPart += $" and substringof('PS{type.ToString()}', Tags) eq true "; + typeFilterPart += $"substringof('PS{type.ToString()}', Tags) eq true"; } else if (type == ResourceType.Module) { - typeFilterPart += $" and substringof('PS{ResourceType.Script.ToString()}', Tags) eq false "; + typeFilterPart += $"substringof('PS{ResourceType.Script.ToString()}', Tags) eq false"; } return typeFilterPart; From 3ec7f9872d49543903da24a3aa9f612c99dc3c5f Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Wed, 15 May 2024 08:37:26 -0700 Subject: [PATCH 081/160] Update changelog (#1655) --- CHANGELOG/1.0.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG/1.0.md b/CHANGELOG/1.0.md index 63909e508..7719f3586 100644 --- a/CHANGELOG/1.0.md +++ b/CHANGELOG/1.0.md @@ -1,8 +1,34 @@ # 1.0 Changelog +## [1.0.5](https://github.com/PowerShell/PSResourceGet/compare/v1.0.4.1...v1.0.5) - 2024-05-13 + +### Bug Fixes +- Update `nuget.config` to use PowerShell packages feed (#1649) +- Refactor V2ServerAPICalls and NuGetServerAPICalls to use object-oriented query/filter builder (#1645 Thanks @sean-r-williams!) +- Fix unnecessary `and` for version globbing in V2ServerAPICalls (#1644 Thanks again @sean-r-williams!) +- Fix requiring `tags` in server response (#1627 Thanks @evelyn-bi!) +- Add 10 minute timeout to HTTPClient (#1626) +- Fix save script without `-IncludeXml` (#1609, #1614 Thanks @o-l-a-v!) +- PAT token fix to translate into HttpClient 'Basic Authorization'(#1599 Thanks @gerryleys!) +- Fix incorrect request url when installing from ADO (#1597 Thanks @antonyoni!) +- Improved exception handling (#1569) +- Ensure that .NET methods are not called in order to enable use in Constrained Language Mode (#1564) +- PSResourceGet packaging update + +## [1.0.4.1](https://github.com/PowerShell/PSResourceGet/compare/v1.0.4...v1.0.4.1) - 2024-04-05 + +- PSResourceGet packaging update + +## [1.0.4](https://github.com/PowerShell/PSResourceGet/compare/v1.0.3...v1.0.4) - 2024-04-05 + +### Patch + +- Dependency package updates + ## [1.0.3](https://github.com/PowerShell/PSResourceGet/compare/v1.0.2...v1.0.3) - 2024-03-13 ### Bug Fixes + - Bug fix for null package version in `Install-PSResource` ## [1.0.2](https://github.com/PowerShell/PSResourceGet/compare/v1.0.1...v1.0.2) - 2024-02-06 From 48d16c0fac61f29a15e2327f462e2eec315822d7 Mon Sep 17 00:00:00 2001 From: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> Date: Wed, 12 Jun 2024 17:39:00 -0700 Subject: [PATCH 082/160] Update Code of Conduct and Security Policy (#1664) --- CODE_OF_CONDUCT.md | 14 ++++++++------ README.md | 10 ++++++++++ SECURITY.md | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 SECURITY.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 90768d129..686e5e7a0 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,8 +1,10 @@ -# Code of Conduct +# Microsoft Open Source Code of Conduct -This project has adopted the [Microsoft Open Source Code of Conduct][conduct-code]. -For more information see the [Code of Conduct FAQ][conduct-FAQ] or contact [opencode@microsoft.com][conduct-email] with any additional questions or comments. +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). -[conduct-code]: https://opensource.microsoft.com/codeofconduct/ -[conduct-FAQ]: https://opensource.microsoft.com/codeofconduct/faq/ -[conduct-email]: mailto:opencode@microsoft.com +Resources: + +- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) +- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns +- Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) diff --git a/README.md b/README.md index a2ebe4593..0afeee92a 100644 --- a/README.md +++ b/README.md @@ -86,3 +86,13 @@ C:\> Import-Module C:\Repos\PSResourceGet\out\PSResourceGet # If running Windows PowerShell C:\> Import-Module C:\Repos\PSResourceGet\out\PSResourceGet\PSResourceGet.psd1 ``` + +Code of Conduct +=============== + +Please see our [Code of Conduct](CODE_OF_CONDUCT.md) before participating in this project. + +Security Policy +=============== + +For any security issues, please see our [Security Policy](SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..f941d308b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ + + +## Security + +Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin) and [PowerShell](https://github.com/PowerShell). + +If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). + +If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). + +You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). + +Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: + + * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) + * Full paths of source file(s) related to the manifestation of the issue + * The location of the affected source code (tag/branch/commit or direct URL) + * Any special configuration required to reproduce the issue + * Step-by-step instructions to reproduce the issue + * Proof-of-concept or exploit code (if possible) + * Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. + +## Preferred Languages + +We prefer all communications to be in English. + +## Policy + +Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). + + From 3db7b650ed3b58ce664022192fa6f94bcf395e82 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Mon, 24 Jun 2024 14:09:50 -0400 Subject: [PATCH 083/160] update .NET sdk version to latest 8.0 stable in global.json (#1665) --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index 65405ec9c..d68ee8382 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.202" + "version": "8.0.300" } } From 8816a79fb90fde364c348ede94518cdb1588dcd9 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Wed, 10 Jul 2024 15:37:27 -0700 Subject: [PATCH 084/160] Remove extra initialization of _sessionClient (#1672) --- src/code/V2ServerAPICalls.cs | 1 - src/code/V3ServerAPICalls.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index e2f7b9440..a7664560b 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -76,7 +76,6 @@ public V2ServerAPICalls (PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, N _sessionClient = new HttpClient(handler); }; - _sessionClient = new HttpClient(handler); _sessionClient.Timeout = TimeSpan.FromMinutes(10); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); var repoURL = repository.Uri.ToString().ToLower(); diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index a6b8d3620..8f5297ecc 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -75,7 +75,6 @@ public V3ServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, Ne _sessionClient = new HttpClient(handler); }; - _sessionClient = new HttpClient(handler); _sessionClient.Timeout = TimeSpan.FromMinutes(10); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); From c4097b0196996f2538122bcb2401aaf567b075e5 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Thu, 25 Jul 2024 11:52:03 -0700 Subject: [PATCH 085/160] Update README.md (#1679) --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0afeee92a..62f94fcea 100644 --- a/README.md +++ b/README.md @@ -65,25 +65,25 @@ PS C:\Repos\PSResourceGet> .\build.ps1 -Clean -Build -BuildConfiguration Debug - PS C:\Repos\PSResourceGet> .\build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework netstandard2.0 ``` -* Publish the module to a local repository +* Run functional tests ```powershell -PS C:\Repos\PSResourceGet> .\build.ps1 -Publish +PS C:\Repos\PSResourceGet> Invoke-Pester ``` -* Run functional tests - ```powershell -PS C:\Repos\PSResourceGet> Invoke-PSPackageProjectTest -Type Functional +PS C:\Repos\PSResourceGet> Invoke-Pester ``` * Import the module into a new PowerShell session ```powershell # If running PowerShell 6+ +C:\> pwsh C:\> Import-Module C:\Repos\PSResourceGet\out\PSResourceGet # If running Windows PowerShell +c:\> PowerShell C:\> Import-Module C:\Repos\PSResourceGet\out\PSResourceGet\PSResourceGet.psd1 ``` From d0e71b2cf558822c040143f76803d6c5efb65584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20R=C3=B8nnestad=20Birkeland?= <6450056+o-l-a-v@users.noreply.github.com> Date: Thu, 1 Aug 2024 17:21:48 +0200 Subject: [PATCH 086/160] Update readme with more info and consistent markdown syntax for headings and lists (#1675) --- README.md | 77 ++++++++++++++++++++++++++----------------------------- 1 file changed, 36 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 62f94fcea..5222ac17a 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,56 @@ +# PSResourceGet [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/PowerShell/PSResourceGet/blob/master/LICENSE) -[![Documentation - PSResourceGet](https://img.shields.io/badge/Documentation-PowerShellGet-blue.svg)](https://docs.microsoft.com/en-us/powershell/module/powershellget/?view=powershell-7.1) +[![Documentation - PSResourceGet](https://img.shields.io/badge/Documentation-PowerShellGet-blue.svg)](https://learn.microsoft.com/powershell/module/microsoft.powershell.psresourceget) [![PowerShell Gallery - PSResourceGet](https://img.shields.io/badge/PowerShell%20Gallery-PSResourceGet-blue.svg)](https://www.powershellgallery.com/packages/Microsoft.PowerShell.PSResourceGet) [![Minimum Supported PowerShell Version](https://img.shields.io/badge/PowerShell-5.0-blue.svg)](https://github.com/PowerShell/PSResourceGet) -Important Note -============== +## Important Notes -If you were familiar with the PowerShellGet 3.0 project, we renamed the module to be PSResourceGet, for more information please read [this blog](https://devblogs.microsoft.com/powershell/powershellget-in-powershell-7-4-updates/). +> [!NOTE] +> `PSResourceGet` is short for the full name of the module, `Microsoft.PowerShell.PSResourceGet`. The full name is what is used in PowerShell and when published to the [PowerShell Gallery](https://www.powershellgallery.com/packages/Microsoft.PowerShell.PSResourceGet). -If you would like to open a PR please open an issue first so that necessary discussion can take place. -Please open an issue for any feature requests, bug reports, or questions for PSResourceGet. -Please note, the repository for PowerShellGet v2 is available at [PowerShell/PowerShellGetv2](https://github.com/PowerShell/PowerShellGetv2). -The repository for the PowerShellGet v3, the compatibility layer between PowerShellGet v2 and PSResourceGet, is available at [PowerShell/PowerShellGet](https://github.com/PowerShell/PowerShellGet). +* If you were familiar with the PowerShellGet 3.0 project, we renamed the module to be PSResourceGet, for more information please read [this blog](https://devblogs.microsoft.com/powershell/powershellget-in-powershell-7-4-updates/). +* If you would like to open a PR please open an issue first so that necessary discussion can take place. + * Please open an issue for any feature requests, bug reports, or questions for PSResourceGet. + * See the [Contributing Quickstart Guide](#contributing-quickstart-guide) section. +* Please note, the repository for PowerShellGet v2 is available at [PowerShell/PowerShellGetv2](https://github.com/PowerShell/PowerShellGetv2). +* The repository for the PowerShellGet v3, the compatibility layer between PowerShellGet v2 and PSResourceGet, is available at [PowerShell/PowerShellGet](https://github.com/PowerShell/PowerShellGet). -Introduction -============ +## Introduction PSResourceGet is a PowerShell module with commands for discovering, installing, updating and publishing the PowerShell resources like Modules, Scripts, and DSC Resources. -Documentation -============= +## Documentation -Documentation for PSResourceGet is currently under its old name PowerShellGet v3, please -[Click here](https://learn.microsoft.com/powershell/module/powershellget/?view=powershellget-3.x) -to reference the documentation. +[Click here](https://learn.microsoft.com/powershell/module/microsoft.powershell.psresourceget) to reference the documentation. -Requirements -============ +## Requirements -- PowerShell 5.0 or higher. +* PowerShell 5.0 or higher. -Get PSResourceGet Module -======================== +## Install the PSResourceGet module +* `PSResourceGet` is short for the full name `Microsoft.PowerShell.PSResourceGet`. +* It's included in PowerShell since v7.4. Please use the [PowerShell Gallery](https://www.powershellgallery.com) to get the latest version of the module. -Get PowerShellGet Source -======================== +## Contributing Quickstart Guide -#### Steps - -* Obtain the source - - Download the latest source code from the release page (https://github.com/PowerShell/PSResourceGet/releases) OR - - Clone the repository (needs git) - ```powershell - git clone https://github.com/PowerShell/PSResourceGet - ``` +### Get the source code +* Download the latest source code from the release page () OR clone the repository using git. + ```powershell + PS > cd 'C:\Repos' + PS C:\Repos> git clone https://github.com/PowerShell/PSResourceGet + ``` * Navigate to the local repository directory + ```powershell + PS C:\> cd c:\Repos\PSResourceGet + PS C:\Repos\PSResourceGet> + ``` -```powershell -PS C:\> cd c:\Repos\PSResourceGet -PS C:\Repos\PSResourceGet> -``` - -* Build the project +### Build the project ```powershell # Build for the net472 framework @@ -65,6 +60,8 @@ PS C:\Repos\PSResourceGet> .\build.ps1 -Clean -Build -BuildConfiguration Debug - PS C:\Repos\PSResourceGet> .\build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework netstandard2.0 ``` +### Publish the module to a local repository +======= * Run functional tests ```powershell @@ -75,7 +72,7 @@ PS C:\Repos\PSResourceGet> Invoke-Pester PS C:\Repos\PSResourceGet> Invoke-Pester ``` -* Import the module into a new PowerShell session +### Import the built module into a new PowerShell session ```powershell # If running PowerShell 6+ @@ -87,12 +84,10 @@ c:\> PowerShell C:\> Import-Module C:\Repos\PSResourceGet\out\PSResourceGet\PSResourceGet.psd1 ``` -Code of Conduct -=============== +## Code of Conduct Please see our [Code of Conduct](CODE_OF_CONDUCT.md) before participating in this project. -Security Policy -=============== +## Security Policy For any security issues, please see our [Security Policy](SECURITY.md). From 8389b046585d74f9ebba797be876a23c7b41176f Mon Sep 17 00:00:00 2001 From: Sean Williams <72675818+sean-r-williams@users.noreply.github.com> Date: Tue, 6 Aug 2024 16:26:35 -0700 Subject: [PATCH 087/160] Add prerelease string when NormalizedVersion doesn't exist (but prelease string does) (#1681) --- src/code/InstallHelper.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index cd86fe180..9057eda10 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -803,7 +803,12 @@ private Hashtable BeginPackageInstall( pkgToInstall.RepositorySourceLocation = repository.Uri.ToString(); pkgToInstall.AdditionalMetadata.TryGetValue("NormalizedVersion", out string pkgVersion); if (pkgVersion == null) { + // Not all NuGet providers (e.g. Artifactory, possibly others) send NormalizedVersion in NuGet package responses. + // If they don't, we need to manually construct the combined version+prerelease from pkgToInstall.Version and the prerelease string. pkgVersion = pkgToInstall.Version.ToString(); + if (!String.IsNullOrEmpty(pkgToInstall.Prerelease)) { + pkgVersion += $"-{pkgToInstall.Prerelease}"; + } } // Check to see if the pkg is already installed (ie the pkg is installed and the version satisfies the version range provided via param) if (!_reinstall) From 41dabab89e28b5f2b70a2ef9aabedefa13d2e787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20R=C3=B8nnestad=20Birkeland?= <6450056+o-l-a-v@users.noreply.github.com> Date: Wed, 7 Aug 2024 05:20:42 +0200 Subject: [PATCH 088/160] VSCode update editor config (#1668) --- .editorconfig | 199 ++++++++++++++++++++++++++++++++++++++++ .vscode/extensions.json | 13 ++- .vscode/launch.json | 4 +- .vscode/settings.json | 3 +- 4 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..686da5c9f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,199 @@ +# EditorConfig is awesome: https://EditorConfig.org +# .NET coding convention settings for EditorConfig +# https://learn.microsoft.com/visualstudio/ide/editorconfig-code-style-settings-reference +# +# This file was taken from PowerShell/PowerShell 2024-07-13 +# https://github.com/PowerShell/PowerShell/blob/master/.editorconfig + +# Top-most EditorConfig file +root = true + +[*] +charset = utf-8 +# indent_size intentionally not specified in this section +indent_style = space +insert_final_newline = true + +# Source code +[*.{cs,ps1,psd1,psm1}] +indent_size = 4 + +# Shell scripts +[*.sh] +end_of_line = lf +indent_size = 4 + +# Xml project files +[*.{csproj,resx,ps1xml}] +indent_size = 2 + +# Data serialization +[*.{json,yaml,yml}] +indent_size = 2 + +# Markdown +[*.md] +indent_size = 2 + +# Xml files +[*.{resx,ruleset,stylecop,xml,xsd,xsl}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +[*.tsv] +indent_style = tab + +# Dotnet code style settings: +[*.cs] +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true + +file_header_template = Copyright (c) Microsoft Corporation.\nLicensed under the MIT License. + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion + +# Avoid "this." and "Me." if not necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# Use language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# Static fields should have s_ prefix +dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style + +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected + +dotnet_naming_style.static_prefix_style.required_prefix = s_ +dotnet_naming_style.static_prefix_style.capitalization = camel_case + +# Internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style + +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal + +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Suggest more modern language features when available +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_readonly_field = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_auto_properties = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion + +dotnet_code_quality_unused_parameters = non_public:suggestion + +# CSharp code style settings: +[*.cs] + +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false +csharp_prefer_braces = true:silent + +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_simple_using_statement = false:none +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_range_operator = false:none +csharp_style_prefer_index_operator = false:none +csharp_style_pattern_local_over_anonymous_function = false:none + +csharp_using_directive_placement = outside_namespace:suggestion + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# Only use var when it's obvious what the variable type is +csharp_style_var_for_built_in_types = false:none +csharp_style_var_when_type_is_apparent = false:none +csharp_style_var_elsewhere = false:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_local_functions = true:silent + +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none + +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Suggest more modern language features when available +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 152354da2..d2f30467c 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,13 +3,12 @@ // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp // List of extensions which should be recommended for users of this workspace. "recommendations": [ + "EditorConfig.EditorConfig", + "ms-dotnettools.csdevkit", "ms-dotnettools.csharp", - "ms-vscode.powershell-preview", - "patcx.vscode-nuget-gallery", - "fudge.auto-using" + "ms-vscode.powershell", + "patcx.vscode-nuget-gallery" ], // List of extensions recommended by VS Code that should not be recommended for users of this workspace. - "unwantedRecommendations": [ - "ms-vscode.powershell" - ] -} \ No newline at end of file + "unwantedRecommendations": [] +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 036014deb..82c604272 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,7 +17,7 @@ "console": "externalTerminal", "stopAtEntry": false, "logging": { - "engineLogging": false, + "logging.diagnosticsLog.protocolMessages": false, "moduleLoad": false, "exceptions": false, "browserStdOut": false @@ -30,4 +30,4 @@ "processId": "${command:pickProcess}" } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 5c9c11754..1dea6f957 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "csharp.semanticHighlighting.enabled": true, + "dotnet.automaticallyCreateSolutionInWorkspace": false, "omnisharp.enableEditorConfigSupport": true, "omnisharp.enableRoslynAnalyzers": true -} \ No newline at end of file +} From 53af2d9e1991a4f60bb5950c69996575fd0f4491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20R=C3=B8nnestad=20Birkeland?= <6450056+o-l-a-v@users.noreply.github.com> Date: Wed, 7 Aug 2024 20:18:49 +0200 Subject: [PATCH 089/160] Make delete more robust (fix #1662) (#1667) --- .gitignore | 2 +- src/code/InstallHelper.cs | 2 +- src/code/Utils.cs | 78 ++++++++++++++++++++++++--------------- 3 files changed, 50 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index ab0db5255..df39f04f3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ srcOld/code/bin srcOld/code/obj out test/**/obj -test/**/bin \ No newline at end of file +test/**/bin diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 9057eda10..ba7fc3d88 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -467,7 +467,7 @@ private void MoveFilesIntoInstallPath( // Delete the directory path before replacing it with the new module. // If deletion fails (usually due to binary file in use), then attempt restore so that the currently // installed module is not corrupted. - _cmdletPassedIn.WriteVerbose($"Attempting to delete with restore on failure.'{finalModuleVersionDir}'"); + _cmdletPassedIn.WriteVerbose($"Attempting to delete with restore on failure. '{finalModuleVersionDir}'"); Utils.DeleteDirectoryWithRestore(finalModuleVersionDir); } diff --git a/src/code/Utils.cs b/src/code/Utils.cs index e2dc8406c..9b3ae51c1 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -20,6 +20,8 @@ using System.Security; using Azure.Core; using Azure.Identity; +using System.Threading.Tasks; +using System.Threading; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses { @@ -42,7 +44,7 @@ public enum MetadataFileType #region String fields public static readonly string[] EmptyStrArray = Array.Empty(); - public static readonly char[] WhitespaceSeparator = new char[]{' '}; + public static readonly char[] WhitespaceSeparator = new char[] { ' ' }; public const string PSDataFileExt = ".psd1"; public const string PSScriptFileExt = ".ps1"; private const string ConvertJsonToHashtableScript = @" @@ -140,7 +142,7 @@ public static string[] GetStringArray(ArrayList list) if (list == null) { return null; } var strArray = new string[list.Count]; - for (int i=0; i < list.Count; i++) + for (int i = 0; i < list.Count; i++) { strArray[i] = list[i] as string; } @@ -161,7 +163,7 @@ public static string[] ProcessNameWildcards( { isContainWildcard = true; errorMsgs = errorMsgsList.ToArray(); - return new string[] {"*"}; + return new string[] { "*" }; } isContainWildcard = false; @@ -179,8 +181,8 @@ public static string[] ProcessNameWildcards( if (String.Equals(name, "*", StringComparison.InvariantCultureIgnoreCase)) { isContainWildcard = true; - errorMsgs = new string[] {}; - return new string[] {"*"}; + errorMsgs = new string[] { }; + return new string[] { "*" }; } if (name.Contains("?") || name.Contains("[")) @@ -275,7 +277,8 @@ public static bool TryGetVersionType( // eg: 2.8.8.* should translate to the version range "[2.1.3.0,2.1.3.99999]" modifiedVersion = $"[{versionSplit[0]}.{versionSplit[1]}.{versionSplit[2]}.0,{versionSplit[0]}.{versionSplit[1]}.{versionSplit[2]}.999999]"; } - else { + else + { error = "Argument for -Version parameter is not in the proper format"; return false; } @@ -469,15 +472,15 @@ public static bool TryCreateValidPSCredentialInfo( try { - if (!string.IsNullOrEmpty((string) credentialInfoCandidate.Properties[PSCredentialInfo.VaultNameAttribute]?.Value) - && !string.IsNullOrEmpty((string) credentialInfoCandidate.Properties[PSCredentialInfo.SecretNameAttribute]?.Value)) + if (!string.IsNullOrEmpty((string)credentialInfoCandidate.Properties[PSCredentialInfo.VaultNameAttribute]?.Value) + && !string.IsNullOrEmpty((string)credentialInfoCandidate.Properties[PSCredentialInfo.SecretNameAttribute]?.Value)) { PSCredential credential = null; if (credentialInfoCandidate.Properties[PSCredentialInfo.CredentialAttribute] != null) { try { - credential = (PSCredential) credentialInfoCandidate.Properties[PSCredentialInfo.CredentialAttribute].Value; + credential = (PSCredential)credentialInfoCandidate.Properties[PSCredentialInfo.CredentialAttribute].Value; } catch (Exception e) { @@ -492,8 +495,8 @@ public static bool TryCreateValidPSCredentialInfo( } repoCredentialInfo = new PSCredentialInfo( - (string) credentialInfoCandidate.Properties[PSCredentialInfo.VaultNameAttribute].Value, - (string) credentialInfoCandidate.Properties[PSCredentialInfo.SecretNameAttribute].Value, + (string)credentialInfoCandidate.Properties[PSCredentialInfo.VaultNameAttribute].Value, + (string)credentialInfoCandidate.Properties[PSCredentialInfo.SecretNameAttribute].Value, credential ); @@ -711,7 +714,7 @@ public static string GetContainerRegistryAccessTokenFromSecretManagement( string password = new NetworkCredential(string.Empty, secretSecureString).Password; return password; } - else if(secretValue is PSCredential psCredSecret) + else if (secretValue is PSCredential psCredSecret) { string password = new NetworkCredential(string.Empty, psCredSecret.Password).Password; return password; @@ -1120,7 +1123,8 @@ private static void GetStandardPlatformPaths( // paths are the same for both Linux and macOS localUserDir = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share", "powershell"); // Create the default data directory if it doesn't exist. - if (!Directory.Exists(localUserDir)) { + if (!Directory.Exists(localUserDir)) + { Directory.CreateDirectory(localUserDir); } @@ -1247,7 +1251,7 @@ private static bool TryReadPSDataFile( result = psObject.BaseObject; } - dataFileInfo = (Hashtable) result; + dataFileInfo = (Hashtable)result; error = null; return true; } @@ -1369,10 +1373,10 @@ public static bool TryCreateModuleSpecification( validatedModuleSpecs = Array.Empty(); List moduleSpecsList = new List(); - foreach(Hashtable moduleSpec in moduleSpecHashtables) + foreach (Hashtable moduleSpec in moduleSpecHashtables) { // ModuleSpecification(string) constructor for creating a ModuleSpecification when only ModuleName is provided. - if (!moduleSpec.ContainsKey("ModuleName") || String.IsNullOrEmpty((string) moduleSpec["ModuleName"])) + if (!moduleSpec.ContainsKey("ModuleName") || String.IsNullOrEmpty((string)moduleSpec["ModuleName"])) { errorList.Add(new ErrorRecord( new ArgumentException($"RequiredModules Hashtable entry {moduleSpec.ToString()} is missing a key 'ModuleName' and associated value, which is required for each module specification entry"), @@ -1384,7 +1388,7 @@ public static bool TryCreateModuleSpecification( } // At this point it must contain ModuleName key. - string moduleSpecName = (string) moduleSpec["ModuleName"]; + string moduleSpecName = (string)moduleSpec["ModuleName"]; ModuleSpecification currentModuleSpec = null; if (!moduleSpec.ContainsKey("MaximumVersion") && !moduleSpec.ContainsKey("ModuleVersion") && !moduleSpec.ContainsKey("RequiredVersion")) { @@ -1410,10 +1414,10 @@ public static bool TryCreateModuleSpecification( else { // ModuleSpecification(Hashtable) constructor for when ModuleName + {Required,Maximum,Module}Version value is also provided. - string moduleSpecMaxVersion = moduleSpec.ContainsKey("MaximumVersion") ? (string) moduleSpec["MaximumVersion"] : String.Empty; - string moduleSpecModuleVersion = moduleSpec.ContainsKey("ModuleVersion") ? (string) moduleSpec["ModuleVersion"] : String.Empty; - string moduleSpecRequiredVersion = moduleSpec.ContainsKey("RequiredVersion") ? (string) moduleSpec["RequiredVersion"] : String.Empty; - Guid moduleSpecGuid = moduleSpec.ContainsKey("Guid") ? (Guid) moduleSpec["Guid"] : Guid.Empty; + string moduleSpecMaxVersion = moduleSpec.ContainsKey("MaximumVersion") ? (string)moduleSpec["MaximumVersion"] : String.Empty; + string moduleSpecModuleVersion = moduleSpec.ContainsKey("ModuleVersion") ? (string)moduleSpec["ModuleVersion"] : String.Empty; + string moduleSpecRequiredVersion = moduleSpec.ContainsKey("RequiredVersion") ? (string)moduleSpec["RequiredVersion"] : String.Empty; + Guid moduleSpecGuid = moduleSpec.ContainsKey("Guid") ? (Guid)moduleSpec["Guid"] : Guid.Empty; if (String.IsNullOrEmpty(moduleSpecMaxVersion) && String.IsNullOrEmpty(moduleSpecModuleVersion) && String.IsNullOrEmpty(moduleSpecRequiredVersion)) { @@ -1536,22 +1540,36 @@ public static void DeleteDirectoryWithRestore(string dirPath) /// public static void DeleteDirectory(string dirPath) { - foreach (var dirFilePath in Directory.GetFiles(dirPath)) + // Remove read only file attributes first + foreach (var dirFilePath in Directory.GetFiles(dirPath,"*",SearchOption.AllDirectories)) { if (File.GetAttributes(dirFilePath).HasFlag(FileAttributes.ReadOnly)) { - File.SetAttributes(dirFilePath, (File.GetAttributes(dirFilePath) & ~FileAttributes.ReadOnly)); + File.SetAttributes(dirFilePath, File.GetAttributes(dirFilePath) & ~FileAttributes.ReadOnly); } - - File.Delete(dirFilePath); } - - foreach (var dirSubPath in Directory.GetDirectories(dirPath)) + // Delete directory recursive, try multiple times before throwing ( #1662 ) + int maxAttempts = 5; + int msDelay = 5; + for (int attempt = 1; attempt <= maxAttempts; ++attempt) { - DeleteDirectory(dirSubPath); + try + { + Directory.Delete(dirPath,true); + return; + } + catch (Exception ex) + { + if (attempt < maxAttempts && (ex is IOException || ex is UnauthorizedAccessException)) + { + Thread.Sleep(msDelay); + } + else + { + throw; + } + } } - - Directory.Delete(dirPath); } /// From e79f6a242f2e90545457b04336b937a47cfcce42 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Mon, 12 Aug 2024 17:23:26 -0700 Subject: [PATCH 090/160] Update buildtools reference to container registry tests (#1683) --- buildtools.psm1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/buildtools.psm1 b/buildtools.psm1 index 32cceec4d..1df564eb1 100644 --- a/buildtools.psm1 +++ b/buildtools.psm1 @@ -134,9 +134,9 @@ function Invoke-ModuleTestsACR { ) $acrTestFiles = @( - "FindPSResourceTests/FindPSResourceACRServer.Tests.ps1", - "InstallPSResourceTests/InstallPSResourceACRServer.Tests.ps1", - "PublishPSResourceTests/PublishPSResourceACRServer.Tests.ps1" + "FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1", + "InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1", + "PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1" ) Invoke-ModuleTests -Type $Type -TestFilePath $acrTestFiles From 1e855841a54b0e0ab4d4922300644b93d9fc5779 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 21 Aug 2024 12:31:02 -0700 Subject: [PATCH 091/160] Update .NET to 8.0.304 and add UseDotnet task to CI (#1691) --- .ci/ci.yml | 4 ++++ .github/workflows/codeql-analysis.yml | 14 +++++++++----- global.json | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.ci/ci.yml b/.ci/ci.yml index 031b42ab3..7add12474 100644 --- a/.ci/ci.yml +++ b/.ci/ci.yml @@ -31,6 +31,10 @@ stages: steps: + - task: UseDotNet@2 + inputs: + useGlobalJson: true + - pwsh: | Get-ChildItem -Path env: displayName: Capture environment for build diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e92f0d635..ba7f2ffb2 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -24,7 +24,7 @@ jobs: security-events: write # for github/codeql-action/analyze to upload SARIF results name: Analyze runs-on: ubuntu-latest - + strategy: fail-fast: false matrix: @@ -33,11 +33,15 @@ jobs: build-mode: manual # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages - + steps: - name: Checkout repository uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 @@ -45,15 +49,15 @@ jobs: languages: ${{ matrix.language }} # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - + - run: | Get-ChildItem . name: Capture env - + - run: | .\build.ps1 -Clean -Build name: Build - + - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 with: diff --git a/global.json b/global.json index d68ee8382..87b60ce1a 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.300" + "version": "8.0.304" } } From 3989cbe3bf32cbaef94a02f39a1fead7265b9679 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 21 Aug 2024 17:17:25 -0700 Subject: [PATCH 092/160] Update the feed name used for testing (#1692) --- .../FindPSResourceADOServer.Tests.ps1 | 2 +- .../FindPSResourceADOV2Server.Tests.ps1 | 2 +- .../InstallPSResourceADOServer.Tests.ps1 | 24 +++++++++---------- .../PublishPSResourceADOServer.Tests.ps1 | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 index 17a8dff13..df2632489 100644 --- a/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 @@ -9,7 +9,7 @@ Describe 'Test HTTP Find-PSResource for ADO Server Protocol' -tags 'CI' { BeforeAll{ $testModuleName = "test_local_mod" $ADORepoName = "PSGetTestingPublicFeed" - $ADORepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/powershell-public-test/nuget/v3/index.json" + $ADORepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/psresourceget-public-test-ci/nuget/v3/index.json" Get-NewPSResourceRepositoryFile Register-PSResourceRepository -Name $ADORepoName -Uri $ADORepoUri } diff --git a/test/FindPSResourceTests/FindPSResourceADOV2Server.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceADOV2Server.Tests.ps1 index b2bb18234..9d4e3f8db 100644 --- a/test/FindPSResourceTests/FindPSResourceADOV2Server.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceADOV2Server.Tests.ps1 @@ -12,7 +12,7 @@ Describe 'Test HTTP Find-PSResource for ADO V2 Server Protocol' -tags 'CI' { BeforeAll{ $testModuleName = "test_local_mod" $ADOV2RepoName = "PSGetTestingPublicFeed" - $ADOV2RepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/powershell-public-test/nuget/v2" + $ADOV2RepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/psresourceget-public-test-ci/nuget/v2" Get-NewPSResourceRepositoryFile Register-PSResourceRepository -Name $ADOV2RepoName -Uri $ADOV2RepoUri } diff --git a/test/InstallPSResourceTests/InstallPSResourceADOServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceADOServer.Tests.ps1 index 8cf65b81f..fbf32c59e 100644 --- a/test/InstallPSResourceTests/InstallPSResourceADOServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceADOServer.Tests.ps1 @@ -12,7 +12,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { $testModuleName2 = "test_local_mod2" $testScriptName = "test_ado_script" $ADORepoName = "PSGetTestingPublicFeed" - $ADORepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/powershell-public-test/nuget/v3/index.json" + $ADORepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/psresourceget-public-test-ci/nuget/v3/index.json" Get-NewPSResourceRepositoryFile Register-PSResourceRepository -Name $ADORepoName -Uri $ADORepoUri } @@ -54,7 +54,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { It "Install multiple resources by name" { $pkgNames = @($testModuleName, $testModuleName2) - Install-PSResource -Name $pkgNames -Repository $ADORepoName -TrustRepository + Install-PSResource -Name $pkgNames -Repository $ADORepoName -TrustRepository $pkg = Get-InstalledPSResource $pkgNames $pkg.Name | Should -Be $pkgNames } @@ -64,7 +64,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { $pkg = Get-InstalledPSResource "NonExistantModule" $pkg | Should -BeNullOrEmpty $err.Count | Should -BeGreaterThan 0 - $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" } # Do some version testing, but Find-PSResource should be doing thorough testing @@ -76,21 +76,21 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } It "Should install resource given name and exact version with bracket syntax" { - Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $ADORepoName -TrustRepository + Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $ADORepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "1.0.0" } It "Should install resource given name and exact range inclusive [1.0.0, 5.0.0]" { - Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $ADORepoName -TrustRepository + Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $ADORepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.0.0" } It "Should install resource given name and exact range exclusive (1.0.0, 5.0.0)" { - Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $ADORepoName -TrustRepository + Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $ADORepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "3.0.0" @@ -118,7 +118,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } It "Install resource with latest (including prerelease) version given Prerelease parameter" { - Install-PSResource -Name $testModuleName -Prerelease -Repository $ADORepoName -TrustRepository + Install-PSResource -Name $testModuleName -Prerelease -Repository $ADORepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.2.5" @@ -126,7 +126,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } It "Install resource via InputObject by piping from Find-PSresource" { - Find-PSResource -Name $testModuleName -Repository $ADORepoName | Install-PSResource -TrustRepository + Find-PSResource -Name $testModuleName -Repository $ADORepoName | Install-PSResource -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.0.0" @@ -238,15 +238,15 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidatio It "Install resource under AllUsers scope - Unix only" -Skip:(Get-IsWindows) { Install-PSResource -Name $testModuleName -Repository $TestGalleryName -Scope AllUsers $pkg = Get-Module $testModuleName -ListAvailable - $pkg.Name | Should -Be $testModuleName + $pkg.Name | Should -Be $testModuleName $pkg.Path.Contains("/usr/") | Should -Be $true } # This needs to be manually tested due to prompt It "Install resource that requires accept license without -AcceptLicense flag" { Install-PSResource -Name $testModuleName2 -Repository $TestGalleryName - $pkg = Get-InstalledPSResource $testModuleName2 - $pkg.Name | Should -Be $testModuleName2 + $pkg = Get-InstalledPSResource $testModuleName2 + $pkg.Name | Should -Be $testModuleName2 $pkg.Version | Should -Be "0.0.1.0" } @@ -255,7 +255,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidatio Set-PSResourceRepository PoshTestGallery -Trusted:$false Install-PSResource -Name $testModuleName -Repository $TestGalleryName -confirm:$false - + $pkg = Get-Module $testModuleName -ListAvailable $pkg.Name | Should -Be $testModuleName diff --git a/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 index 87c3320c5..e5984b7f3 100644 --- a/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 @@ -48,7 +48,7 @@ Describe "Test Publish-PSResource" -tags 'CI' { $testModuleName = "test_local_mod" $ADOPublicRepoName = "PSGetTestingPublicFeed" - $ADOPublicRepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/powershell-public-test/nuget/v3/index.json" + $ADOPublicRepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/psresourceget-public-test-ci/nuget/v3/index.json" Register-PSResourceRepository -Name $ADOPublicRepoName -Uri $ADOPublicRepoUri $ADOPrivateRepoName = "PSGetTestFeedWithPrivateAccess" From a2df53a13ca05ff16848895fedcf222d0c3b1b2c Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Fri, 23 Aug 2024 11:26:09 -0700 Subject: [PATCH 093/160] Update expected values of test for packages from PSGallery (#1693) --- ...indPSResourceRepositorySearching.Tests.ps1 | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/test/FindPSResourceTests/FindPSResourceRepositorySearching.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceRepositorySearching.Tests.ps1 index 4b6ddac79..405c93e61 100644 --- a/test/FindPSResourceTests/FindPSResourceRepositorySearching.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceRepositorySearching.Tests.ps1 @@ -57,7 +57,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $pkg2 = $res[1] $pkg2.Name | Should -Be $testModuleName $pkg2.Repository | Should -Be $PSGalleryName - + $pkg3 = $res[2] $pkg3.Name | Should -Be $testModuleName $pkg3.Repository | Should -Be $NuGetGalleryName @@ -210,7 +210,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "should not allow for repository name with wildcard and non-wildcard name specified in same command run" { {Find-PSResource -Name "test_module" -Repository "*Gallery",$localRepoName} | Should -Throw -ErrorId "RepositoryNamesWithWildcardsAndNonWildcardUnsupported,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } - + It "not find resource and write error if resource does not exist in any pattern matching repositories (-Repository with wildcard)" { $res = Find-PSResource -Name "nonExistantPkg" -Repository "*Gallery" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty @@ -296,7 +296,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $pkg3 = $res[2] $pkg3.Name | Should -Be $testModuleName $pkg3.Repository | Should -Be $PSGalleryName - + # Note Find-PSResource -Tag returns package Ids in desc order $pkg4 = $res[3] $pkg4.Name | Should -Be $testModuleName @@ -330,7 +330,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $res = Find-PSResource -Tag "NonExistantTag" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -HaveCount 0 $err | Should -HaveCount 1 - $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithTagsNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithTagsNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } It "not find resource when it has one tag specified but not other and report error (without -Repository specified)" { @@ -380,7 +380,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $pkg2 = $res[1] $pkg2.Name | Should -Be $testModuleName $pkg2.Repository | Should -Be $PSGalleryName - + # Note Find-PSResource -Tag returns package Ids in desc order $pkg3 = $res[2] $pkg3.Name | Should -Be $testModuleName @@ -394,7 +394,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "should not allow for repository name with wildcard and non-wildcard name specified in same command run" { {Find-PSResource -Tag $tag1 -Repository "*Gallery",$localRepoName} | Should -Throw -ErrorId "RepositoryNamesWithWildcardsAndNonWildcardUnsupported,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } - + It "not find resource and write error if tag does not exist for resources in any pattern matching repositories (-Repository with wildcard)" { $res = Find-PSResource -Tag "NonExistantTag" -Repository "*Gallery" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty @@ -451,7 +451,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $pkg2 = $res[1] $pkg2.Name | Should -Be "anam_script" - $pkg2.Repository | Should -Be $PSGalleryName + $pkg2.Repository | Should -Be $PSGalleryName $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithSpecifiedTagsNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } @@ -461,7 +461,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - # $cmdNameToSearch = "Get-TargetResource" $res = Find-PSResource -CommandName $cmdName -ErrorVariable err -ErrorAction SilentlyContinue $err | Should -HaveCount 0 - $res.Count | Should -BeGreaterOrEqual 10 + $res.Count | Should -BeGreaterOrEqual 9 $pkgFoundFromLocalRepo = $false $pkgFoundFromPSGallery = $false @@ -477,7 +477,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $cmdName + $pkg.Names | Should -Be $cmdName $pkg.ParentResource.Includes.Command | Should -Contain $cmdName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeTrue @@ -487,7 +487,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $res = Find-PSResource -Command "NonExistantCommandName" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -HaveCount 0 $err | Should -HaveCount 1 - $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithCmdOrDscNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithCmdOrDscNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } It "not find resource when it has one CommandName specified but not other and report error (without -Repository specified)" { @@ -509,7 +509,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $err | Should -HaveCount 1 $err[0].FullyQualifiedErrorId | Should -BeExactly "WildcardsUnsupportedForCommandNameorDSCResourceName,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" - $res.Count | Should -BeGreaterOrEqual 10 + $res.Count | Should -BeGreaterOrEqual 9 $pkgFoundFromLocalRepo = $false $pkgFoundFromPSGallery = $false @@ -525,7 +525,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $cmdName + $pkg.Names | Should -Be $cmdName $pkg.ParentResource.Includes.Command | Should -Contain $cmdName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeTrue @@ -552,7 +552,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $cmdName + $pkg.Names | Should -Be $cmdName $pkg.ParentResource.Includes.Command | Should -Contain $cmdName $pkgFoundFromLocalRepo | Should -BeFalse $pkgFoundFromPSGallery | Should -BeTrue @@ -561,7 +561,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "should not allow for repository name with wildcard and non-wildcard command name specified in same command run" { {Find-PSResource -CommandName $cmdName -Repository "*Gallery",$localRepoName} | Should -Throw -ErrorId "RepositoryNamesWithWildcardsAndNonWildcardUnsupported,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } - + It "not find resource and write error if tag does not exist for resources in any pattern matching repositories (-Repository with wildcard)" { $res = Find-PSResource -CommandName "NonExistantCommand" -Repository "*Gallery" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty @@ -592,7 +592,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "find resource given CommandName from all repositories where it exists (-Repository with multiple non-wildcard values)" { $res = Find-PSResource -CommandName $cmdName -Repository $PSGalleryName,$localRepoName - $res.Count | Should -BeGreaterOrEqual 10 + $res.Count | Should -BeGreaterOrEqual 9 $pkgFoundFromLocalRepo = $false $pkgFoundFromPSGallery = $false @@ -609,7 +609,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $cmdName + $pkg.Names | Should -Be $cmdName $pkg.ParentResource.Includes.Command | Should -Contain $cmdName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeTrue @@ -636,10 +636,10 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $cmdName2 + $pkg.Names | Should -Be $cmdName2 $pkg.ParentResource.Includes.Command | Should -Contain $cmdName2 $pkgFoundFromLocalRepo | Should -BeTrue - $pkgFoundFromPSGallery | Should -BeFalse + $pkgFoundFromPSGallery | Should -BeFalse $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithSpecifiedCmdOrDSCNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } @@ -681,7 +681,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "find resource that has DSCResourceName specified from all repositories where it exists and not write errors where it does not exist (without -Repository specified)" { $res = Find-PSResource -DscResourceName $dscName -ErrorVariable err -ErrorAction SilentlyContinue $err | Should -HaveCount 0 - $res.Count | Should -BeGreaterOrEqual 3 + $res.Count | Should -BeGreaterOrEqual 2 $pkgFoundFromLocalRepo = $false $pkgFoundFromPSGallery = $false @@ -697,7 +697,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $dscName + $pkg.Names | Should -Be $dscName $pkg.ParentResource.Includes.DscResource | Should -Contain $dscName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeTrue @@ -707,7 +707,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $res = Find-PSResource -DscResourceName "NonExistantDSCResourceName" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -HaveCount 0 $err | Should -HaveCount 1 - $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithCmdOrDscNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithCmdOrDscNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } It "not find resource when it has one DSCResourceName specified but not other and report error (without -Repository specified)" { @@ -729,7 +729,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $err | Should -HaveCount 1 $err[0].FullyQualifiedErrorId | Should -BeExactly "WildcardsUnsupportedForCommandNameorDSCResourceName,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" - $res.Count | Should -BeGreaterOrEqual 3 + $res.Count | Should -BeGreaterOrEqual 2 $pkgFoundFromLocalRepo = $false $pkgFoundFromPSGallery = $false @@ -772,7 +772,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $dscName + $pkg.Names | Should -Be $dscName $pkg.ParentResource.Includes.DscResource | Should -Contain $dscName $pkgFoundFromLocalRepo | Should -BeFalse $pkgFoundFromPSGallery | Should -BeTrue @@ -781,7 +781,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "should not allow for repository name with wildcard and non-wildcard command name specified in same command run" { {Find-PSResource -DscResourceName $dscName -Repository "*Gallery",$localRepoName} | Should -Throw -ErrorId "RepositoryNamesWithWildcardsAndNonWildcardUnsupported,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } - + It "not find resource and write error if tag does not exist for resources in any pattern matching repositories (-Repository with wildcard)" { $res = Find-PSResource -DscResourceName "NonExistantDSCResource" -Repository "*Gallery" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty @@ -830,7 +830,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $dscName + $pkg.Names | Should -Be $dscName $pkg.ParentResource.Includes.DscResource | Should -Contain $dscName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeTrue @@ -860,7 +860,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $pkg.Names | Should -Be $dscName2 $pkg.ParentResource.Includes.DscResource | Should -Contain $dscName2 $pkgFoundFromLocalRepo | Should -BeTrue - $pkgFoundFromPSGallery | Should -BeFalse + $pkgFoundFromPSGallery | Should -BeFalse $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithSpecifiedCmdOrDSCNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } @@ -891,7 +891,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $dscName + $pkg.Names | Should -Be $dscName $pkg.ParentResource.Includes.DscResource | Should -Contain $dscName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeFalse From e9e8ecba61ce3f1143726ace77d320a2bb5bea31 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Fri, 23 Aug 2024 16:35:24 -0500 Subject: [PATCH 094/160] Publish-PSResource: Pack and Push Feature (#1682) --- .gitignore | 4 + src/Microsoft.PowerShell.PSResourceGet.psd1 | 1 + .../v17/.suo | Bin 266752 -> 221184 bytes src/code/CompressPSResource.cs | 77 ++ src/code/PSResourceInfo.cs | 3 +- src/code/PublishHelper.cs | 1211 +++++++++++++++++ src/code/PublishPSResource.cs | 1079 +-------------- .../CompressPSResource.Tests.ps1 | 179 +++ .../PublishPSResource.Tests.ps1 | 16 +- 9 files changed, 1521 insertions(+), 1049 deletions(-) create mode 100644 src/code/CompressPSResource.cs create mode 100644 src/code/PublishHelper.cs create mode 100644 test/PublishPSResourceTests/CompressPSResource.Tests.ps1 diff --git a/.gitignore b/.gitignore index df39f04f3..e035e30c7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ srcOld/code/obj out test/**/obj test/**/bin +.vs +.vscode +src/code/.vs +test/testFiles/testScripts/test.ps1 \ No newline at end of file diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index b5abe9ae6..2b631ee21 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -16,6 +16,7 @@ CLRVersion = '4.0.0' FormatsToProcess = 'PSGet.Format.ps1xml' CmdletsToExport = @( + 'Compress-PSResource', 'Find-PSResource', 'Get-InstalledPSResource', 'Get-PSResourceRepository', diff --git a/src/code/.vs/Microsoft.PowerShell.PSResourceGet/v17/.suo b/src/code/.vs/Microsoft.PowerShell.PSResourceGet/v17/.suo index 05b0bcf91c690892d34c6ec7f22675839024ccd4..12eaeb3a3a156d92c95b2d4f77161ec22932264a 100644 GIT binary patch delta 1511 zcmb7@e{54#6vumCU$?ef>q9!+*kohAc5}Ry*U{29HEvqmh>%bbjah1-bPrfE?9%s` zx?ooMLrGW+>oL*)#6pPKUt`Z?|7dIiiN+YkfIpb}zwsYQoPTH{^LwaeGeE?feBL?t z+PjI!08t}>s>CFcHu=u3S8TN$+x^D8WAWaX-b+BKhf{fmp+$@hzuw@ z(@whN3veeY;e!?+A-7IH2(KC3SXo)=faMwMKnQT`FVGI3Q^CF2uZN!mDc}b!;7RZh z;5i#%9{@w33wQy?!>~=D2eg51UK)Y39q@$5V7VYeAIhrIiNFBp2SGqT%W9Jk4$s~R z`xG$1@NBl-8T4qP{!Dy?)gViHU7WhJ476Myn%<06T!$MUl!ah_tr*;AG1zkB`7!ia z49`FDI@koblNfxqwPJ7%76aneyQ*r?TXJWZ^F|Th3Uts1TERB(1mN+3*=#Fam)nGo z0-oPWeog)gBaZHG6tTJfEOZXEoRLbw zluw}G3ctnLt8;8+Mr_q}{k7xU;z_M9d3-24ZkexewdX9ekS^p8jg6b(jFmrD$eEdf zIgz*MeWTf%${(F5n3hE!8;P!dO|LF|`9Bvb>1X$v)oeS>@d>j)2aGL#BU#Hp-WEgo z;*oQYzB#x4^MS7UrH>w;dY;lP^W~1ZapPeo{lX=igs%+Zdl-C-m~6S0H+qjYtPR*uT0ZYUXyId=DqD90NY zw4w;}3%C#PKT$jC>dvD%YZur0WUe)E60eMB2Jziby3t9$_9jT(^j=kM{+Vv6y}Swc zcGr6-5BbZ6RNm`#l=O8mTU5#!@BiBjReA?$v$TPBADwe5hX&niQ_nZ^yY}2wc>0<}S$E+i()%GE3-5!qKj7KM|7}riDwi}w!SAC6>_gZwZ_9S87zyZpHW?mYezI0WQE5fFi#0sjQ?P(<+0MT|j1Fb8r-PGImO)nHo*MZ||6~Q8SaN zGs$F`&%NL7?c4Wv-|pe`YMlMLXDS0-L=aMrKduus=MyhoKk-KrCJ2Ie2JI5NNH6_N z{H>Z8a3lbzWoHu25!D*LlP%%{TQDU72@W-)NXUXD;Ae!x%`bi14J6Un=a=F~?(?S! zNQ)uFzi&dC3TY4zu}}gk;Q9Dnv96>mZ7tA-_Al_IW^9DzN76M_S!4s98 zXfUrBZ*VZW?A8YJYu>HF{QCTRNK;xIg$vx$Vx>Y;acm0AfgA3&Scee-&&jtLh#!P} zD1ezT3uePZpo1cq423Wsc)l2aL%cSc9+@ z)X(+?H7NCIp?}a$6ESVOiu$d0N;7a!aHyrl-YRYS)|T z8EsB*F3z3&svuw22NI9(JK3(^Nd_VeqHM@WB5Hxp<4~BGwbY)ujGor!2Js;j1o=PF zH4?2#>hFse2cirjHJDyhE5pd=)I=tE+!RLol7l?jVu?rsP4VzMU{WCO*i_o7UFMs0 z{u@Ic*M<>Kgn=6Jl6ZAxdmgHkU8DvBDWhtDG)K;9&(``t1 zyDh~H&mv&(96S$uU@z>07vLq}878(q>>q@FI0Qd|pTf)V3LJ)?!4dd5yb4F*4Ywt~ zX$ZkdI0X|IJdOAaoP~369!B6zcnkP>ZzCP`#}oN}hyCBf1-J;8;1BQ)ybEJ|j{F(H z75La~nI8BFf}8HPa!&?2#CSQ>V>+!?E`QtT(X-vUW*);Z+QwJOqAr5dP;5vl6nVcBafl9=fMvV!XCRkW(QpN`c| zp`-g^__x$+-AHrJ>S$i=5qY$)k|;Y@yW{BSZoORgN~xqASbRi9v-+~wwGr~9eDSq- zQ8`-BZ}c6hJXxR|ac8Nv4t4Ynb&$cK4$*Y=YiAR^Vzsh$OC%$`_I5R^StA*!_m@%3 z+bkxNciFI2>|izJBqF?1Fyof371rak9(SpYk*#DA8+}haz{X}t)$Ex^iJoA7UEQ*@!No6d7!|l`D|Rj%CM&&vv0fXcp?(@hPO@o|KaKHKhoJgD(}5xgr}| zDH)lrK>FdJ(z9+{ikzH9pve}+Bl9eXE6`}M3 z>130J$#j+zEDbR84x(Zots+s;$>^62XjD6z*o@|x(M$C#cL%Yt1HD8w{SHmX@|lcv zt_As;vF<53&^3H@4x+=>?donseCuW@UF1h!saE{=b&S-TeXSNYE6vuN$7t}U7*BJl zWmD$2Fdm`Gcx>@kY|9-g#)fOf@_RCmzdLt}y2sz(9_HF5x-}&j0si>ssh*Dn8DXwf zWN%=l&?Gc6*KCp$t%P{Mw_rxsVSX!j19R;lsqDaNvXb91D|n=0H&>I%fqb$zDV^)^ zm*u!aS**>C&b_J>Hq}=`Iu?^oWg)3U;n{-k=tQcN&&Yo9$E;^eEK!!DUHio-Hvf=l zz84pgKOZO*)lPq%ol2cU7NTGH>%&5kj*eyDo6c!gI>+TN$mJ`j)L&4k$ckPgzLj8~ z8>{tquZy1Y*Ze7d&vfRk6{_$i*q{ttHkM4Au(9MFuWJ6y)cWV2JN)L-{fVEtx($L})%ipTwaso(_FW%S5g+Sp5Bp zZ(DuK#Q#+6d$~;T7fHTtqq$=(ba;^gZ>15E?_S_(fwESdqks9;WB4|iw!W@PT#1#T z9mC&%m7*Cx5*m@#;7@repl#kvcEUxBY_wO*V@1Cf)AIQjb}T41JXjm>DV#jZaX6$uhhFNefUQh%(o%h~slAw&sc3&-p?RTx_Kl?LNzOWv0%)Bvl?oXiw zo73CsbE_idakeXdXU)UEv3wTy;g%1-K{LYt9mzJD#6mjo`VPAEcnsD4?Pc=+TtKeA z^gbCpGneQI>v@702hD#a$0pO(58_x@AQ`8l2iGy{QS{q^0Ma-Zr4m0*afrAf_)kI! zX#fO55U5}-xJ*L*2m1Z7YTi(v^Yg=JudhhaHXzzTQ-R>GrD39Dc=JO)+pI8?(L zc*21X3)Da@tc5zTLOra526z(ILnAanGqgY}v_U)Azz!Q=BXqzf=!7olhRv`AdVrTL eIz?)EI^FX{#-KSvJf^13GVg86s65)3L;efpy}|SV diff --git a/src/code/CompressPSResource.cs b/src/code/CompressPSResource.cs new file mode 100644 index 000000000..ba79425df --- /dev/null +++ b/src/code/CompressPSResource.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using System.IO; +using System.Linq; +using System.Management.Automation; + +namespace Microsoft.PowerShell.PSResourceGet.Cmdlets +{ + /// + /// Compresses a module, script, or nupkg to a designated repository. + /// + [Cmdlet(VerbsData.Compress, + "PSResource", + SupportsShouldProcess = true)] + [Alias("cmres")] + public sealed class CompressPSResource : PSCmdlet + { + #region Parameters + + /// + /// Specifies the path to the resource that you want to compress. This parameter accepts the path to the folder that contains the resource. + /// Specifies a path to one or more locations. Wildcards are permitted. The default location is the current directory (.). + /// + [Parameter(Mandatory = true, Position = 0, HelpMessage = "Path to the resource to be compressed.")] + [ValidateNotNullOrEmpty] + public string Path { get; set; } + + /// + /// Specifies the path where the compressed resource (as a .nupkg file) should be saved. + /// This parameter allows you to save the package to a specified location on the local file system. + /// + [Parameter(Mandatory = true, Position = 1, HelpMessage = "Path to save the compressed resource.")] + [ValidateNotNullOrEmpty] + public string DestinationPath { get; set; } + + /// + /// Bypasses validating a resource module manifest before publishing. + /// + [Parameter] + public SwitchParameter SkipModuleManifestValidate { get; set; } + + #endregion + + #region Members + + private PublishHelper _publishHelper; + + #endregion + + #region Method Overrides + + protected override void BeginProcessing() + { + // Create a respository store (the PSResourceRepository.xml file) if it does not already exist + // This is to create a better experience for those who have just installed v3 and want to get up and running quickly + RepositorySettings.CheckRepositoryStore(); + + _publishHelper = new PublishHelper( + this, + Path, + DestinationPath, + SkipModuleManifestValidate); + + _publishHelper.CheckAllParameterPaths(); + } + + protected override void EndProcessing() + { + _publishHelper.PackResource(); + } + + #endregion + + } +} diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 9e2fc6bc2..0e14f6ce9 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -21,7 +21,8 @@ public enum ResourceType { None, Module, - Script + Script, + Nupkg } public enum VersionType diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs new file mode 100644 index 000000000..f8c762eab --- /dev/null +++ b/src/code/PublishHelper.cs @@ -0,0 +1,1211 @@ +using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using NuGet.Commands; +using NuGet.Common; +using NuGet.Configuration; +using NuGet.Packaging; +using NuGet.Versioning; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Xml; + +namespace Microsoft.PowerShell.PSResourceGet.Cmdlets +{ + internal class PublishHelper + { + #region Enums + internal enum CallerCmdlet + { + PublishPSResource, + CompressPSResource + } + + #endregion + + #region Members + + private readonly CallerCmdlet _callerCmdlet; + private readonly PSCmdlet _cmdletPassedIn; + private readonly string _cmdOperation; + private readonly string Path; + private string DestinationPath; + private string resolvedPath; + private CancellationToken _cancellationToken; + private NuGetVersion _pkgVersion; + private string _pkgName; + private static char[] _PathSeparators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar }; + public const string PSDataFileExt = ".psd1"; + public const string PSScriptFileExt = ".ps1"; + public const string NupkgFileExt = ".nupkg"; + private const string PSScriptInfoCommentString = "<#PSScriptInfo"; + private string pathToScriptFileToPublish = string.Empty; + private string pathToModuleManifestToPublish = string.Empty; + private string pathToModuleDirToPublish = string.Empty; + private string pathToNupkgToPublish = string.Empty; + private ResourceType resourceType = ResourceType.None; + private NetworkCredential _networkCredential; + string userAgentString = UserAgentInfo.UserAgentString(); + private bool _isNupkgPathSpecified = false; + private Hashtable dependencies; + private Hashtable parsedMetadata; + private PSCredential Credential; + private string outputNupkgDir; + private string ApiKey; + private bool SkipModuleManifestValidate = false; + private string outputDir = string.Empty; + internal bool ScriptError = false; + internal bool ShouldProcess = true; + + #endregion + + #region Constructors + + internal PublishHelper(PSCmdlet cmdlet, string path, string destinationPath, bool skipModuleManifestValidate) + { + _callerCmdlet = CallerCmdlet.CompressPSResource; + _cmdOperation = "Compress"; + _cmdletPassedIn = cmdlet; + Path = path; + DestinationPath = destinationPath; + SkipModuleManifestValidate = skipModuleManifestValidate; + outputDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); + outputNupkgDir = destinationPath; + } + + internal PublishHelper(PSCmdlet cmdlet, + PSCredential credential, + string apiKey, + string path, + string destinationPath, + bool skipModuleManifestValidate, + CancellationToken cancellationToken, + bool isNupkgPathSpecified) + { + _callerCmdlet = CallerCmdlet.PublishPSResource; + _cmdOperation = "Publish"; + _cmdletPassedIn = cmdlet; + Credential = credential; + ApiKey = apiKey; + Path = path; + DestinationPath = destinationPath; + SkipModuleManifestValidate = skipModuleManifestValidate; + _cancellationToken = cancellationToken; + _isNupkgPathSpecified = isNupkgPathSpecified; + outputDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); + outputNupkgDir = System.IO.Path.Combine(outputDir, "nupkg"); + } + + #endregion + + #region Internal Methods + + internal void PackResource() + { + // Returns the name of the file or the name of the directory, depending on path + if (!_cmdletPassedIn.ShouldProcess(string.Format("'{0}' from the machine", resolvedPath))) + { + _cmdletPassedIn.WriteVerbose("ShouldProcess is set to false."); + ShouldProcess = false; + return; + } + + parsedMetadata = new Hashtable(StringComparer.OrdinalIgnoreCase); + if (resourceType == ResourceType.Script) + { + if (!PSScriptFileInfo.TryTestPSScriptFileInfo( + scriptFileInfoPath: pathToScriptFileToPublish, + parsedScript: out PSScriptFileInfo scriptToPublish, + out ErrorRecord[] errors, + out string[] _ + )) + { + foreach (ErrorRecord error in errors) + { + _cmdletPassedIn.WriteError(error); + } + + ScriptError = true; + + return; + } + + parsedMetadata = scriptToPublish.ToHashtable(); + + _pkgName = System.IO.Path.GetFileNameWithoutExtension(pathToScriptFileToPublish); + } + else + { + if (!string.IsNullOrEmpty(pathToModuleManifestToPublish)) + { + _pkgName = System.IO.Path.GetFileNameWithoutExtension(pathToModuleManifestToPublish); + } + else + { + // Search for module manifest + foreach (FileInfo file in new DirectoryInfo(pathToModuleDirToPublish).EnumerateFiles()) + { + if (file.Name.EndsWith(PSDataFileExt, StringComparison.OrdinalIgnoreCase)) + { + pathToModuleManifestToPublish = file.FullName; + _pkgName = System.IO.Path.GetFileNameWithoutExtension(file.Name); + + break; + } + } + } + + // Validate that there's a module manifest + if (!File.Exists(pathToModuleManifestToPublish)) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"No file with a .psd1 extension was found in '{pathToModuleManifestToPublish}'. Please specify a path to a valid module manifest."), + "moduleManifestNotFound", + ErrorCategory.ObjectNotFound, + this)); + + return; + } + + // The Test-ModuleManifest currently cannot process UNC paths. Disabling verification for now. + if ((new Uri(pathToModuleManifestToPublish)).IsUnc) + SkipModuleManifestValidate = true; + + // Validate that the module manifest has correct data + if (!SkipModuleManifestValidate && + !Utils.ValidateModuleManifest(pathToModuleManifestToPublish, out string errorMsg)) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new PSInvalidOperationException(errorMsg), + "InvalidModuleManifest", + ErrorCategory.InvalidOperation, + this)); + } + + if (!Utils.TryReadManifestFile( + manifestFilePath: pathToModuleManifestToPublish, + manifestInfo: out parsedMetadata, + error: out Exception manifestReadError)) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + manifestReadError, + "ManifestFileReadParseForContainerRegistryPublishError", + ErrorCategory.ReadError, + this)); + + return; + } + + } + + // Create a temp folder to push the nupkg to and delete it later + try + { + Directory.CreateDirectory(outputDir); + } + catch (Exception e) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException(e.Message), + "ErrorCreatingTempDir", + ErrorCategory.InvalidData, + this)); + + return; + } + + try + { + string nuspec = string.Empty; + + // Create a nuspec + try + { + nuspec = CreateNuspec( + outputDir: outputDir, + filePath: (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish, + parsedMetadataHash: parsedMetadata, + requiredModules: out dependencies); + } + catch (Exception e) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"Nuspec creation failed: {e.Message}"), + "NuspecCreationFailed", + ErrorCategory.ObjectNotFound, + this)); + + return; + } + + if (string.IsNullOrEmpty(nuspec)) + { + // nuspec creation failed. + _cmdletPassedIn.WriteVerbose("Nuspec creation failed."); + return; + } + + if (resourceType == ResourceType.Script) + { + // copy the script file to the temp directory + File.Copy(pathToScriptFileToPublish, System.IO.Path.Combine(outputDir, _pkgName + PSScriptFileExt), true); + } + else + { + try + { + // If path is pointing to a file, get the parent directory, otherwise assumption is that path is pointing to the root directory + string rootModuleDir = !string.IsNullOrEmpty(pathToModuleManifestToPublish) ? System.IO.Path.GetDirectoryName(pathToModuleManifestToPublish) : pathToModuleDirToPublish; + + // Create subdirectory structure in temp folder + foreach (string dir in Directory.GetDirectories(rootModuleDir, "*", SearchOption.AllDirectories)) + { + var dirName = dir.Substring(rootModuleDir.Length).Trim(_PathSeparators); + Directory.CreateDirectory(System.IO.Path.Combine(outputDir, dirName)); + } + + // Copy files over to temp folder + foreach (string fileNamePath in Directory.GetFiles(rootModuleDir, "*", SearchOption.AllDirectories)) + { + var fileName = fileNamePath.Substring(rootModuleDir.Length).Trim(_PathSeparators); + var newFilePath = System.IO.Path.Combine(outputDir, fileName); + + // The user may have a .nuspec defined in the module directory + // If that's the case, we will not use that file and use the .nuspec that is generated via PSGet + // The .nuspec that is already in in the output directory is the one that was generated via the CreateNuspec method + if (!File.Exists(newFilePath)) + { + File.Copy(fileNamePath, newFilePath); + } + } + } + catch (Exception e) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException("Error occured while creating directory to publish: " + e.Message), + "ErrorCreatingDirectoryToPublish", + ErrorCategory.InvalidOperation, + this)); + } + } + + // pack into .nupkg + if (!PackNupkg(outputDir, outputNupkgDir, nuspec, out ErrorRecord packNupkgError)) + { + _cmdletPassedIn.WriteError(packNupkgError); + // exit out of processing + return; + } + } + catch (Exception e) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + e, + $"{this.GetType()}Error", + ErrorCategory.NotSpecified, + this)); + } + finally + { + if(_callerCmdlet == CallerCmdlet.CompressPSResource) + { + _cmdletPassedIn.WriteVerbose(string.Format("Deleting temporary directory '{0}'", outputDir)); + Utils.DeleteDirectory(outputDir); + } + } + } + + internal void PushResource(string Repository, bool SkipDependenciesCheck, NetworkCredential _networkCrendential) + { + try + { + PSRepositoryInfo repository = RepositorySettings.Read(new[] { Repository }, out _).FirstOrDefault(); + // Find repository + if (repository == null) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"The resource repository '{Repository}' is not a registered. Please run 'Register-PSResourceRepository' in order to publish to this repository."), + "RepositoryNotFound", + ErrorCategory.ObjectNotFound, + this)); + + return; + } + else if (repository.Uri.Scheme == Uri.UriSchemeFile && !repository.Uri.IsUnc && !Directory.Exists(repository.Uri.LocalPath)) + { + // this check to ensure valid local path is not for UNC paths (which are server based, instead of Drive based) + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"The repository '{repository.Name}' with uri: '{repository.Uri.AbsoluteUri}' is not a valid folder path which exists. If providing a file based repository, provide a repository with a path that exists."), + "repositoryPathDoesNotExist", + ErrorCategory.ObjectNotFound, + this)); + + return; + } + + _networkCredential = Utils.SetNetworkCredential(repository, _networkCredential, _cmdletPassedIn); + + // Check if dependencies already exist within the repo if: + // 1) the resource to publish has dependencies and + // 2) the -SkipDependenciesCheck flag is not passed in + if (dependencies != null && !SkipDependenciesCheck) + { + // If error gets thrown, exit process record + if (!CheckDependenciesExist(dependencies, repository.Name)) + { + return; + } + } + + // If -DestinationPath is specified then also publish the .nupkg there + if (!string.IsNullOrWhiteSpace(DestinationPath)) + { + if (!Directory.Exists(DestinationPath)) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"Destination path does not exist: '{DestinationPath}'"), + "InvalidDestinationPath", + ErrorCategory.InvalidArgument, + this)); + + return; + } + + if (!_isNupkgPathSpecified) + { + try + { + var nupkgName = _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg"; + var sourceFilePath = System.IO.Path.Combine(outputNupkgDir, nupkgName); + var destinationFilePath = System.IO.Path.Combine(DestinationPath, nupkgName); + + if (!File.Exists(destinationFilePath)) + { + File.Copy(sourceFilePath, destinationFilePath); + } + } + catch (Exception e) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"Error moving .nupkg into destination path '{DestinationPath}' due to: '{e.Message}'."), + "ErrorMovingNupkg", + ErrorCategory.NotSpecified, + this)); + + // exit process record + return; + } + } + } + + string repositoryUri = repository.Uri.AbsoluteUri; + + if (repository.ApiVersion == PSRepositoryInfo.APIVersion.ContainerRegistry) + { + ContainerRegistryServerAPICalls containerRegistryServer = new ContainerRegistryServerAPICalls(repository, _cmdletPassedIn, _networkCredential, userAgentString); + + var pkgMetadataFile = (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish; + if (!containerRegistryServer.PushNupkgContainerRegistry(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgContainerRegistryError)) + { + _cmdletPassedIn.WriteError(pushNupkgContainerRegistryError); + // exit out of processing + return; + } + } + else + { + if(_isNupkgPathSpecified) + { + outputNupkgDir = pathToNupkgToPublish; + } + // This call does not throw any exceptions, but it will write unsuccessful responses to the console + if (!PushNupkg(outputNupkgDir, repository.Name, repository.Uri.ToString(), out ErrorRecord pushNupkgError)) + { + _cmdletPassedIn.WriteError(pushNupkgError); + // exit out of processing + return; + } + } + } + catch (Exception e) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + e, + "PublishPSResourceError", + ErrorCategory.NotSpecified, + this)); + } + finally + { + if (!_isNupkgPathSpecified) + { + _cmdletPassedIn.WriteVerbose(string.Format("Deleting temporary directory '{0}'", outputDir)); + Utils.DeleteDirectory(outputDir); + } + } + } + + internal void CheckAllParameterPaths() + { + try + { + resolvedPath = _cmdletPassedIn.GetResolvedProviderPathFromPSPath(Path, out ProviderInfo provider).First(); + } + catch (MethodInvocationException) + { + // path does not exist + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"The path to the resource to {_cmdOperation.ToLower()} does not exist, point to an existing path or file of the module or script to {_cmdOperation.ToLower()}."), + "SourcePathDoesNotExist", + ErrorCategory.InvalidArgument, + this)); + } + + // Condition 1: path is to the root directory of the module to be published + // Condition 2: path is to the .psd1 or .ps1 of the module/script to be published + if (string.IsNullOrEmpty(resolvedPath)) + { + // unsupported file path + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"The path to the resource to {_cmdOperation.ToLower()} is not in the correct format or does not exist. Please provide the path of the root module " + + $"(i.e. './/') or the path to the .psd1 (i.e. './/.psd1')."), + $"Invalid{_cmdOperation}Path", + ErrorCategory.InvalidArgument, + this)); + } + else if (Directory.Exists(resolvedPath)) + { + pathToModuleDirToPublish = resolvedPath; + resourceType = ResourceType.Module; + } + else if (resolvedPath.EndsWith(PSDataFileExt, StringComparison.OrdinalIgnoreCase)) + { + pathToModuleManifestToPublish = resolvedPath; + resourceType = ResourceType.Module; + } + else if (resolvedPath.EndsWith(PSScriptFileExt, StringComparison.OrdinalIgnoreCase)) + { + pathToScriptFileToPublish = resolvedPath; + resourceType = ResourceType.Script; + } + else if (resolvedPath.EndsWith(NupkgFileExt, StringComparison.OrdinalIgnoreCase) && _isNupkgPathSpecified) + { + pathToNupkgToPublish = resolvedPath; + resourceType = ResourceType.Nupkg; + } + else + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"The {_cmdOperation.ToLower()} path provided, '{resolvedPath}', is not a valid. Please provide a path to the root module " + + $"(i.e. './/') or path to the .psd1 (i.e. './/.psd1')."), + $"Invalid{_cmdOperation}Path", + ErrorCategory.InvalidArgument, + this)); + } + + if (!String.IsNullOrEmpty(DestinationPath)) + { + string resolvedDestinationPath = _cmdletPassedIn.GetResolvedProviderPathFromPSPath(DestinationPath, out ProviderInfo provider).First(); + + if (Directory.Exists(resolvedDestinationPath)) + { + DestinationPath = resolvedDestinationPath; + } + else + { + try + { + Directory.CreateDirectory(resolvedDestinationPath); + } + catch (Exception e) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"Destination path does not exist and cannot be created: {e.Message}"), + "InvalidDestinationPath", + ErrorCategory.InvalidArgument, + this)); + } + } + } + } + + #endregion + + #region Private Methods + + private bool PackNupkg(string outputDir, string outputNupkgDir, string nuspecFile, out ErrorRecord error) + { + _cmdletPassedIn.WriteDebug("In PublishHelper::PackNupkg()"); + // Pack the module or script into a nupkg given a nuspec. + var builder = new PackageBuilder(); + try + { + var runner = new PackCommandRunner( + new PackArgs + { + CurrentDirectory = outputDir, + OutputDirectory = outputNupkgDir, + Path = nuspecFile, + Exclude = System.Array.Empty(), + Symbols = false, + Logger = NullLogger.Instance + }, + MSBuildProjectFactory.ProjectCreator, + builder); + bool success = runner.RunPackageBuild(); + + if (success) + { + _cmdletPassedIn.WriteVerbose("Successfully packed the resource into a .nupkg"); + } + else + { + error = new ErrorRecord( + new InvalidOperationException("Not able to successfully pack the resource into a .nupkg"), + "failedToPackIntoNupkg", + ErrorCategory.ObjectNotFound, + this); + + return false; + } + } + catch (Exception e) + { + error = new ErrorRecord( + new ArgumentException($"Unexpected error packing into .nupkg: '{e.Message}'."), + "ErrorPackingIntoNupkg", + ErrorCategory.NotSpecified, + this); + + // exit process record + return false; + } + + error = null; + return true; + } + + private bool PushNupkg(string outputNupkgDir, string repoName, string repoUri, out ErrorRecord error) + { + _cmdletPassedIn.WriteDebug("In PublishPSResource::PushNupkg()"); + + string fullNupkgFile; + if (_isNupkgPathSpecified) + { + fullNupkgFile = outputNupkgDir; + } + else + { + // Push the nupkg to the appropriate repository + // Pkg version is parsed from .ps1 file or .psd1 file + fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg"); + } + + // The PSGallery uses the v2 protocol still and publishes to a slightly different endpoint: + // "https://www.powershellgallery.com/api/v2/package" + // Until the PSGallery is moved onto the NuGet v3 server protocol, we'll modify the repository uri + // to accommodate for the approprate publish location. + string publishLocation = repoUri.EndsWith("/v2", StringComparison.OrdinalIgnoreCase) ? repoUri + "/package" : repoUri; + + var settings = NuGet.Configuration.Settings.LoadDefaultSettings(null, null, null); + var success = false; + + var sourceProvider = new PackageSourceProvider(settings); + if (Credential != null || _networkCredential != null) + { + InjectCredentialsToSettings(settings, sourceProvider, publishLocation); + } + + + try + { + PushRunner.Run( + settings: Settings.LoadDefaultSettings(root: null, configFileName: null, machineWideSettings: null), + sourceProvider: sourceProvider, + packagePaths: new List { fullNupkgFile }, + source: publishLocation, + apiKey: ApiKey, + symbolSource: null, + symbolApiKey: null, + timeoutSeconds: 0, + disableBuffering: false, + noSymbols: false, + noServiceEndpoint: false, // enable server endpoint + skipDuplicate: false, // if true-- if a package and version already exists, skip it and continue with the next package in the push, if any. + logger: NullLogger.Instance // nuget logger + ).GetAwaiter().GetResult(); + } + catch (HttpRequestException e) + { + _cmdletPassedIn.WriteVerbose(string.Format("Not able to publish resource to '{0}'", repoUri)); + // look in PS repo for how httpRequestExceptions are handled + + // Unfortunately there is no response message are no status codes provided with the exception and no + var ex = new ArgumentException(String.Format("Repository '{0}': {1}", repoName, e.Message)); + if (e.Message.Contains("400")) + { + if (e.Message.Contains("Api")) + { + // For ADO repositories, public and private, when ApiKey is not provided. + error = new ErrorRecord( + new ArgumentException($"Repository '{repoName}': Please try running again with the -ApiKey parameter and specific API key for the repository specified. For Azure Devops repository, set this to an arbitrary value, for example '-ApiKey AzureDevOps'"), + "400ApiKeyError", + ErrorCategory.AuthenticationError, + this); + } + else + { + error = new ErrorRecord( + ex, + "400Error", + ErrorCategory.PermissionDenied, + this); + } + } + else if (e.Message.Contains("401")) + { + if (e.Message.Contains("API")) + { + // For PSGallery when ApiKey is not provided. + error = new ErrorRecord( + new ArgumentException($"Could not publish to repository '{repoName}'. Please try running again with the -ApiKey parameter and the API key for the repository specified. Exception: '{e.Message}'"), + "401ApiKeyError", + ErrorCategory.AuthenticationError, + this); + } + else + { + // For ADO repository feeds that are public feeds, when the credentials are incorrect. + error = new ErrorRecord(new ArgumentException($"Could not publish to repository '{repoName}'. The Credential provided was incorrect. Exception: '{e.Message}'"), + "401Error", + ErrorCategory.PermissionDenied, + this); ; + } + } + else if (e.Message.Contains("403")) + { + if (repoUri.Contains("myget.org")) + { + // For myGet.org repository feeds when the ApiKey is missing or incorrect. + error = new ErrorRecord( + new ArgumentException($"Could not publish to repository '{repoName}'. The ApiKey provided is incorrect or missing. Please try running again with the -ApiKey parameter and correct API key value for the repository. Exception: '{e.Message}'"), + "403Error", + ErrorCategory.PermissionDenied, + this); + } + else if (repoUri.Contains(".jfrog.io")) + { + // For JFrog Artifactory repository feeds when the ApiKey is provided, whether correct or incorrect, as JFrog does not require -ApiKey (but does require ApiKey to be present as password to -Credential). + error = new ErrorRecord( + new ArgumentException($"Could not publish to repository '{repoName}'. The ApiKey provided is not needed for JFrog Artifactory. Please try running again without the -ApiKey parameter but ensure that -Credential is provided with ApiKey as password. Exception: '{e.Message}'"), + "403Error", + ErrorCategory.PermissionDenied, + this); + } + else + { + error = new ErrorRecord( + ex, + "403Error", + ErrorCategory.PermissionDenied, + this); + } + } + else if (e.Message.Contains("409")) + { + error = new ErrorRecord( + ex, + "409Error", + ErrorCategory.PermissionDenied, this); + } + else + { + error = new ErrorRecord( + ex, + "HTTPRequestError", + ErrorCategory.PermissionDenied, + this); + } + + return success; + } + catch (NuGet.Protocol.Core.Types.FatalProtocolException e) + { + // for ADO repository feeds that are private feeds the error thrown is different and the 401 is in the inner exception message + if (e.InnerException.Message.Contains("401")) + { + error = new ErrorRecord( + new ArgumentException($"Could not publish to repository '{repoName}'. The Credential provided was incorrect. Exception '{e.InnerException.Message}'"), + "401FatalProtocolError", + ErrorCategory.AuthenticationError, + this); + } + else + { + error = new ErrorRecord( + new ArgumentException($"Repository '{repoName}': {e.InnerException.Message}"), + "ProtocolFailError", + ErrorCategory.ProtocolError, + this); + } + + return success; + } + catch (Exception e) + { + _cmdletPassedIn.WriteVerbose($"Not able to publish resource to '{repoUri}'"); + error = new ErrorRecord( + new ArgumentException(e.Message), + "PushNupkgError", + ErrorCategory.InvalidResult, + this); + + return success; + } + + _cmdletPassedIn.WriteVerbose(string.Format("Successfully published the resource to '{0}'", repoUri)); + error = null; + success = true; + + return success; + } + + private void InjectCredentialsToSettings(ISettings settings, IPackageSourceProvider sourceProvider, string source) + { + _cmdletPassedIn.WriteDebug("In PublishPSResource::InjectCredentialsToSettings()"); + if (Credential == null && _networkCredential == null) + { + return; + } + + var packageSource = sourceProvider.LoadPackageSources().FirstOrDefault(s => s.Source == source); + if (packageSource != null) + { + if (!packageSource.IsEnabled) + { + packageSource.IsEnabled = true; + } + } + + + var networkCred = Credential == null ? _networkCredential : Credential.GetNetworkCredential(); + string key; + + if (packageSource == null) + + { + key = "_" + Guid.NewGuid().ToString().Replace("-", ""); + settings.AddOrUpdate( + ConfigurationConstants.PackageSources, + new SourceItem(key, source)); + } + else + { + key = packageSource.Name; + } + + settings.AddOrUpdate( + ConfigurationConstants.CredentialsSectionName, + new CredentialsItem( + key, + networkCred.UserName, + networkCred.Password, + isPasswordClearText: true, + String.Empty)); + } + + private string CreateNuspec( + string outputDir, + string filePath, + Hashtable parsedMetadataHash, + out Hashtable requiredModules) + { + _cmdletPassedIn.WriteDebug("In PublishHelper::CreateNuspec()"); + + bool isModule = resourceType != ResourceType.Script; + requiredModules = new Hashtable(); + + if (parsedMetadataHash == null || parsedMetadataHash.Count == 0) + { + _cmdletPassedIn.WriteError(new ErrorRecord(new ArgumentException("Hashtable provided with package metadata was null or empty"), + "PackageMetadataHashtableNullOrEmptyError", + ErrorCategory.ReadError, + this)); + + return string.Empty; + } + + // now we have parsedMetadatahash to fill out the nuspec information + var nameSpaceUri = "http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd"; + var doc = new XmlDocument(); + + // xml declaration is recommended, but not mandatory + XmlDeclaration xmlDeclaration = doc.CreateXmlDeclaration("1.0", "utf-8", null); + XmlElement root = doc.DocumentElement; + doc.InsertBefore(xmlDeclaration, root); + + // create top-level elements + XmlElement packageElement = doc.CreateElement("package", nameSpaceUri); + XmlElement metadataElement = doc.CreateElement("metadata", nameSpaceUri); + + Dictionary metadataElementsDictionary = new Dictionary(); + + // id is mandatory + metadataElementsDictionary.Add("id", _pkgName); + + string version; + if (parsedMetadataHash.ContainsKey("moduleversion")) + { + version = parsedMetadataHash["moduleversion"].ToString(); + } + else if (parsedMetadataHash.ContainsKey("version")) + { + version = parsedMetadataHash["version"].ToString(); + } + else + { + // no version is specified for the nuspec + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException("There is no package version specified. Please specify a version before publishing."), + "NoVersionFound", + ErrorCategory.InvalidArgument, + this)); + + return string.Empty; + } + + // Look for Prerelease tag and then process any Tags in PrivateData > PSData + if (isModule) + { + if (parsedMetadataHash.ContainsKey("PrivateData")) + { + if (parsedMetadataHash["PrivateData"] is Hashtable privateData && + privateData.ContainsKey("PSData")) + { + if (privateData["PSData"] is Hashtable psData) + { + if (psData.ContainsKey("prerelease") && psData["prerelease"] is string preReleaseVersion) + { + if (!string.IsNullOrEmpty(preReleaseVersion)) + { + version = string.Format(@"{0}-{1}", version, preReleaseVersion); + } + } + + if (psData.ContainsKey("licenseuri") && psData["licenseuri"] is string licenseUri) + + { + metadataElementsDictionary.Add("licenseUrl", licenseUri.Trim()); + } + + if (psData.ContainsKey("projecturi") && psData["projecturi"] is string projectUri) + { + metadataElementsDictionary.Add("projectUrl", projectUri.Trim()); + } + + if (psData.ContainsKey("iconuri") && psData["iconuri"] is string iconUri) + { + metadataElementsDictionary.Add("iconUrl", iconUri.Trim()); + } + + if (psData.ContainsKey("releasenotes")) + { + if (psData["releasenotes"] is string releaseNotes) + { + metadataElementsDictionary.Add("releaseNotes", releaseNotes.Trim()); + } + else if (psData["releasenotes"] is string[] releaseNotesArr) + { + metadataElementsDictionary.Add("releaseNotes", string.Join("\n", releaseNotesArr)); + } + } + + // defaults to false + // Value for requireAcceptLicense key needs to be a lowercase string representation of the boolean for it to be correctly parsed from psData file. + + string requireLicenseAcceptance = psData.ContainsKey("requirelicenseacceptance") ? psData["requirelicenseacceptance"].ToString().ToLower() : "false"; + + metadataElementsDictionary.Add("requireLicenseAcceptance", requireLicenseAcceptance); + + + if (psData.ContainsKey("Tags") && psData["Tags"] is Array manifestTags) + { + var tagArr = new List(); + foreach (string tag in manifestTags) + { + tagArr.Add(tag); + } + parsedMetadataHash["tags"] = string.Join(" ", tagArr.ToArray()); + } + } + } + } + } + else + { + if (parsedMetadataHash.ContainsKey("licenseuri") && parsedMetadataHash["licenseuri"] is Uri licenseUri) + + { + metadataElementsDictionary.Add("licenseUrl", licenseUri.ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("projecturi") && parsedMetadataHash["projecturi"] is Uri projectUri) + { + metadataElementsDictionary.Add("projectUrl", projectUri.ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("iconuri") && parsedMetadataHash["iconuri"] is Uri iconUri) + { + metadataElementsDictionary.Add("iconUrl", iconUri.ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("releaseNotes")) + { + if (parsedMetadataHash["releasenotes"] is string releaseNotes) + { + metadataElementsDictionary.Add("releaseNotes", releaseNotes.Trim()); + } + else if (parsedMetadataHash["releasenotes"] is string[] releaseNotesArr) + { + metadataElementsDictionary.Add("releaseNotes", string.Join("\n", releaseNotesArr)); + } + } + } + + + if (NuGetVersion.TryParse(version, out _pkgVersion)) + { + metadataElementsDictionary.Add("version", _pkgVersion.ToNormalizedString()); + } + + if (parsedMetadataHash.ContainsKey("author")) + { + metadataElementsDictionary.Add("authors", parsedMetadataHash["author"].ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("companyname")) + { + metadataElementsDictionary.Add("owners", parsedMetadataHash["companyname"].ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("description")) + { + metadataElementsDictionary.Add("description", parsedMetadataHash["description"].ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("copyright")) + { + metadataElementsDictionary.Add("copyright", parsedMetadataHash["copyright"].ToString().Trim()); + } + + string tags = (resourceType == ResourceType.Script) ? "PSScript" : "PSModule"; + if (parsedMetadataHash.ContainsKey("tags") && parsedMetadataHash["tags"] != null) + { + if (parsedMetadataHash["tags"] is string[]) + { + string[] tagsArr = parsedMetadataHash["tags"] as string[]; + tags += " " + String.Join(" ", tagsArr); + } + else if (parsedMetadataHash["tags"] is string) + { + tags += " " + parsedMetadataHash["tags"].ToString().Trim(); + } + } + + metadataElementsDictionary.Add("tags", tags); + + + // Example nuspec: + /* + + + + System.Management.Automation + 1.0.0 + Microsoft + Microsoft,PowerShell + false + MIT + https://licenses.nuget.org/MIT + Powershell_black_64.png + https://github.com/PowerShell/PowerShell + Example description here + Copyright (c) Microsoft Corporation. All rights reserved. + en-US + PowerShell + + + + + + + + + */ + + foreach (var key in metadataElementsDictionary.Keys) + { + if (metadataElementsDictionary.TryGetValue(key, out string elementInnerText)) + { + XmlElement element = doc.CreateElement(key, nameSpaceUri); + element.InnerText = elementInnerText; + metadataElement.AppendChild(element); + } + else + { + _cmdletPassedIn.WriteVerbose(string.Format("Creating XML element failed. Unable to get value from key '{0}'.", key)); + } + } + + requiredModules = ParseRequiredModules(parsedMetadataHash); + if (requiredModules != null) + { + XmlElement dependenciesElement = doc.CreateElement("dependencies", nameSpaceUri); + + foreach (string dependencyName in requiredModules.Keys) + { + XmlElement element = doc.CreateElement("dependency", nameSpaceUri); + + element.SetAttribute("id", dependencyName); + string dependencyVersion = requiredModules[dependencyName].ToString(); + if (!string.IsNullOrEmpty(dependencyVersion)) + { + element.SetAttribute("version", requiredModules[dependencyName].ToString()); + } + + dependenciesElement.AppendChild(element); + } + metadataElement.AppendChild(dependenciesElement); + } + + packageElement.AppendChild(metadataElement); + doc.AppendChild(packageElement); + + var nuspecFullName = System.IO.Path.Combine(outputDir, _pkgName + ".nuspec"); + doc.Save(nuspecFullName); + + _cmdletPassedIn.WriteVerbose("The newly created nuspec is: " + nuspecFullName); + + return nuspecFullName; + } + + private Hashtable ParseRequiredModules(Hashtable parsedMetadataHash) + { + _cmdletPassedIn.WriteDebug("In PublishHelper::ParseRequiredModules()"); + + if (!parsedMetadataHash.ContainsKey("requiredmodules")) + { + return null; + } + + LanguagePrimitives.TryConvertTo(parsedMetadataHash["requiredmodules"], out object[] requiredModules); + + // Required modules can be: + // a. An array of hash tables of module name and version + // b. A single hash table of module name and version + // c. A string array of module names + // d. A single string module name + + var dependenciesHash = new Hashtable(); + foreach (var reqModule in requiredModules) + { + if (LanguagePrimitives.TryConvertTo(reqModule, out Hashtable moduleHash)) + { + string moduleName = moduleHash["ModuleName"] as string; + + if (moduleHash.ContainsKey("ModuleVersion")) + { + dependenciesHash.Add(moduleName, moduleHash["ModuleVersion"]); + } + else if (moduleHash.ContainsKey("RequiredVersion")) + { + dependenciesHash.Add(moduleName, moduleHash["RequiredVersion"]); + } + else + { + dependenciesHash.Add(moduleName, string.Empty); + } + } + else if (LanguagePrimitives.TryConvertTo(reqModule, out string moduleName)) + { + dependenciesHash.Add(moduleName, string.Empty); + } + } + + var externalModuleDeps = parsedMetadataHash.ContainsKey("ExternalModuleDependencies") ? + parsedMetadataHash["ExternalModuleDependencies"] : null; + + if (externalModuleDeps != null && LanguagePrimitives.TryConvertTo(externalModuleDeps, out string[] externalModuleNames)) + { + foreach (var extModName in externalModuleNames) + { + if (dependenciesHash.ContainsKey(extModName)) + { + dependenciesHash.Remove(extModName); + } + } + } + + return dependenciesHash; + } + + private bool CheckDependenciesExist(Hashtable dependencies, string repositoryName) + { + _cmdletPassedIn.WriteDebug("In PublishHelper::CheckDependenciesExist()"); + + // Check to see that all dependencies are in the repository + // Searches for each dependency in the repository the pkg is being pushed to, + // If the dependency is not there, error + foreach (DictionaryEntry dependency in dependencies) + { + // Need to make individual calls since we're look for exact version numbers or ranges. + var depName = dependency.Key as string; + // test version + string depVersion = dependencies[depName] as string; + depVersion = string.IsNullOrWhiteSpace(depVersion) ? "*" : depVersion; + + if (!Utils.TryGetVersionType( + version: depVersion, + nugetVersion: out NuGetVersion nugetVersion, + versionRange: out VersionRange versionRange, + versionType: out VersionType versionType, + error: out string error)) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException(error), + "IncorrectVersionFormat", + ErrorCategory.InvalidArgument, + this)); + } + + // Search for and return the dependency if it's in the repository. + FindHelper findHelper = new FindHelper(_cancellationToken, _cmdletPassedIn, _networkCredential); + + var repository = new[] { repositoryName }; + // Note: we set prerelease argument for FindByResourceName() to true because if no version is specified we want latest version (including prerelease). + // If version is specified it will get that one. There is also no way to specify a prerelease flag with RequiredModules hashtable of dependency so always try to get latest version. + var dependencyFound = findHelper.FindByResourceName(new string[] { depName }, ResourceType.Module, versionRange, nugetVersion, versionType, depVersion, prerelease: true, tag: null, repository, includeDependencies: false, suppressErrors: true); + if (dependencyFound == null || !dependencyFound.Any()) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"Dependency '{depName}' was not found in repository '{repositoryName}'. Make sure the dependency is published to the repository before {_cmdOperation.ToLower()} this module."), + "DependencyNotFound", + ErrorCategory.ObjectNotFound, + this)); + + return false; + } + + } + + return true; + } + + #endregion + } +} diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index d3ba80d4a..620de3b7e 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -2,21 +2,10 @@ // Licensed under the MIT License. using Microsoft.PowerShell.PSResourceGet.UtilClasses; -using NuGet.Commands; -using NuGet.Common; -using NuGet.Configuration; -using NuGet.Packaging; -using NuGet.Versioning; using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Management.Automation; using System.Net; -using System.Net.Http; using System.Threading; -using System.Xml; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets { @@ -31,6 +20,9 @@ public sealed class PublishPSResource : PSCmdlet { #region Parameters + private const string PathParameterSet = "PathParameterSet"; + private const string NupkgPathParameterSet = "NupkgPathParameterSet"; + /// /// Specifies the API key that you want to use to publish a module to the online gallery. /// @@ -50,7 +42,7 @@ public sealed class PublishPSResource : PSCmdlet /// Specifies the path to the resource that you want to publish. This parameter accepts the path to the folder that contains the resource. /// Specifies a path to one or more locations. Wildcards are permitted. The default location is the current directory (.). /// - [Parameter (Mandatory = true, Position = 0, HelpMessage = "Path to the resource to be published.")] + [Parameter (Mandatory = true, Position = 0, ParameterSetName = PathParameterSet, HelpMessage = "Path to the resource to be published.")] [ValidateNotNullOrEmpty] public string Path { get; set; } @@ -119,24 +111,18 @@ public PSCredential ProxyCredential { } } + [Parameter(Mandatory = true, ParameterSetName = NupkgPathParameterSet, HelpMessage = "Path to the resource to be published.")] + [ValidateNotNullOrEmpty] + public string NupkgPath { get; set; } + #endregion #region Members - private string resolvedPath; private CancellationToken _cancellationToken; - private NuGetVersion _pkgVersion; - private string _pkgName; - private static char[] _PathSeparators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar }; - public const string PSDataFileExt = ".psd1"; - public const string PSScriptFileExt = ".ps1"; - private const string PSScriptInfoCommentString = "<#PSScriptInfo"; - private string pathToScriptFileToPublish = string.Empty; - private string pathToModuleManifestToPublish = string.Empty; - private string pathToModuleDirToPublish = string.Empty; - private ResourceType resourceType = ResourceType.None; private NetworkCredential _networkCredential; - string userAgentString = UserAgentInfo.UserAgentString(); + private bool _isNupkgPathSpecified = false; + private PublishHelper _publishHelper; #endregion @@ -148,1046 +134,45 @@ protected override void BeginProcessing() _networkCredential = Credential != null ? new NetworkCredential(Credential.UserName, Credential.Password) : null; - // Create a respository story (the PSResourceRepository.xml file) if it does not already exist - // This is to create a better experience for those who have just installed v3 and want to get up and running quickly - RepositorySettings.CheckRepositoryStore(); - - try - { - resolvedPath = GetResolvedProviderPathFromPSPath(Path, out ProviderInfo provider).First(); - } - catch (MethodInvocationException) + if (!string.IsNullOrEmpty(NupkgPath)) { - // path does not exist - ThrowTerminatingError(new ErrorRecord( - new ArgumentException("The path to the resource to publish does not exist, point to an existing path or file of the module or script to publish."), - "SourcePathDoesNotExist", - ErrorCategory.InvalidArgument, - this)); + _isNupkgPathSpecified = true; + Path = NupkgPath; } - // Condition 1: path is to the root directory of the module to be published - // Condition 2: path is to the .psd1 or .ps1 of the module/script to be published - if (string.IsNullOrEmpty(resolvedPath)) - { - // unsupported file path - ThrowTerminatingError(new ErrorRecord( - new ArgumentException("The path to the resource to publish is not in the correct format or does not exist. Please provide the path of the root module " + - "(i.e. './/') or the path to the .psd1 (i.e. './/.psd1')."), - "InvalidPublishPath", - ErrorCategory.InvalidArgument, - this)); - } - else if (Directory.Exists(resolvedPath)) - { - pathToModuleDirToPublish = resolvedPath; - resourceType = ResourceType.Module; - } - else if (resolvedPath.EndsWith(PSDataFileExt, StringComparison.OrdinalIgnoreCase)) - { - pathToModuleManifestToPublish = resolvedPath; - resourceType = ResourceType.Module; - } - else if (resolvedPath.EndsWith(PSScriptFileExt, StringComparison.OrdinalIgnoreCase)) - { - pathToScriptFileToPublish = resolvedPath; - resourceType = ResourceType.Script; - } - else { - ThrowTerminatingError(new ErrorRecord( - new ArgumentException($"The publish path provided, '{resolvedPath}', is not a valid. Please provide a path to the root module " + - "(i.e. './/') or path to the .psd1 (i.e. './/.psd1')."), - "InvalidPublishPath", - ErrorCategory.InvalidArgument, - this)); - } - - if (!String.IsNullOrEmpty(DestinationPath)) - { - string resolvedDestinationPath = GetResolvedProviderPathFromPSPath(DestinationPath, out ProviderInfo provider).First(); + // Create a respository story (the PSResourceRepository.xml file) if it does not already exist + // This is to create a better experience for those who have just installed v3 and want to get up and running quickly + RepositorySettings.CheckRepositoryStore(); - if (Directory.Exists(resolvedDestinationPath)) - { - DestinationPath = resolvedDestinationPath; - } - else - { - try - { - Directory.CreateDirectory(resolvedDestinationPath); - } - catch (Exception e) - { - ThrowTerminatingError(new ErrorRecord( - new ArgumentException($"Destination path does not exist and cannot be created: {e.Message}"), - "InvalidDestinationPath", - ErrorCategory.InvalidArgument, - this)); - } - } - } + _publishHelper = new PublishHelper( + this, + Credential, + ApiKey, + Path, + DestinationPath, + SkipModuleManifestValidate, + _cancellationToken, + _isNupkgPathSpecified); + + _publishHelper.CheckAllParameterPaths(); } protected override void EndProcessing() { - // Returns the name of the file or the name of the directory, depending on path - if (!ShouldProcess(string.Format("Publish resource '{0}' from the machine", resolvedPath))) - { - WriteVerbose("ShouldProcess is set to false."); - return; - } - - Hashtable parsedMetadata = new Hashtable(StringComparer.OrdinalIgnoreCase); - if (resourceType == ResourceType.Script) - { - if (!PSScriptFileInfo.TryTestPSScriptFileInfo( - scriptFileInfoPath: pathToScriptFileToPublish, - parsedScript: out PSScriptFileInfo scriptToPublish, - out ErrorRecord[] errors, - out string[] _ - )) - { - foreach (ErrorRecord error in errors) - { - WriteError(error); - } - - return; - } - - parsedMetadata = scriptToPublish.ToHashtable(); - - _pkgName = System.IO.Path.GetFileNameWithoutExtension(pathToScriptFileToPublish); - } - else - { - if (!string.IsNullOrEmpty(pathToModuleManifestToPublish)) - { - _pkgName = System.IO.Path.GetFileNameWithoutExtension(pathToModuleManifestToPublish); - } - else { - // Search for module manifest - foreach (FileInfo file in new DirectoryInfo(pathToModuleDirToPublish).EnumerateFiles()) - { - if (file.Name.EndsWith(PSDataFileExt, StringComparison.OrdinalIgnoreCase)) - { - pathToModuleManifestToPublish = file.FullName; - _pkgName = System.IO.Path.GetFileNameWithoutExtension(file.Name); - - break; - } - } - } - - // Validate that there's a module manifest - if (!File.Exists(pathToModuleManifestToPublish)) - { - WriteError(new ErrorRecord( - new ArgumentException($"No file with a .psd1 extension was found in '{pathToModuleManifestToPublish}'. Please specify a path to a valid module manifest."), - "moduleManifestNotFound", - ErrorCategory.ObjectNotFound, - this)); - - return; - } - - // The Test-ModuleManifest currently cannot process UNC paths. Disabling verification for now. - if ((new Uri(pathToModuleManifestToPublish)).IsUnc) - SkipModuleManifestValidate = true; - // Validate that the module manifest has correct data - if (! SkipModuleManifestValidate && - ! Utils.ValidateModuleManifest(pathToModuleManifestToPublish, out string errorMsg)) - { - ThrowTerminatingError(new ErrorRecord( - new PSInvalidOperationException(errorMsg), - "InvalidModuleManifest", - ErrorCategory.InvalidOperation, - this)); - } - - if (!Utils.TryReadManifestFile( - manifestFilePath: pathToModuleManifestToPublish, - manifestInfo: out parsedMetadata, - error: out Exception manifestReadError)) - { - WriteError(new ErrorRecord( - manifestReadError, - "ManifestFileReadParseForContainerRegistryPublishError", - ErrorCategory.ReadError, - this)); - - return; - } - } - - // Create a temp folder to push the nupkg to and delete it later - string outputDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); - try - { - Directory.CreateDirectory(outputDir); - } - catch (Exception e) - { - WriteError(new ErrorRecord( - new ArgumentException(e.Message), - "ErrorCreatingTempDir", - ErrorCategory.InvalidData, - this)); - - return; - } - - try - { - // Create a nuspec - Hashtable dependencies; - string nuspec = string.Empty; - try - { - nuspec = CreateNuspec( - outputDir: outputDir, - filePath: (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish, - parsedMetadataHash: parsedMetadata, - requiredModules: out dependencies); - } - catch (Exception e) - { - WriteError(new ErrorRecord( - new ArgumentException($"Nuspec creation failed: {e.Message}"), - "NuspecCreationFailed", - ErrorCategory.ObjectNotFound, - this)); - - return; - } - - if (string.IsNullOrEmpty(nuspec)) - { - // nuspec creation failed. - WriteVerbose("Nuspec creation failed."); - return; - } - - // Find repository - PSRepositoryInfo repository = RepositorySettings.Read(new[] { Repository }, out string[] _).FirstOrDefault(); - if (repository == null) - { - WriteError(new ErrorRecord( - new ArgumentException($"The resource repository '{Repository}' is not a registered. Please run 'Register-PSResourceRepository' in order to publish to this repository."), - "RepositoryNotFound", - ErrorCategory.ObjectNotFound, - this)); - - return; - } - else if(repository.Uri.Scheme == Uri.UriSchemeFile && !repository.Uri.IsUnc && !Directory.Exists(repository.Uri.LocalPath)) - { - // this check to ensure valid local path is not for UNC paths (which are server based, instead of Drive based) - WriteError(new ErrorRecord( - new ArgumentException($"The repository '{repository.Name}' with uri: '{repository.Uri.AbsoluteUri}' is not a valid folder path which exists. If providing a file based repository, provide a repository with a path that exists."), - "repositoryPathDoesNotExist", - ErrorCategory.ObjectNotFound, - this)); - - return; - } - - _networkCredential = Utils.SetNetworkCredential(repository, _networkCredential, this); - - // Check if dependencies already exist within the repo if: - // 1) the resource to publish has dependencies and - // 2) the -SkipDependenciesCheck flag is not passed in - if (dependencies != null && !SkipDependenciesCheck) - { - // If error gets thrown, exit process record - if (!CheckDependenciesExist(dependencies, repository.Name)) - { - return; - } - } - - if (resourceType == ResourceType.Script) - { - // copy the script file to the temp directory - File.Copy(pathToScriptFileToPublish, System.IO.Path.Combine(outputDir, _pkgName + PSScriptFileExt), true); - } - else - { - try - { - // If path is pointing to a file, get the parent directory, otherwise assumption is that path is pointing to the root directory - string rootModuleDir = !string.IsNullOrEmpty(pathToModuleManifestToPublish) ? System.IO.Path.GetDirectoryName(pathToModuleManifestToPublish) : pathToModuleDirToPublish; - - // Create subdirectory structure in temp folder - foreach (string dir in Directory.GetDirectories(rootModuleDir, "*", SearchOption.AllDirectories)) - { - var dirName = dir.Substring(rootModuleDir.Length).Trim(_PathSeparators); - Directory.CreateDirectory(System.IO.Path.Combine(outputDir, dirName)); - } - - // Copy files over to temp folder - foreach (string fileNamePath in Directory.GetFiles(rootModuleDir, "*", SearchOption.AllDirectories)) - { - var fileName = fileNamePath.Substring(rootModuleDir.Length).Trim(_PathSeparators); - var newFilePath = System.IO.Path.Combine(outputDir, fileName); - - // The user may have a .nuspec defined in the module directory - // If that's the case, we will not use that file and use the .nuspec that is generated via PSGet - // The .nuspec that is already in in the output directory is the one that was generated via the CreateNuspec method - if (!File.Exists(newFilePath)) - { - File.Copy(fileNamePath, newFilePath); - } - } - } - catch (Exception e) - { - ThrowTerminatingError(new ErrorRecord( - new ArgumentException("Error occured while creating directory to publish: " + e.Message), - "ErrorCreatingDirectoryToPublish", - ErrorCategory.InvalidOperation, - this)); - } - } - - var outputNupkgDir = System.IO.Path.Combine(outputDir, "nupkg"); - - // pack into .nupkg - if (!PackNupkg(outputDir, outputNupkgDir, nuspec, out ErrorRecord packNupkgError)) - { - WriteError(packNupkgError); - // exit out of processing - return; - } - - // If -DestinationPath is specified then also publish the .nupkg there - if (!string.IsNullOrWhiteSpace(DestinationPath)) - { - if (!Directory.Exists(DestinationPath)) - { - WriteError(new ErrorRecord( - new ArgumentException($"Destination path does not exist: '{DestinationPath}'"), - "InvalidDestinationPath", - ErrorCategory.InvalidArgument, - this)); - - return; - } - - try - { - var nupkgName = _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg"; - File.Copy(System.IO.Path.Combine(outputNupkgDir, nupkgName), System.IO.Path.Combine(DestinationPath, nupkgName)); - } - catch (Exception e) - { - WriteError(new ErrorRecord( - new ArgumentException($"Error moving .nupkg into destination path '{DestinationPath}' due to: '{e.Message}'."), - "ErrorMovingNupkg", - ErrorCategory.NotSpecified, - this)); - - // exit process record - return; - } - } - - string repositoryUri = repository.Uri.AbsoluteUri; - - if (repository.ApiVersion == PSRepositoryInfo.APIVersion.ContainerRegistry) - { - ContainerRegistryServerAPICalls containerRegistryServer = new ContainerRegistryServerAPICalls(repository, this, _networkCredential, userAgentString); - - var pkgMetadataFile = (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish; - if (!containerRegistryServer.PushNupkgContainerRegistry(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgContainerRegistryError)) - { - WriteError(pushNupkgContainerRegistryError); - // exit out of processing - return; - } - } - else - { - // This call does not throw any exceptions, but it will write unsuccessful responses to the console - if (!PushNupkg(outputNupkgDir, repository.Name, repository.Uri.ToString(), out ErrorRecord pushNupkgError)) - { - WriteError(pushNupkgError); - // exit out of processing - return; - } - } - } - catch (Exception e) - { - ThrowTerminatingError(new ErrorRecord( - e, - "PublishPSResourceError", - ErrorCategory.NotSpecified, - this)); - } - finally - { - WriteVerbose(string.Format("Deleting temporary directory '{0}'", outputDir)); - - Utils.DeleteDirectory(outputDir); - } - - } - #endregion - - #region Private methods - - private string CreateNuspec( - string outputDir, - string filePath, - Hashtable parsedMetadataHash, - out Hashtable requiredModules) - { - WriteDebug("In PublishPSResource::CreateNuspec()"); - bool isModule = resourceType != ResourceType.Script; - requiredModules = new Hashtable(); - - if (parsedMetadataHash == null || parsedMetadataHash.Count == 0) - { - WriteError(new ErrorRecord(new ArgumentException("Hashtable provided with package metadata was null or empty"), - "PackageMetadataHashtableNullOrEmptyError", - ErrorCategory.ReadError, - this)); - - return string.Empty; - } - - // now we have parsedMetadatahash to fill out the nuspec information - var nameSpaceUri = "http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd"; - var doc = new XmlDocument(); - - // xml declaration is recommended, but not mandatory - XmlDeclaration xmlDeclaration = doc.CreateXmlDeclaration("1.0", "utf-8", null); - XmlElement root = doc.DocumentElement; - doc.InsertBefore(xmlDeclaration, root); - - // create top-level elements - XmlElement packageElement = doc.CreateElement("package", nameSpaceUri); - XmlElement metadataElement = doc.CreateElement("metadata", nameSpaceUri); - - Dictionary metadataElementsDictionary = new Dictionary(); - - // id is mandatory - metadataElementsDictionary.Add("id", _pkgName); - - string version; - if (parsedMetadataHash.ContainsKey("moduleversion")) - { - version = parsedMetadataHash["moduleversion"].ToString(); - } - else if (parsedMetadataHash.ContainsKey("version")) - { - version = parsedMetadataHash["version"].ToString(); - } - else - { - // no version is specified for the nuspec - WriteError(new ErrorRecord( - new ArgumentException("There is no package version specified. Please specify a version before publishing."), - "NoVersionFound", - ErrorCategory.InvalidArgument, - this)); - - return string.Empty; - } - - // Look for Prerelease tag and then process any Tags in PrivateData > PSData - if (isModule) - { - if (parsedMetadataHash.ContainsKey("PrivateData")) - { - if (parsedMetadataHash["PrivateData"] is Hashtable privateData && - privateData.ContainsKey("PSData")) - { - if (privateData["PSData"] is Hashtable psData) - { - if (psData.ContainsKey("prerelease") && psData["prerelease"] is string preReleaseVersion) - { - if (!string.IsNullOrEmpty(preReleaseVersion)) - { - version = string.Format(@"{0}-{1}", version, preReleaseVersion); - } - } - - if (psData.ContainsKey("licenseuri") && psData["licenseuri"] is string licenseUri) - - { - metadataElementsDictionary.Add("licenseUrl", licenseUri.Trim()); - } - - if (psData.ContainsKey("projecturi") && psData["projecturi"] is string projectUri) - { - metadataElementsDictionary.Add("projectUrl", projectUri.Trim()); - } - - if (psData.ContainsKey("iconuri") && psData["iconuri"] is string iconUri) - { - metadataElementsDictionary.Add("iconUrl", iconUri.Trim()); - } - - if (psData.ContainsKey("releasenotes")) - { - if (psData["releasenotes"] is string releaseNotes) - { - metadataElementsDictionary.Add("releaseNotes", releaseNotes.Trim()); - } - else if (psData["releasenotes"] is string[] releaseNotesArr) - { - metadataElementsDictionary.Add("releaseNotes", string.Join("\n", releaseNotesArr)); - } - } - - // defaults to false - // Value for requireAcceptLicense key needs to be a lowercase string representation of the boolean for it to be correctly parsed from psData file. - - string requireLicenseAcceptance = psData.ContainsKey("requirelicenseacceptance") ? psData["requirelicenseacceptance"].ToString().ToLower() : "false"; - - metadataElementsDictionary.Add("requireLicenseAcceptance", requireLicenseAcceptance); - - - if (psData.ContainsKey("Tags") && psData["Tags"] is Array manifestTags) - { - var tagArr = new List(); - foreach (string tag in manifestTags) - { - tagArr.Add(tag); - } - parsedMetadataHash["tags"] = string.Join(" ", tagArr.ToArray()); - } - } - } - } - } - else - { - if (parsedMetadataHash.ContainsKey("licenseuri") && parsedMetadataHash["licenseuri"] is Uri licenseUri) - - { - metadataElementsDictionary.Add("licenseUrl", licenseUri.ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("projecturi") && parsedMetadataHash["projecturi"] is Uri projectUri) - { - metadataElementsDictionary.Add("projectUrl", projectUri.ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("iconuri") && parsedMetadataHash["iconuri"] is Uri iconUri) - { - metadataElementsDictionary.Add("iconUrl", iconUri.ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("releaseNotes")) - { - if (parsedMetadataHash["releasenotes"] is string releaseNotes) - { - metadataElementsDictionary.Add("releaseNotes", releaseNotes.Trim()); - } - else if (parsedMetadataHash["releasenotes"] is string[] releaseNotesArr) - { - metadataElementsDictionary.Add("releaseNotes", string.Join("\n", releaseNotesArr)); - } - } - } - - - if (NuGetVersion.TryParse(version, out _pkgVersion)) - { - metadataElementsDictionary.Add("version", _pkgVersion.ToNormalizedString()); - } - - if (parsedMetadataHash.ContainsKey("author")) - { - metadataElementsDictionary.Add("authors", parsedMetadataHash["author"].ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("companyname")) - { - metadataElementsDictionary.Add("owners", parsedMetadataHash["companyname"].ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("description")) - { - metadataElementsDictionary.Add("description", parsedMetadataHash["description"].ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("copyright")) - { - metadataElementsDictionary.Add("copyright", parsedMetadataHash["copyright"].ToString().Trim()); - } - - string tags = (resourceType == ResourceType.Script) ? "PSScript" : "PSModule"; - if (parsedMetadataHash.ContainsKey("tags") && parsedMetadataHash["tags"] != null) - { - if (parsedMetadataHash["tags"] is string[]) - { - string[] tagsArr = parsedMetadataHash["tags"] as string[]; - tags += " " + String.Join(" ", tagsArr); - } - else if (parsedMetadataHash["tags"] is string) - { - tags += " " + parsedMetadataHash["tags"].ToString().Trim(); - } - } - - metadataElementsDictionary.Add("tags", tags); - - - // Example nuspec: - /* - - - - System.Management.Automation - 1.0.0 - Microsoft - Microsoft,PowerShell - false - MIT - https://licenses.nuget.org/MIT - Powershell_black_64.png - https://github.com/PowerShell/PowerShell - Example description here - Copyright (c) Microsoft Corporation. All rights reserved. - en-US - PowerShell - - - - - - - - - */ - - foreach (var key in metadataElementsDictionary.Keys) - { - if (metadataElementsDictionary.TryGetValue(key, out string elementInnerText)) - { - XmlElement element = doc.CreateElement(key, nameSpaceUri); - element.InnerText = elementInnerText; - metadataElement.AppendChild(element); - } - else { - WriteVerbose(string.Format("Creating XML element failed. Unable to get value from key '{0}'.", key)); - } - } - - requiredModules = ParseRequiredModules(parsedMetadataHash); - if (requiredModules != null) - { - XmlElement dependenciesElement = doc.CreateElement("dependencies", nameSpaceUri); - - foreach (string dependencyName in requiredModules.Keys) - { - XmlElement element = doc.CreateElement("dependency", nameSpaceUri); - - element.SetAttribute("id", dependencyName); - string dependencyVersion = requiredModules[dependencyName].ToString(); - if (!string.IsNullOrEmpty(dependencyVersion)) - { - element.SetAttribute("version", requiredModules[dependencyName].ToString()); - } - - dependenciesElement.AppendChild(element); - } - metadataElement.AppendChild(dependenciesElement); - } - - packageElement.AppendChild(metadataElement); - doc.AppendChild(packageElement); - - var nuspecFullName = System.IO.Path.Combine(outputDir, _pkgName + ".nuspec"); - doc.Save(nuspecFullName); - - WriteVerbose("The newly created nuspec is: " + nuspecFullName); - - return nuspecFullName; - } - - private Hashtable ParseRequiredModules(Hashtable parsedMetadataHash) - { - WriteDebug("In PublishPSResource::ParseRequiredModules()"); - if (!parsedMetadataHash.ContainsKey("requiredmodules")) - { - return null; - } - LanguagePrimitives.TryConvertTo(parsedMetadataHash["requiredmodules"], out object[] requiredModules); - - // Required modules can be: - // a. An array of hash tables of module name and version - // b. A single hash table of module name and version - // c. A string array of module names - // d. A single string module name - - var dependenciesHash = new Hashtable(); - foreach (var reqModule in requiredModules) - { - if (LanguagePrimitives.TryConvertTo(reqModule, out Hashtable moduleHash)) - { - string moduleName = moduleHash["ModuleName"] as string; - - if (moduleHash.ContainsKey("ModuleVersion")) - { - dependenciesHash.Add(moduleName, moduleHash["ModuleVersion"]); - } - else if (moduleHash.ContainsKey("RequiredVersion")) - { - dependenciesHash.Add(moduleName, moduleHash["RequiredVersion"]); - } - else { - dependenciesHash.Add(moduleName, string.Empty); - } - } - else if (LanguagePrimitives.TryConvertTo(reqModule, out string moduleName)) - { - dependenciesHash.Add(moduleName, string.Empty); - } - } - var externalModuleDeps = parsedMetadataHash.ContainsKey("ExternalModuleDependencies") ? - parsedMetadataHash["ExternalModuleDependencies"] : null; - - if (externalModuleDeps != null && LanguagePrimitives.TryConvertTo(externalModuleDeps, out string[] externalModuleNames)) - { - foreach (var extModName in externalModuleNames) - { - if (dependenciesHash.ContainsKey(extModName)) - { - dependenciesHash.Remove(extModName); - } - } - } - - return dependenciesHash; - } - - private bool CheckDependenciesExist(Hashtable dependencies, string repositoryName) - { - WriteDebug("In PublishPSResource::CheckDependenciesExist()"); - // Check to see that all dependencies are in the repository - // Searches for each dependency in the repository the pkg is being pushed to, - // If the dependency is not there, error - foreach (DictionaryEntry dependency in dependencies) + if (!_isNupkgPathSpecified) { - // Need to make individual calls since we're look for exact version numbers or ranges. - var depName = dependency.Key as string; - // test version - string depVersion = dependencies[depName] as string; - depVersion = string.IsNullOrWhiteSpace(depVersion) ? "*" : depVersion; - - if (!Utils.TryGetVersionType( - version: depVersion, - nugetVersion: out NuGetVersion nugetVersion, - versionRange: out VersionRange versionRange, - versionType: out VersionType versionType, - error: out string error)) - { - ThrowTerminatingError(new ErrorRecord( - new ArgumentException(error), - "IncorrectVersionFormat", - ErrorCategory.InvalidArgument, - this)); - } - - // Search for and return the dependency if it's in the repository. - FindHelper findHelper = new FindHelper(_cancellationToken, this, _networkCredential); - - var repository = new[] { repositoryName }; - // Note: we set prerelease argument for FindByResourceName() to true because if no version is specified we want latest version (including prerelease). - // If version is specified it will get that one. There is also no way to specify a prerelease flag with RequiredModules hashtable of dependency so always try to get latest version. - var dependencyFound = findHelper.FindByResourceName(new string[] { depName }, ResourceType.Module, versionRange, nugetVersion, versionType, depVersion, prerelease: true, tag: null, repository, includeDependencies: false, suppressErrors: true); - if (dependencyFound == null || !dependencyFound.Any()) - { - WriteError(new ErrorRecord( - new ArgumentException($"Dependency '{depName}' was not found in repository '{repositoryName}'. Make sure the dependency is published to the repository before publishing this module."), - "DependencyNotFound", - ErrorCategory.ObjectNotFound, - this)); - - return false; - } + _publishHelper.PackResource(); } - return true; - } - - private bool PackNupkg(string outputDir, string outputNupkgDir, string nuspecFile, out ErrorRecord error) - { - WriteDebug("In PublishPSResource::PackNupkg()"); - // Pack the module or script into a nupkg given a nuspec. - var builder = new PackageBuilder(); - try - { - var runner = new PackCommandRunner( - new PackArgs - { - CurrentDirectory = outputDir, - OutputDirectory = outputNupkgDir, - Path = nuspecFile, - Exclude = System.Array.Empty(), - Symbols = false, - Logger = NullLogger.Instance - }, - MSBuildProjectFactory.ProjectCreator, - builder); - bool success = runner.RunPackageBuild(); - - if (success) - { - WriteVerbose("Successfully packed the resource into a .nupkg"); - } - else - { - error = new ErrorRecord( - new InvalidOperationException("Not able to successfully pack the resource into a .nupkg"), - "failedToPackIntoNupkg", - ErrorCategory.ObjectNotFound, - this); - return false; - } - } - catch (Exception e) - { - error = new ErrorRecord( - new ArgumentException($"Unexpected error packing into .nupkg: '{e.Message}'."), - "ErrorPackingIntoNupkg", - ErrorCategory.NotSpecified, - this); - - // exit process record - return false; - } - - error = null; - return true; - } - - private bool PushNupkg(string outputNupkgDir, string repoName, string repoUri, out ErrorRecord error) - { - WriteDebug("In PublishPSResource::PushNupkg()"); - // Push the nupkg to the appropriate repository - // Pkg version is parsed from .ps1 file or .psd1 file - var fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg"); - - // The PSGallery uses the v2 protocol still and publishes to a slightly different endpoint: - // "https://www.powershellgallery.com/api/v2/package" - // Until the PSGallery is moved onto the NuGet v3 server protocol, we'll modify the repository uri - // to accommodate for the approprate publish location. - string publishLocation = repoUri.EndsWith("/v2", StringComparison.OrdinalIgnoreCase) ? repoUri + "/package" : repoUri; - - var settings = NuGet.Configuration.Settings.LoadDefaultSettings(null, null, null); - var success = false; - - var sourceProvider = new PackageSourceProvider(settings); - if (Credential != null || _networkCredential != null) - { - InjectCredentialsToSettings(settings, sourceProvider, publishLocation); - } - - - try - { - PushRunner.Run( - settings: Settings.LoadDefaultSettings(root: null, configFileName: null, machineWideSettings: null), - sourceProvider: sourceProvider, - packagePaths: new List { fullNupkgFile }, - source: publishLocation, - apiKey: ApiKey, - symbolSource: null, - symbolApiKey: null, - timeoutSeconds: 0, - disableBuffering: false, - noSymbols: false, - noServiceEndpoint: false, // enable server endpoint - skipDuplicate: false, // if true-- if a package and version already exists, skip it and continue with the next package in the push, if any. - logger: NullLogger.Instance // nuget logger - ).GetAwaiter().GetResult(); - } - catch (HttpRequestException e) - { - WriteVerbose(string.Format("Not able to publish resource to '{0}'", repoUri)); - // look in PS repo for how httpRequestExceptions are handled - - // Unfortunately there is no response message are no status codes provided with the exception and no - var ex = new ArgumentException(String.Format("Repository '{0}': {1}", repoName, e.Message)); - if (e.Message.Contains("400")) - { - if (e.Message.Contains("Api")) - { - // For ADO repositories, public and private, when ApiKey is not provided. - error = new ErrorRecord( - new ArgumentException($"Repository '{repoName}': Please try running again with the -ApiKey parameter and specific API key for the repository specified. For Azure Devops repository, set this to an arbitrary value, for example '-ApiKey AzureDevOps'"), - "400ApiKeyError", - ErrorCategory.AuthenticationError, - this); - } - else - { - error = new ErrorRecord( - ex, - "400Error", - ErrorCategory.PermissionDenied, - this); - } - } - else if (e.Message.Contains("401")) - { - if (e.Message.Contains("API")) - { - // For PSGallery when ApiKey is not provided. - error = new ErrorRecord( - new ArgumentException($"Could not publish to repository '{repoName}'. Please try running again with the -ApiKey parameter and the API key for the repository specified. Exception: '{e.Message}'"), - "401ApiKeyError", - ErrorCategory.AuthenticationError, - this); - } - else - { - // For ADO repository feeds that are public feeds, when the credentials are incorrect. - error = new ErrorRecord(new ArgumentException($"Could not publish to repository '{repoName}'. The Credential provided was incorrect. Exception: '{e.Message}'"), - "401Error", - ErrorCategory.PermissionDenied, - this); ; - } - } - else if (e.Message.Contains("403")) - { - if (repoUri.Contains("myget.org")) - { - // For myGet.org repository feeds when the ApiKey is missing or incorrect. - error = new ErrorRecord( - new ArgumentException($"Could not publish to repository '{repoName}'. The ApiKey provided is incorrect or missing. Please try running again with the -ApiKey parameter and correct API key value for the repository. Exception: '{e.Message}'"), - "403Error", - ErrorCategory.PermissionDenied, - this); - } - else if (repoUri.Contains(".jfrog.io")) - { - // For JFrog Artifactory repository feeds when the ApiKey is provided, whether correct or incorrect, as JFrog does not require -ApiKey (but does require ApiKey to be present as password to -Credential). - error = new ErrorRecord( - new ArgumentException($"Could not publish to repository '{repoName}'. The ApiKey provided is not needed for JFrog Artifactory. Please try running again without the -ApiKey parameter but ensure that -Credential is provided with ApiKey as password. Exception: '{e.Message}'"), - "403Error", - ErrorCategory.PermissionDenied, - this); - } - else - { - error = new ErrorRecord( - ex, - "403Error", - ErrorCategory.PermissionDenied, - this); - } - } - else if (e.Message.Contains("409")) - { - error = new ErrorRecord( - ex, - "409Error", - ErrorCategory.PermissionDenied, this); - } - else - { - error = new ErrorRecord( - ex, - "HTTPRequestError", - ErrorCategory.PermissionDenied, - this); - } - - return success; - } - catch (NuGet.Protocol.Core.Types.FatalProtocolException e) - { - // for ADO repository feeds that are private feeds the error thrown is different and the 401 is in the inner exception message - if (e.InnerException.Message.Contains("401")) - { - error = new ErrorRecord( - new ArgumentException($"Could not publish to repository '{repoName}'. The Credential provided was incorrect. Exception '{e.InnerException.Message}'"), - "401FatalProtocolError", - ErrorCategory.AuthenticationError, - this); - } - else - { - error = new ErrorRecord( - new ArgumentException($"Repository '{repoName}': {e.InnerException.Message}"), - "ProtocolFailError", - ErrorCategory.ProtocolError, - this); - } - - return success; - } - catch (Exception e) - { - WriteVerbose($"Not able to publish resource to '{repoUri}'"); - error = new ErrorRecord( - new ArgumentException(e.Message), - "PushNupkgError", - ErrorCategory.InvalidResult, - this); - - return success; - } - - - WriteVerbose(string.Format("Successfully published the resource to '{0}'", repoUri)); - error = null; - success = true; - - return success; - } - - private void InjectCredentialsToSettings(ISettings settings, IPackageSourceProvider sourceProvider, string source) - { - WriteDebug("In PublishPSResource::InjectCredentialsToSettings()"); - if (Credential == null && _networkCredential == null) + if (_publishHelper.ScriptError || !_publishHelper.ShouldProcess) { return; } - var packageSource = sourceProvider.LoadPackageSources().FirstOrDefault(s => s.Source == source); - if (packageSource != null) - { - if (!packageSource.IsEnabled) - { - packageSource.IsEnabled = true; - } - } - - - var networkCred = Credential == null ? _networkCredential : Credential.GetNetworkCredential(); - string key; - - if (packageSource == null) - - { - key = "_" + Guid.NewGuid().ToString().Replace("-", ""); - settings.AddOrUpdate( - ConfigurationConstants.PackageSources, - new SourceItem(key, source)); - } - else - { - key = packageSource.Name; - } - - settings.AddOrUpdate( - ConfigurationConstants.CredentialsSectionName, - new CredentialsItem( - key, - networkCred.UserName, - networkCred.Password, - isPasswordClearText: true, - String.Empty)); + _publishHelper.PushResource(Repository, SkipDependenciesCheck, _networkCredential); } #endregion + } } diff --git a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 new file mode 100644 index 000000000..cdccee7f6 --- /dev/null +++ b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 @@ -0,0 +1,179 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$modPath = "$psscriptroot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +$testDir = (get-item $psscriptroot).parent.FullName + +function CreateTestModule +{ + param ( + [string] $Path = "$TestDrive", + [string] $ModuleName = 'TestModule' + ) + + $modulePath = Join-Path -Path $Path -ChildPath $ModuleName + $moduleMan = Join-Path $modulePath -ChildPath ($ModuleName + '.psd1') + $moduleSrc = Join-Path $modulePath -ChildPath ($ModuleName + '.psm1') + + if ( Test-Path -Path $modulePath) { + Remove-Item -Path $modulePath -Recurse -Force + } + + $null = New-Item -Path $modulePath -ItemType Directory -Force + + @' + @{{ + RootModule = "{0}.psm1" + ModuleVersion = '1.0.0' + Author = 'None' + Description = 'None' + GUID = '0c2829fc-b165-4d72-9038-ae3a71a755c1' + FunctionsToExport = @('Test1') + RequiredModules = @('NonExistentModule') + }} +'@ -f $ModuleName | Out-File -FilePath $moduleMan + + @' + function Test1 { + Write-Output 'Hello from Test1' + } +'@ | Out-File -FilePath $moduleSrc +} + +Describe "Test Compress-PSResource" -tags 'CI' { + BeforeAll { + Get-NewPSResourceRepositoryFile + + # Register temporary repositories + $tmpRepoPath = Join-Path -Path $TestDrive -ChildPath "tmpRepoPath" + New-Item $tmpRepoPath -Itemtype directory -Force + $testRepository = "testRepository" + Register-PSResourceRepository -Name $testRepository -Uri $tmpRepoPath -Priority 1 -ErrorAction SilentlyContinue + $script:repositoryPath = [IO.Path]::GetFullPath((get-psresourcerepository "testRepository").Uri.AbsolutePath) + + $tmpRepoPath2 = Join-Path -Path $TestDrive -ChildPath "tmpRepoPath2" + New-Item $tmpRepoPath2 -Itemtype directory -Force + $testRepository2 = "testRepository2" + Register-PSResourceRepository -Name $testRepository2 -Uri $tmpRepoPath2 -ErrorAction SilentlyContinue + $script:repositoryPath2 = [IO.Path]::GetFullPath((get-psresourcerepository "testRepository2").Uri.AbsolutePath) + + # Create module + $script:tmpModulesPath = Join-Path -Path $TestDrive -ChildPath "tmpModulesPath" + $script:PublishModuleName = "PSGetTestModule" + $script:PublishModuleBase = Join-Path $script:tmpModulesPath -ChildPath $script:PublishModuleName + if(!(Test-Path $script:PublishModuleBase)) + { + New-Item -Path $script:PublishModuleBase -ItemType Directory -Force + } + $script:PublishModuleBaseUNC = $script:PublishModuleBase -Replace '^(.):', '\\localhost\$1$' + + #Create dependency module + $script:DependencyModuleName = "PackageManagement" + $script:DependencyModuleBase = Join-Path $script:tmpModulesPath -ChildPath $script:DependencyModuleName + if(!(Test-Path $script:DependencyModuleBase)) + { + New-Item -Path $script:DependencyModuleBase -ItemType Directory -Force + } + + # Create temp destination path + $script:destinationPath = [IO.Path]::GetFullPath((Join-Path -Path $TestDrive -ChildPath "tmpDestinationPath")) + New-Item $script:destinationPath -ItemType directory -Force + + #Create folder where we shall place all script files to be published for these tests + $script:tmpScriptsFolderPath = Join-Path -Path $TestDrive -ChildPath "tmpScriptsPath" + if(!(Test-Path $script:tmpScriptsFolderPath)) + { + New-Item -Path $script:tmpScriptsFolderPath -ItemType Directory -Force + } + + # Path to folder, within our test folder, where we store invalid module and script files used for testing + $script:testFilesFolderPath = Join-Path $testDir -ChildPath "testFiles" + + # Path to specifically to that invalid test modules folder + $script:testModulesFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testModules" + + # Path to specifically to that invalid test scripts folder + $script:testScriptsFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testScripts" + + # Create test module with missing required module + CreateTestModule -Path $TestDrive -ModuleName 'ModuleWithMissingRequiredModule' + } + AfterAll { + Get-RevertPSResourceRepositoryFile + } + AfterEach { + # Delete all contents of the repository without deleting the repository directory itself + $pkgsToDelete = Join-Path -Path "$script:repositoryPath" -ChildPath "*" + Remove-Item $pkgsToDelete -Recurse + + $pkgsToDelete = Join-Path -Path "$script:repositoryPath2" -ChildPath "*" + Remove-Item $pkgsToDelete -Recurse + + $pkgsToDelete = Join-Path -Path $script:PublishModuleBase -ChildPath "*" + Remove-Item $pkgsToDelete -Recurse -ErrorAction SilentlyContinue + } + + It "Compress-PSResource compresses a module into a nupkg and saves it to the DestinationPath" { + $version = "1.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:repositoryPath + + $expectedPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.nupkg" + (Get-ChildItem $script:repositoryPath).FullName | Should -Be $expectedPath + } + + It "Compress a module using -Path positional parameter and -Destination positional parameter" { + $version = "1.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Compress-PSResource $script:PublishModuleBase $script:repositoryPath + + $expectedPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.nupkg" + (Get-ChildItem $script:repositoryPath).FullName | Should -Be $expectedPath + } + + It "Compress-PSResource compresses a module and preserves file structure" { + $version = "1.0.0" + $testFile = Join-Path -Path "TestSubDirectory" -ChildPath "TestSubDirFile.ps1" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + New-Item -Path (Join-Path -Path $script:PublishModuleBase -ChildPath $testFile) -Force + + Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:repositoryPath + + # Must change .nupkg to .zip so that Expand-Archive can work on Windows PowerShell + $nupkgPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.nupkg" + $zipPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.zip" + Rename-Item -Path $nupkgPath -NewName $zipPath + $unzippedPath = Join-Path -Path $TestDrive -ChildPath "$script:PublishModuleName" + New-Item $unzippedPath -Itemtype directory -Force + Expand-Archive -Path $zipPath -DestinationPath $unzippedPath + + Test-Path -Path (Join-Path -Path $unzippedPath -ChildPath $testFile) | Should -Be $True + } +<# Test for Signing the nupkg. Signing doesn't work + It "Compressed Module is able to be signed with a certificate" { + $version = "1.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:repositoryPath2 + + $expectedPath = Join-Path -Path $script:repositoryPath2 -ChildPath "$script:PublishModuleName.$version.nupkg" + (Get-ChildItem $script:repositoryPath2).FullName | Should -Be $expectedPath + + # create test cert + # Create a self-signed certificate for code signing + $testCert = New-SelfSignedCertificate -Subject "CN=NuGet Test Developer, OU=Use for testing purposes ONLY" -FriendlyName "NuGetTestDeveloper" -Type CodeSigning -KeyUsage DigitalSignature -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256 -Provider "Microsoft Enhanced RSA and AES Cryptographic Provider" -CertStoreLocation "Cert:\CurrentUser\My" + + # sign the nupkg + $nupkgPath = Join-Path -Path $script:repositoryPath2 -ChildPath "$script:PublishModuleName.$version.nupkg" + Set-AuthenticodeSignature -FilePath $nupkgPath -Certificate $testCert + + # Verify the file was signed + $signature = Get-AuthenticodeSignature -FilePath $nupkgPath + $signature.Status | Should -Be 'Valid' + } + #> +} diff --git a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 index 0decd5011..1b2a70d84 100644 --- a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 @@ -290,7 +290,6 @@ Describe "Test Publish-PSResource" -tags 'CI' { {Publish-PSResource -Path $script:PublishModuleBase -ErrorAction Stop} | Should -Throw -ErrorId "DependencyNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" } - It "Publish a module with -SkipDependenciesCheck" { $version = "1.0.0" $dependencyVersion = "2.0.0" @@ -321,8 +320,23 @@ Describe "Test Publish-PSResource" -tags 'CI' { Test-Path -Path (Join-Path -Path $unzippedPath -ChildPath $testFile) | Should -Be $True } + It "Publish a module with -NupkgPath" { + $version = "1.0.0" + # Make a nupkg + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:destinationPath + $expectedPath = Join-Path -Path $script:destinationPath -ChildPath "$script:PublishModuleName.$version.nupkg" + (Get-ChildItem $script:destinationPath).FullName | Should -Be $expectedPath + + # Pass the nupkg via -NupkgPath + Publish-PSResource -NupkgPath $expectedPath -Repository $testRepository2 + $expectedPath = Join-Path -Path $script:repositoryPath2 -ChildPath "$script:PublishModuleName.$version.nupkg" + (Get-ChildItem $script:repositoryPath2).FullName | Should -Be $expectedPath + } + <# The following tests are related to passing in parameters to customize a nuspec. # These parameters are not going in the current release, but is open for discussion to include in the future. + It "Publish a module with -Nuspec" { $version = "1.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -NestedModules "$script:PublishModuleName.psm1" From c9cec2957beda2bc2a248139156c98078300aafe Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Mon, 26 Aug 2024 14:22:37 -0700 Subject: [PATCH 095/160] Add module prefix for `Publish-PSResource` cmdlet (#1694) --- src/code/ContainerRegistryServerAPICalls.cs | 21 +++++++---- src/code/PublishHelper.cs | 31 ++++++++-------- src/code/PublishPSResource.cs | 37 ++++++++++++++++--- src/code/ServerApiCall.cs | 7 ++-- ...SResourceContainerRegistryServer.Tests.ps1 | 31 +++++++++++----- 5 files changed, 86 insertions(+), 41 deletions(-) diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs index 7d9998777..f5310d38c 100644 --- a/src/code/ContainerRegistryServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -1129,15 +1129,18 @@ private static Collection> GetDefaultHeaders(string internal bool PushNupkgContainerRegistry(string psd1OrPs1File, string outputNupkgDir, string packageName, + string modulePrefix, NuGetVersion packageVersion, ResourceType resourceType, - Hashtable parsedMetadataHash, - Hashtable dependencies, + Hashtable parsedMetadataHash, + Hashtable dependencies, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::PushNupkgContainerRegistry()"); string fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, packageName + "." + packageVersion.ToNormalizedString() + ".nupkg"); - string packageNameLowercase = packageName.ToLower(); + + string pkgNameForUpload = string.IsNullOrEmpty(modulePrefix) ? packageName : modulePrefix + "/" + packageName; + string packageNameLowercase = pkgNameForUpload.ToLower(); // Get access token (includes refresh tokens) _cmdletPassedIn.WriteVerbose($"Get access token for container registry server."); @@ -1179,8 +1182,8 @@ internal bool PushNupkgContainerRegistry(string psd1OrPs1File, return false; } - // Create and upload manifest - TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, packageName, resourceType, metadataJson, configFilePath, packageVersion, containerRegistryAccessToken, out errRecord); + // Create and upload manifest + TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, packageName, modulePrefix, resourceType, metadataJson, configFilePath, packageVersion, containerRegistryAccessToken, out errRecord); if (errRecord != null) { return false; @@ -1195,7 +1198,7 @@ internal bool PushNupkgContainerRegistry(string psd1OrPs1File, /// private string UploadNupkgFile(string packageNameLowercase, string containerRegistryAccessToken, string fullNupkgFile, out ErrorRecord errRecord) { - _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::UploadNupkgFile()"); + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::UploadNupkgFile()"); _cmdletPassedIn.WriteVerbose("Start uploading blob"); string nupkgDigest = string.Empty; errRecord = null; @@ -1349,6 +1352,7 @@ private bool TryCreateAndUploadManifest(string fullNupkgFile, string nupkgDigest, string configDigest, string packageName, + string modulePrefix, ResourceType resourceType, string metadataJson, string configFilePath, @@ -1358,7 +1362,10 @@ private bool TryCreateAndUploadManifest(string fullNupkgFile, { _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::TryCreateAndUploadManifest()"); errRecord = null; - string packageNameLowercase = packageName.ToLower(); + + string pkgNameForUpload = string.IsNullOrEmpty(modulePrefix) ? packageName : modulePrefix + "/" + packageName; + string packageNameLowercase = pkgNameForUpload.ToLower(); + FileInfo nupkgFile = new FileInfo(fullNupkgFile); var fileSize = nupkgFile.Length; var fileName = System.IO.Path.GetFileName(fullNupkgFile); diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index f8c762eab..8fc2575ad 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -78,12 +78,12 @@ internal PublishHelper(PSCmdlet cmdlet, string path, string destinationPath, boo outputNupkgDir = destinationPath; } - internal PublishHelper(PSCmdlet cmdlet, - PSCredential credential, - string apiKey, - string path, - string destinationPath, - bool skipModuleManifestValidate, + internal PublishHelper(PSCmdlet cmdlet, + PSCredential credential, + string apiKey, + string path, + string destinationPath, + bool skipModuleManifestValidate, CancellationToken cancellationToken, bool isNupkgPathSpecified) { @@ -105,7 +105,7 @@ internal PublishHelper(PSCmdlet cmdlet, #region Internal Methods - internal void PackResource() + internal void PackResource() { // Returns the name of the file or the name of the directory, depending on path if (!_cmdletPassedIn.ShouldProcess(string.Format("'{0}' from the machine", resolvedPath))) @@ -136,7 +136,7 @@ out string[] _ } parsedMetadata = scriptToPublish.ToHashtable(); - + _pkgName = System.IO.Path.GetFileNameWithoutExtension(pathToScriptFileToPublish); } else @@ -320,7 +320,7 @@ out string[] _ } } - internal void PushResource(string Repository, bool SkipDependenciesCheck, NetworkCredential _networkCrendential) + internal void PushResource(string Repository, string modulePrefix, bool SkipDependenciesCheck, NetworkCredential _networkCrendential) { try { @@ -410,7 +410,8 @@ internal void PushResource(string Repository, bool SkipDependenciesCheck, Networ ContainerRegistryServerAPICalls containerRegistryServer = new ContainerRegistryServerAPICalls(repository, _cmdletPassedIn, _networkCredential, userAgentString); var pkgMetadataFile = (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish; - if (!containerRegistryServer.PushNupkgContainerRegistry(pkgMetadataFile, outputNupkgDir, _pkgName, _pkgVersion, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgContainerRegistryError)) + + if (!containerRegistryServer.PushNupkgContainerRegistry(pkgMetadataFile, outputNupkgDir, _pkgName, modulePrefix, _pkgVersion, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgContainerRegistryError)) { _cmdletPassedIn.WriteError(pushNupkgContainerRegistryError); // exit out of processing @@ -593,7 +594,7 @@ private bool PackNupkg(string outputDir, string outputNupkgDir, string nuspecFil private bool PushNupkg(string outputNupkgDir, string repoName, string repoUri, out ErrorRecord error) { _cmdletPassedIn.WriteDebug("In PublishPSResource::PushNupkg()"); - + string fullNupkgFile; if (_isNupkgPathSpecified) { @@ -1097,12 +1098,12 @@ private string CreateNuspec( private Hashtable ParseRequiredModules(Hashtable parsedMetadataHash) { _cmdletPassedIn.WriteDebug("In PublishHelper::ParseRequiredModules()"); - + if (!parsedMetadataHash.ContainsKey("requiredmodules")) { return null; } - + LanguagePrimitives.TryConvertTo(parsedMetadataHash["requiredmodules"], out object[] requiredModules); // Required modules can be: @@ -1136,7 +1137,7 @@ private Hashtable ParseRequiredModules(Hashtable parsedMetadataHash) dependenciesHash.Add(moduleName, string.Empty); } } - + var externalModuleDeps = parsedMetadataHash.ContainsKey("ExternalModuleDependencies") ? parsedMetadataHash["ExternalModuleDependencies"] : null; @@ -1157,7 +1158,7 @@ private Hashtable ParseRequiredModules(Hashtable parsedMetadataHash) private bool CheckDependenciesExist(Hashtable dependencies, string repositoryName) { _cmdletPassedIn.WriteDebug("In PublishHelper::CheckDependenciesExist()"); - + // Check to see that all dependencies are in the repository // Searches for each dependency in the repository the pkg is being pushed to, // If the dependency is not there, error diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index 620de3b7e..524e86ae1 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -3,6 +3,7 @@ using Microsoft.PowerShell.PSResourceGet.UtilClasses; using System; +using System.Linq; using System.Management.Automation; using System.Net; using System.Threading; @@ -16,12 +17,13 @@ namespace Microsoft.PowerShell.PSResourceGet.Cmdlets "PSResource", SupportsShouldProcess = true)] [Alias("pbres")] - public sealed class PublishPSResource : PSCmdlet + public sealed class PublishPSResource : PSCmdlet, IDynamicParameters { #region Parameters private const string PathParameterSet = "PathParameterSet"; private const string NupkgPathParameterSet = "NupkgPathParameterSet"; + private ContainerRegistryDynamicParameters _pkgPrefix; /// /// Specifies the API key that you want to use to publish a module to the online gallery. @@ -117,6 +119,21 @@ public PSCredential ProxyCredential { #endregion + #region DynamicParameters + public object GetDynamicParameters() + { + PSRepositoryInfo repository = RepositorySettings.Read(new[] { Repository }, out string[] _).FirstOrDefault(); + if (repository is not null && repository.ApiVersion == PSRepositoryInfo.APIVersion.ContainerRegistry) + { + _pkgPrefix = new ContainerRegistryDynamicParameters(); + return _pkgPrefix; + } + + return null; + } + + #endregion + #region Members private CancellationToken _cancellationToken; @@ -145,10 +162,10 @@ protected override void BeginProcessing() RepositorySettings.CheckRepositoryStore(); _publishHelper = new PublishHelper( - this, - Credential, - ApiKey, - Path, + this, + Credential, + ApiKey, + Path, DestinationPath, SkipModuleManifestValidate, _cancellationToken, @@ -169,10 +186,18 @@ protected override void EndProcessing() return; } - _publishHelper.PushResource(Repository, SkipDependenciesCheck, _networkCredential); + string modulePrefix = _pkgPrefix?.ModulePrefix; + + _publishHelper.PushResource(Repository, modulePrefix, SkipDependenciesCheck, _networkCredential); } #endregion } + + public class ContainerRegistryDynamicParameters + { + [Parameter] + public string ModulePrefix { get; set; } + } } diff --git a/src/code/ServerApiCall.cs b/src/code/ServerApiCall.cs index 9f8bf4fe9..4580e362e 100644 --- a/src/code/ServerApiCall.cs +++ b/src/code/ServerApiCall.cs @@ -10,7 +10,6 @@ using System.Text; using System.Runtime.ExceptionServices; using System.Management.Automation; -using System; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets { @@ -28,11 +27,11 @@ internal abstract class ServerApiCall : IServerAPICalls public ServerApiCall(PSRepositoryInfo repository, NetworkCredential networkCredential) { this.Repository = repository; - + HttpClientHandler handler = new HttpClientHandler(); bool token = false; - if(networkCredential != null) + if(networkCredential != null) { token = String.Equals("token", networkCredential.UserName) ? true : false; }; @@ -47,7 +46,7 @@ public ServerApiCall(PSRepositoryInfo repository, NetworkCredential networkCrede } else { handler.Credentials = networkCredential; - + _sessionClient = new HttpClient(handler); }; _sessionClient.Timeout = TimeSpan.FromMinutes(10); diff --git a/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 index 8787ee02e..6c4150dac 100644 --- a/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 @@ -281,10 +281,10 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $script:PublishModuleName - $results[0].Version | Should -Be $version - $results[0].Dependencies.Name | Should -Be $dependencyName - $results[0].Dependencies.VersionRange.MinVersion.ToString() | Should -Be $dependencyVersion + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + $results[0].Dependencies.Name | Should -Be $dependencyName + $results[0].Dependencies.VersionRange.MinVersion.ToString() | Should -Be $dependencyVersion } It "Publish a module with multiple dependencies" { @@ -296,18 +296,18 @@ Describe "Test Publish-PSResource" -tags 'CI' { # New-ModuleManifest requires that the module be installed before it can be added as a dependency Install-PSResource -Name $dependency1Name -Repository $ACRRepoName -TrustRepository -Verbose -Reinstall Install-PSResource -Name $dependency2Name -Version $dependency2Version -Repository $ACRRepoName -TrustRepository -Reinstall - New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -RequiredModules @( $dependency1Name , @{ ModuleName = $dependency2Name; ModuleVersion = $dependency2Version }) + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -RequiredModules @( $dependency1Name , @{ ModuleName = $dependency2Name; ModuleVersion = $dependency2Version }) Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version $results | Should -Not -BeNullOrEmpty - $results[0].Name | Should -Be $script:PublishModuleName - $results[0].Version | Should -Be $version + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version $results[0].Dependencies.Name | Should -Be $dependency1Name, $dependency2Name - $results[0].Dependencies.VersionRange.MinVersion.OriginalVersion.ToString() | Should -Be $dependency2Version + $results[0].Dependencies.VersionRange.MinVersion.OriginalVersion.ToString() | Should -Be $dependency2Version } - + It "Publish a module and clean up properly when file in module is readonly" { $version = "13.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" @@ -498,4 +498,17 @@ Describe "Test Publish-PSResource" -tags 'CI' { {Publish-PSResource -Path $psm1Path -Repository $ACRRepoName -ErrorAction Stop} | Should -Throw -ErrorId "InvalidPublishPath,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" } + + It "Publish a module with -ModulePrefix" { + $version = "1.0.0" + $modulePrefix = "unlisted" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName -ModulePrefix $modulePrefix + + $results = Find-PSResource -Name "$modulePrefix/$script:PublishModuleName" -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } } From bf99f3a14e3631a7563a2356b802f492719e9e5f Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:47:30 -0700 Subject: [PATCH 096/160] Update error message for authenticode check (#1701) --- src/code/Utils.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 9b3ae51c1..2227b5e2a 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1999,7 +1999,7 @@ internal static bool CheckAuthenticodeSignature( if (!signature.Status.Equals(SignatureStatus.Valid)) { errorRecord = new ErrorRecord( - new ArgumentException($"The signature for '{pkgName}' is '{signature.Status}."), + new ArgumentException($"The signature status for '{pkgName}' file '{Path.GetFileName(signature.Path)}' is '{signature.Status}'. Status message: '{signature.StatusMessage}'"), "GetAuthenticodeSignatureError", ErrorCategory.InvalidResult, cmdletPassedIn); From a64c2e5d41faf3e0ef498bb90a3c7c4d42ca0bf7 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Thu, 12 Sep 2024 19:27:44 -0500 Subject: [PATCH 097/160] Added -PassThru Parameter for Compress-PSResource (#1702) --- src/code/CompressPSResource.cs | 9 ++++++++- src/code/PublishHelper.cs | 8 +++++++- .../CompressPSResource.Tests.ps1 | 11 +++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/code/CompressPSResource.cs b/src/code/CompressPSResource.cs index ba79425df..95b95a4e3 100644 --- a/src/code/CompressPSResource.cs +++ b/src/code/CompressPSResource.cs @@ -36,7 +36,13 @@ public sealed class CompressPSResource : PSCmdlet public string DestinationPath { get; set; } /// - /// Bypasses validating a resource module manifest before publishing. + /// When specified, passes the full path of the nupkg through the pipeline. + /// + [Parameter(Mandatory = false, HelpMessage = "Pass the full path of the nupkg through the pipeline")] + public SwitchParameter PassThru { get; set; } + + /// + /// Bypasses validating a resource module manifest before compressing. /// [Parameter] public SwitchParameter SkipModuleManifestValidate { get; set; } @@ -61,6 +67,7 @@ protected override void BeginProcessing() this, Path, DestinationPath, + PassThru, SkipModuleManifestValidate); _publishHelper.CheckAllParameterPaths(); diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 8fc2575ad..861bbfab3 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -61,18 +61,20 @@ internal enum CallerCmdlet private string outputDir = string.Empty; internal bool ScriptError = false; internal bool ShouldProcess = true; + internal bool PassThru = false; #endregion #region Constructors - internal PublishHelper(PSCmdlet cmdlet, string path, string destinationPath, bool skipModuleManifestValidate) + internal PublishHelper(PSCmdlet cmdlet, string path, string destinationPath, bool passThru, bool skipModuleManifestValidate) { _callerCmdlet = CallerCmdlet.CompressPSResource; _cmdOperation = "Compress"; _cmdletPassedIn = cmdlet; Path = path; DestinationPath = destinationPath; + PassThru = passThru; SkipModuleManifestValidate = skipModuleManifestValidate; outputDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); outputNupkgDir = destinationPath; @@ -562,6 +564,10 @@ private bool PackNupkg(string outputDir, string outputNupkgDir, string nuspecFil if (success) { + if (PassThru) + { + _cmdletPassedIn.WriteObject(System.IO.Path.Combine(outputNupkgDir, _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg")); + } _cmdletPassedIn.WriteVerbose("Successfully packed the resource into a .nupkg"); } else diff --git a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 index cdccee7f6..778c225bb 100644 --- a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 @@ -153,6 +153,17 @@ Describe "Test Compress-PSResource" -tags 'CI' { Test-Path -Path (Join-Path -Path $unzippedPath -ChildPath $testFile) | Should -Be $True } + + It "Compress-PSResource -PassThru returns the path to the nupkg" { + $version = "1.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + $nupkgPath = Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:repositoryPath -PassThru + + $expectedPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.nupkg" + $nupkgPath | Should -Be $expectedPath + } + <# Test for Signing the nupkg. Signing doesn't work It "Compressed Module is able to be signed with a certificate" { $version = "1.0.0" From f74d444e35670aaf213e44ae120420950a045dce Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Fri, 13 Sep 2024 10:04:38 -0700 Subject: [PATCH 098/160] Update version, changelog, and release notes (#1703) --- CHANGELOG/preview.md | 15 +++ src/Microsoft.PowerShell.PSResourceGet.psd1 | 129 +++----------------- 2 files changed, 32 insertions(+), 112 deletions(-) diff --git a/CHANGELOG/preview.md b/CHANGELOG/preview.md index 3e4653abb..963a8c36c 100644 --- a/CHANGELOG/preview.md +++ b/CHANGELOG/preview.md @@ -1,3 +1,18 @@ +## [1.1.0-preview2](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-preview1...v1.1.0-preview2) - 2024-09-13 + +### New Features + +- New cmdlet `Compress-PSResource` which packs a package into a .nupkg and saves it to the file system (#1682, #1702) +- New `-Nupkg` parameter for `Publish-PSResource` which pushes pushes a .nupkg to a repository (#1682) +- New `-ModulePrefix` parameter for `Publish-PSResource` which adds a prefix to a module name (#1694) + +### Bug Fixes + +- Add prerelease string when NormalizedVersion doesn't exist, but prelease string does (#1681 Thanks @sean-r-williams) +- Add retry logic when deleting files (#1667 Thanks @o-l-a-v!) +- Fix broken PAT token use (#1672) +- Updated error messaging for authenticode signature failures (#1701) + ## [1.1.0-preview1](https://github.com/PowerShell/PSResourceGet/compare/v1.0.3...v1.1.0-preview1) - 2024-04-01 ### New Features diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index 2b631ee21..92167e4d8 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -46,7 +46,7 @@ 'udres') PrivateData = @{ PSData = @{ - Prerelease = 'preview1' + Prerelease = 'preview2' Tags = @('PackageManagement', 'PSEdition_Desktop', 'PSEdition_Core', @@ -56,6 +56,21 @@ ProjectUri = 'https://go.microsoft.com/fwlink/?LinkId=828955' LicenseUri = 'https://go.microsoft.com/fwlink/?LinkId=829061' ReleaseNotes = @' +## 1.1.0-preview2 + +### New Features + +- New cmdlet `Compress-PSResource` which packs a package into a .nupkg and saves it to the file system (#1682, #1702) +- New `-Nupkg` parameter for `Publish-PSResource` which pushes pushes a .nupkg to a repository (#1682) +- New `-ModulePrefix` parameter for `Publish-PSResource` which adds a prefix to a module name (#1694) + +### Bug Fixes + +- Add prerelease string when NormalizedVersion doesn't exist, but prelease string does (#1681 Thanks @sean-r-williams) +- Add retry logic when deleting files (#1667 Thanks @o-l-a-v!) +- Fix broken PAT token use (#1672) +- Updated error messaging for authenticode signature failures (#1701) + ## 1.1.0-preview1 ### New Features @@ -114,117 +129,7 @@ - Bug fix `-RequiredResource` silent failures (#1426) - Bug fix for v2 repository returning extra packages for `-Tag` based search with `-Prerelease` (#1405) -## 0.9.0-rc1 - -### Bug Fixes -- Bug fix for using `Import-PSGetRepository` in Windows PowerShell (#1390) -- Add error handling when searching for unlisted package versions (#1386) -- Bug fix for deduplicating dependencies found from `Find-PSResource` (#1382) -- Added support for non-PowerShell Gallery v2 repositories (#1380) -- Bug fix for setting 'unknown' repository `APIVersion` (#1377) -- Bug fix for saving a script with `-IncludeXML` parameter (#1375) -- Bug fix for v3 server logic to properly parse inner @id element (#1374) -- Bug fix to write warning instead of error when package is already installed (#1367) - -## 0.5.24-beta24 - -### Bug Fixes -- Detect empty V2 server responses at ServerApiCall level instead of ResponseUtil level (#1358) -- Bug fix for finding all versions of a package returning correct results and incorrect "package not found" error (#1356) -- Bug fix for installing or saving a pkg found in lower priority repository (#1350) -- Ensure `-Prerelease` is not empty or whitespace for `Update-PSModuleManifest` (#1348) -- Bug fix for saving `Az` module dependencies (#1343) -- Bug fix for `Find-PSResource` repository looping to to return matches from all repositories (#1342) -- Update error handling for Tags, Commands, and DSCResources when searching across repositories (#1339) -- Update `Find-PSResource` looping and error handling to account for multiple package names (#1338) -- Update error handling for `Find-PSResource` using V2 server endpoint repositories (#1329) -- Bug fix for searching through multiple repositories when some repositories do not contain the specified package (#1328) -- Add parameters to `Install-PSResource` verbose message (#1327) -- Bug fix for parsing required modules when publishing (#1326) -- Bug fix for saving dependency modules in version range format (#1323) -- Bug fix for `Install-PSResource` failing to find prerelease dependencies (#1322) -- Bug fix for updating to a new version of a prerelease module (#1320) -- Fix for error message when DSCResource is not found (#1317) -- Add error handling for local repository pattern based searching (#1316) -- `Set-PSResourceRepository` run without `-ApiVersion` paramater no longer resets the property for the repository (#1310) - - -## 0.5.23-beta23 - -### Breaking Changes - -### New Features -- *-PSResourceRepository -Uri now accepting PSPaths (#1269) -- Add aliases for Install-PSResource, Find-PSResource, Update-PSResource, Publish-PSResource (#1264) -- Add custom user agent string to API calls (#1260) -- Support install for NuGet.Server application hosted feed (#1253) -- Add support for NuGet.Server application hosted feeds (#1236) -- Add Import-PSGetRepository function to import existing v2 PSRepositories into PSResourceRepositories. (#1221) -- Add 'Get-PSResource' alias to 'Get-InstalledPSResource' (#1216) -- Add -ApiVersion parameter to Set-PSResourceRepository (#1207) -- Add support for FindNameGlobbing scenarios (i.e -Name az*) for MyGet server repository (V3) (#1202) - - -### Bug Fixes -- Better error handling for scenario where repo ApiVersion is unknown and allow for PSPaths as URI for registered repositories (#1288) -- Bugfix for Uninstall should be able to remove older versions of a package that are not a dependency (#1287) -- Bugfix for Publish finding prerelease dependency versions. (#1283) -- Fix Pagination for V3 search with globbing scenarios (#1277) -- Update message for -WhatIf in Install-PSResource, Save-PSResource, and Update-PSResource (#1274) -- Bug fix for publishing with ExternalModuleDependencies (#1271) -- Support Credential Persistence for Publish-PSResource (#1268) -- Update Save-PSResource -Path param so it defaults to the current working directory (#1265) -- Update dependency error message in Publish-PSResource (#1263) -- Bug fixes for script metadata (#1259) -- Fix error message for Publish-PSResource for MyGet.org feeds (#1256) -- Bug fix for version ranges with prerelease versions not returning the correct versions (#1255) -- Bug fix for file path version must match psd1 version error when publishing (#1254) -- Bug fix for searching through local repositories with -Type parameter (#1252) -- Allow environment variables in module manifests (#1249) -- Updating prerelease version should update to latest prerelease version (#1238) -- Fix InstallHelper call to GetEnvironmentVariable() on Unix (#1237) -- Update build script to resolve module loading error (#1234) -- Enable UNC Paths for local repositories, source directories and destination directories (#1229) -- Improve better error handling for -Path in Publish-PSResource (#1227) -- Bug fix for RequireLicenseAcceptance in Publish-PSResource (#1225) -- Provide clearer error handling for V3 Publish support (#1224) -- Fix bug with version parsing in Publish-PSResource (#1223) -- Improve error handling for Find-PSResource (#1222) -- Add error handling to Get-InstalledPSResource and Find-PSResource (#1217) -- Improve error handling in Uninstall-PSResource (#1215) -- Change resolved paths to use GetResolvedProviderPathFromPSPath (#1209) -- Bug fix for Get-InstalledPSResource returning type of scripts as module (#1198) - - -## 0.5.22-beta22 - -### Breaking Changes -- PowerShellGet is now PSResourceGet! (#1164) -- Update-PSScriptFile is now Update-PSScriptFileInfo (#1140) -- New-PSScriptFile is now New-PSScriptFileInfo (#1140) -- Update-ModuleManifest is now Update-PSModuleManifest (#1139) -- -Tags parameter changed to -Tag in New-PSScriptFile, Update-PSScriptFileInfo, and Update-ModuleManifest (#1123) -- Change the type of -InputObject from PSResource to PSResource[] for Install-PSResource, Save-PSResource, and Uninstall-PSResource (#1124) -- PSModulePath is no longer referenced when searching paths (#1154) - -### New Features -- Support for Azure Artifacts, GitHub Packages, and Artifactory (#1167, #1180) - -### Bug Fixes -- Filter out unlisted packages (#1172, #1161) -- Add paging for V3 server requests (#1170) -- Support for floating versions (#1117) -- Update, Save, and Install with wildcard gets the latest version within specified range (#1117) -- Add positonal parameter for -Path in Publish-PSResource (#1111) -- Uninstall-PSResource -WhatIf now shows version and path of package being uninstalled (#1116) -- Find returns packages from the highest priority repository only (#1155) -- Bug fix for PSCredentialInfo constructor (#1156) -- Bug fix for Install-PSResource -NoClobber parameter (#1121) -- Save-PSResource now searches through all repos when no repo is specified (#1125) -- Caching for improved performance in Uninstall-PSResource (#1175) -- Bug fix for parsing package tags from local repository (#1119) - -See change log (CHANGELOG.md) at https://github.com/PowerShell/PSResourceGet +See change log (CHANGELOG) at https://github.com/PowerShell/PSResourceGet '@ } } From fe9c4d685933d4626d0bc74b682c73a671216934 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Fri, 13 Sep 2024 11:52:07 -0700 Subject: [PATCH 099/160] Update preview.md (#1704) --- CHANGELOG/preview.md | 2 +- global.json | 2 +- src/Microsoft.PowerShell.PSResourceGet.psd1 | 2 +- src/code/Microsoft.PowerShell.PSResourceGet.csproj | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG/preview.md b/CHANGELOG/preview.md index 963a8c36c..539630d0d 100644 --- a/CHANGELOG/preview.md +++ b/CHANGELOG/preview.md @@ -4,7 +4,7 @@ - New cmdlet `Compress-PSResource` which packs a package into a .nupkg and saves it to the file system (#1682, #1702) - New `-Nupkg` parameter for `Publish-PSResource` which pushes pushes a .nupkg to a repository (#1682) -- New `-ModulePrefix` parameter for `Publish-PSResource` which adds a prefix to a module name (#1694) +- New `-ModulePrefix` parameter for `Publish-PSResource` which adds a prefix to a module name for container registry repositories to add a module prefix.This is only used for publishing and is not part of metadata. MAR will drop the prefix when syndicating from ACR to MAR (#1694) ### Bug Fixes diff --git a/global.json b/global.json index 87b60ce1a..8acf2f3a1 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.304" + "version": "8.0.400" } } diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index 92167e4d8..9fea3b273 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -62,7 +62,7 @@ - New cmdlet `Compress-PSResource` which packs a package into a .nupkg and saves it to the file system (#1682, #1702) - New `-Nupkg` parameter for `Publish-PSResource` which pushes pushes a .nupkg to a repository (#1682) -- New `-ModulePrefix` parameter for `Publish-PSResource` which adds a prefix to a module name (#1694) +- New `-ModulePrefix` parameter for `Publish-PSResource` which adds a prefix to a module name for container registry repositories to add a module prefix.This is only used for publishing and is not part of metadata. MAR will drop the prefix when syndicating from ACR to MAR (#1694) ### Bug Fixes diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index d4692e4c4..591b53856 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -22,9 +22,9 @@ - + - + From 540b090d4139a991cd3bface87233ac9bb62045a Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Wed, 9 Oct 2024 12:06:33 -0500 Subject: [PATCH 100/160] Compress-PSResource -PassThru now passes FileInfo instead of string (#1720) --- src/code/CompressPSResource.cs | 2 +- src/code/PublishHelper.cs | 3 ++- test/PublishPSResourceTests/CompressPSResource.Tests.ps1 | 9 ++++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/code/CompressPSResource.cs b/src/code/CompressPSResource.cs index 95b95a4e3..b306e7d88 100644 --- a/src/code/CompressPSResource.cs +++ b/src/code/CompressPSResource.cs @@ -3,7 +3,6 @@ using Microsoft.PowerShell.PSResourceGet.UtilClasses; using System.IO; -using System.Linq; using System.Management.Automation; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets @@ -15,6 +14,7 @@ namespace Microsoft.PowerShell.PSResourceGet.Cmdlets "PSResource", SupportsShouldProcess = true)] [Alias("cmres")] + [OutputType(typeof(FileInfo))] public sealed class CompressPSResource : PSCmdlet { #region Parameters diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 861bbfab3..f7ef9248b 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -566,7 +566,8 @@ private bool PackNupkg(string outputDir, string outputNupkgDir, string nuspecFil { if (PassThru) { - _cmdletPassedIn.WriteObject(System.IO.Path.Combine(outputNupkgDir, _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg")); + var nupkgPath = System.IO.Path.Combine(outputNupkgDir, _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg"); + _cmdletPassedIn.WriteObject(new FileInfo(nupkgPath)); } _cmdletPassedIn.WriteVerbose("Successfully packed the resource into a .nupkg"); } diff --git a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 index 778c225bb..7a54d72d6 100644 --- a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 @@ -154,14 +154,17 @@ Describe "Test Compress-PSResource" -tags 'CI' { Test-Path -Path (Join-Path -Path $unzippedPath -ChildPath $testFile) | Should -Be $True } - It "Compress-PSResource -PassThru returns the path to the nupkg" { + It "Compress-PSResource -PassThru returns a FileInfo object with the correct path" { $version = "1.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" - $nupkgPath = Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:repositoryPath -PassThru + $fileInfoObject = Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:repositoryPath -PassThru $expectedPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.nupkg" - $nupkgPath | Should -Be $expectedPath + $fileInfoObject | Should -BeOfType 'System.IO.FileSystemInfo' + $fileInfoObject.FullName | Should -Be $expectedPath + $fileInfoObject.Extension | Should -Be '.nupkg' + $fileInfoObject.Name | Should -Be "$script:PublishModuleName.$version.nupkg" } <# Test for Signing the nupkg. Signing doesn't work From e83ff8dce50237c738a2dda274248c519147604b Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:55:29 -0500 Subject: [PATCH 101/160] Fixed Issue with Compress-PSResource not working when passed Relative Path to DestinationPath (#1719) --- src/code/PublishHelper.cs | 5 ++ .../CompressPSResource.Tests.ps1 | 51 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index f7ef9248b..6e15dd459 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -296,6 +296,11 @@ out string[] _ } } + if (_callerCmdlet == CallerCmdlet.CompressPSResource) + { + outputNupkgDir = DestinationPath; + } + // pack into .nupkg if (!PackNupkg(outputDir, outputNupkgDir, nuspec, out ErrorRecord packNupkgError)) { diff --git a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 index 7a54d72d6..7d9fe9770 100644 --- a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 @@ -154,6 +154,57 @@ Describe "Test Compress-PSResource" -tags 'CI' { Test-Path -Path (Join-Path -Path $unzippedPath -ChildPath $testFile) | Should -Be $True } + It "Compresses a script" { + $scriptName = "PSGetTestScript" + $scriptVersion = "1.0.0" + + $params = @{ + Version = $scriptVersion + GUID = [guid]::NewGuid() + Author = 'Jane' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) 2020 Microsoft Corporation. All rights reserved.' + Description = "Description for the $scriptName script" + LicenseUri = "https://$scriptName.com/license" + IconUri = "https://$scriptName.com/icon" + ProjectUri = "https://$scriptName.com" + Tags = @('Tag1','Tag2', "Tag-$scriptName-$scriptVersion") + ReleaseNotes = "$scriptName release notes" + } + + $scriptPath = (Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$scriptName.ps1") + New-PSScriptFileInfo @params -Path $scriptPath + + Compress-PSResource -Path $scriptPath -DestinationPath $script:repositoryPath + + $expectedPath = Join-Path -Path $script:repositoryPath -ChildPath "$scriptName.$scriptVersion.nupkg" + (Get-ChildItem $script:repositoryPath).FullName | Should -Be $expectedPath + } + + It "Compress-PSResource -DestinationPath works for relative paths" { + $version = "1.0.0" + $relativePath = ".\RelativeTestModule" + $relativeDestination = ".\RelativeDestination" + + # Create relative paths + New-Item -Path $relativePath -ItemType Directory -Force + New-Item -Path $relativeDestination -ItemType Directory -Force + + # Create module manifest in the relative path + New-ModuleManifest -Path (Join-Path -Path $relativePath -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + # Compress using relative paths + Compress-PSResource -Path $relativePath -DestinationPath $relativeDestination + + $expectedPath = Join-Path -Path $relativeDestination -ChildPath "$script:PublishModuleName.$version.nupkg" + $fileExists = Test-Path -Path $expectedPath + $fileExists | Should -Be $True + + # Cleanup + Remove-Item -Path $relativePath -Recurse -Force + Remove-Item -Path $relativeDestination -Recurse -Force + } + It "Compress-PSResource -PassThru returns a FileInfo object with the correct path" { $version = "1.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" From 08d5b7ad5b8b8f137480fb504675f8a823eb2336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20R=C3=B8nnestad=20Birkeland?= <6450056+o-l-a-v@users.noreply.github.com> Date: Wed, 9 Oct 2024 22:13:21 +0200 Subject: [PATCH 102/160] Fix 1688 "Find-PSResource -Name * does not return anything on 1.0.5+" (#1706) --- src/code/V2ServerAPICalls.cs | 2 +- test/FindPSResourceTests/FindPSResourceV2Server.Tests.ps1 | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index a7664560b..d24542dbf 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -901,7 +901,7 @@ private string FindAllFromTypeEndPoint(bool includePrerelease, bool isSearchingM } else { filterBuilder.AddCriterion("IsLatestVersion"); } - var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?$filter={queryBuilder.BuildQueryString()}"; + var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrlV2, out errRecord); } diff --git a/test/FindPSResourceTests/FindPSResourceV2Server.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceV2Server.Tests.ps1 index b345b48c2..2a01c7677 100644 --- a/test/FindPSResourceTests/FindPSResourceV2Server.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceV2Server.Tests.ps1 @@ -5,7 +5,7 @@ $modPath = "$psscriptroot/../PSGetTestUtils.psm1" Import-Module $modPath -Force -Verbose $psmodulePaths = $env:PSModulePath -split ';' -Write-Verbose -Verbose "Current module search paths: $psmodulePaths" +Write-Verbose -Verbose -Message "Current module search paths: $psmodulePaths" Describe 'Test HTTP Find-PSResource for V2 Server Protocol' -tags 'CI' { @@ -56,6 +56,11 @@ Describe 'Test HTTP Find-PSResource for V2 Server Protocol' -tags 'CI' { $foundScript | Should -BeTrue } + It "find all resources when wildcard only for Name" { + $res = Find-PSResource -Name '*' -Repository $PSGalleryName + $res.Count | Should -BeGreaterThan 0 + } + $testCases2 = @{Version="[5.0.0.0]"; ExpectedVersions=@("5.0.0.0"); Reason="validate version, exact match"}, @{Version="5.0.0.0"; ExpectedVersions=@("5.0.0.0"); Reason="validate version, exact match without bracket syntax"}, @{Version="[1.0.0.0, 5.0.0.0]"; ExpectedVersions=@("1.0.0.0", "3.0.0.0", "5.0.0.0"); Reason="validate version, exact range inclusive"}, From 913a92eca8d65337eb852d1fdfc4e2dbcbb4f53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20R=C3=B8nnestad=20Birkeland?= <6450056+o-l-a-v@users.noreply.github.com> Date: Thu, 10 Oct 2024 02:31:47 +0200 Subject: [PATCH 103/160] Fix 1659 "Some Nuget packages fail to extract" (#1707) --- src/code/InstallHelper.cs | 2 +- .../InstallPSResourceLocal.Tests.ps1 | 12 ++++++++++++ .../microsoft.web.webview2.1.0.2792.45.nupkg | Bin 0 -> 8474494 bytes 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 test/testFiles/testNupkgs/microsoft.web.webview2.1.0.2792.45.nupkg diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index ba7fc3d88..d804da16a 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -1203,7 +1203,7 @@ private bool TryExtractToDirectory(string zipPath, string extractPath, out Error { using (ZipArchive archive = ZipFile.OpenRead(zipPath)) { - foreach (ZipArchiveEntry entry in archive.Entries) + foreach (ZipArchiveEntry entry in archive.Entries.Where(entry => entry.CompressedLength > 0)) { // If a file has one or more parent directories. if (entry.FullName.Contains(Path.DirectorySeparatorChar) || entry.FullName.Contains(Path.AltDirectorySeparatorChar)) diff --git a/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 index 7553fa066..90b264983 100644 --- a/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 @@ -14,12 +14,15 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { BeforeAll { $localRepo = "psgettestlocal" $localUNCRepo = "psgettestlocal3" + $localNupkgRepo = "LocalNupkgRepo" + $localNupkgRepoUri = "test\testFiles\testNupkgs" $testModuleName = "test_local_mod" $testModuleName2 = "test_local_mod2" $testModuleClobber = "testModuleClobber" $testModuleClobber2 = "testModuleClobber2" Get-NewPSResourceRepositoryFile Register-LocalRepos + Register-PSResourceRepository -Name $localNupkgRepo -SourceLocation $localNupkgRepoUri $prereleaseLabel = "alpha001" $tags = @() @@ -279,4 +282,13 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { $err[$i].FullyQualifiedErrorId | Should -Not -Be "System.NullReferenceException,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" } } + + It "Install .nupkg that contains directories (specific package throws errors when accessed by ZipFile.OpenRead)" { + $nupkgName = "Microsoft.Web.Webview2" + $nupkgVersion = "1.0.2792.45" + Install-PSResource -Name $nupkgName -Version $nupkgVersion -Repository $localNupkgRepo -TrustRepository + $pkg = Get-InstalledPSResource $nupkgName + $pkg.Name | Should -Be $nupkgName + $pkg.Version | Should -Be $nupkgVersion + } } diff --git a/test/testFiles/testNupkgs/microsoft.web.webview2.1.0.2792.45.nupkg b/test/testFiles/testNupkgs/microsoft.web.webview2.1.0.2792.45.nupkg new file mode 100644 index 0000000000000000000000000000000000000000..d047ae55d1069bb2c2a5f63196ae8634f2c83d6f GIT binary patch literal 8474494 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̆tc$sqq+(1YP* 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

public SwitchParameter Quiet { get; set; } + /// + /// For modules that require a license, AcceptLicense automatically accepts the license agreement during installation. + /// + [Parameter] + public SwitchParameter AcceptLicense { get; set; } + #endregion #region Method overrides @@ -207,7 +213,8 @@ protected override void ProcessRecord() break; case InputObjectParameterSet: - foreach (var inputObj in InputObject) { + foreach (var inputObj in InputObject) + { string normalizedVersionString = Utils.GetNormalizedVersionString(inputObj.Version.ToString(), inputObj.Prerelease); ProcessSaveHelper( pkgNames: new string[] { inputObj.Name }, @@ -230,7 +237,7 @@ protected override void ProcessRecord() private void ProcessSaveHelper(string[] pkgNames, string pkgVersion, bool pkgPrerelease, string[] pkgRepository) { WriteDebug("In SavePSResource::ProcessSaveHelper()"); - var namesToSave = Utils.ProcessNameWildcards(pkgNames, removeWildcardEntries:false, out string[] errorMsgs, out bool nameContainsWildcard); + var namesToSave = Utils.ProcessNameWildcards(pkgNames, removeWildcardEntries: false, out string[] errorMsgs, out bool nameContainsWildcard); if (nameContainsWildcard) { WriteError(new ErrorRecord( @@ -238,7 +245,7 @@ private void ProcessSaveHelper(string[] pkgNames, string pkgVersion, bool pkgPre "NameContainsWildcard", ErrorCategory.InvalidArgument, this)); - + return; } @@ -276,26 +283,27 @@ private void ProcessSaveHelper(string[] pkgNames, string pkgVersion, bool pkgPre // figure out if version is a prerelease or not. // if condition is not met, prerelease is the value passed in via the parameter. - if (!string.IsNullOrEmpty(pkgVersion) && pkgVersion.Contains('-')) { + if (!string.IsNullOrEmpty(pkgVersion) && pkgVersion.Contains('-')) + { pkgPrerelease = true; } var installedPkgs = _installHelper.BeginInstallPackages( - names: namesToSave, + names: namesToSave, versionRange: versionRange, nugetVersion: nugetVersion, versionType: versionType, versionString: pkgVersion, - prerelease: pkgPrerelease, - repository: pkgRepository, - acceptLicense: true, - quiet: Quiet, - reinstall: true, - force: false, + prerelease: pkgPrerelease, + repository: pkgRepository, + acceptLicense: AcceptLicense, + quiet: Quiet, + reinstall: true, + force: false, trustRepository: TrustRepository, - noClobber: false, - asNupkg: AsNupkg, - includeXml: IncludeXml, + noClobber: false, + asNupkg: AsNupkg, + includeXml: IncludeXml, skipDependencyCheck: SkipDependencyCheck, authenticodeCheck: AuthenticodeCheck, savePkg: true, diff --git a/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 b/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 index 8f7d540c5..f00656b06 100644 --- a/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 +++ b/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 @@ -30,14 +30,14 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Save specific module resource by name" { Save-PSResource -Name $testModuleName -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty (Get-ChildItem $pkgDir.FullName) | Should -HaveCount 1 } It "Save specific script resource by name" { Save-PSResource -Name $testScriptName -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "test_script.ps1" + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "test_script.ps1" $pkgDir | Should -Not -BeNullOrEmpty (Get-ChildItem $pkgDir.FullName) | Should -HaveCount 1 } @@ -53,7 +53,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should not save resource given nonexistant name" { Save-PSResource -Name NonExistentModule -Repository $PSGalleryName -Path $SaveDir -ErrorVariable err -ErrorAction SilentlyContinue -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "NonExistentModule" + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "NonExistentModule" $pkgDir.Name | Should -BeNullOrEmpty } @@ -65,7 +65,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should save resource given name and exact version" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0.0" @@ -73,7 +73,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should save resource given name and version '3.*'" { Save-PSResource -Name $testModuleName -Version "3.*" -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "3.0.0.0" @@ -81,7 +81,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should save resource given name and exact version with bracket syntax" { Save-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0.0" @@ -89,7 +89,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should save resource given name and exact range inclusive [1.0.0, 3.0.0]" { Save-PSResource -Name $testModuleName -Version "[1.0.0, 3.0.0]" -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "3.0.0.0" @@ -97,7 +97,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should save resource given name and exact range exclusive (1.0.0, 5.0.0)" { Save-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "3.0.0.0" @@ -111,15 +111,15 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { catch {} - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -BeNullOrEmpty $Error.Count | Should -BeGreaterThan 0 - $Error[0].FullyQualifiedErrorId | Should -Be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" + $Error[0].FullyQualifiedErrorId | Should -Be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" } It "Save resource with latest (including prerelease) version given Prerelease parameter" { Save-PSResource -Name $testModuleName -Prerelease -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "5.2.5" @@ -146,24 +146,24 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { ### the input object is of type string (ie "true"). It "Save PSResourceInfo object piped in for prerelease version object" -Pending { Find-PSResource -Name $testModuleName -Version "5.2.5-alpha001" -Repository $PSGalleryName | Save-PSResource -Path $SaveDir -TrustRepository -Verbose - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty (Get-ChildItem -Path $pkgDir.FullName) | Should -HaveCount 1 } It "Save module as a nupkg" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $PSGalleryName -Path $SaveDir -AsNupkg -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "test_module.1.0.0.nupkg" + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "test_module.1.0.0.nupkg" $pkgDir | Should -Not -BeNullOrEmpty } It "Save module and include XML metadata file" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $PSGalleryName -Path $SaveDir -IncludeXml -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0.0" - $xmlFile = Get-ChildItem -Path $pkgDirVersion.FullName | Where-Object Name -eq "PSGetModuleInfo.xml" + $xmlFile = Get-ChildItem -Path $pkgDirVersion.FullName | Where-Object Name -EQ "PSGetModuleInfo.xml" $xmlFile | Should -Not -BeNullOrEmpty } @@ -184,8 +184,8 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { Save-PSResource -Name $testScriptName -Repository $PSGalleryName -Path $SaveDir -TrustRepository -IncludeXml $scriptXML = $testScriptName + "_InstalledScriptInfo.xml" - $savedScriptFile = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "test_script.ps1" - $savedScriptXML = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $scriptXML + $savedScriptFile = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "test_script.ps1" + $savedScriptXML = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $scriptXML $savedScriptFile | Should -Not -BeNullOrEmpty (Get-ChildItem $savedScriptFile.FullName) | Should -HaveCount 1 $savedScriptXML | Should -Not -BeNullOrEmpty @@ -199,4 +199,13 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { $err.Count | Should -BeGreaterThan 0 $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" } + + # Save resource that requires license + It "Save resource that requires accept license with -AcceptLicense flag" { + Save-PSResource -Repository $TestGalleryName -TrustRepository -Path $SaveDir ` + -Name $testModuleName2 -AcceptLicense + $pkg = Get-InstalledPSResource -Path $SaveDir -Name $testModuleName2 + $pkg.Name | Should -Be $testModuleName2 + $pkg.Version | Should -Be "0.0.1.0" + } } diff --git a/test/SavePSResourceTests/SavePSResourceV3Tests.ps1 b/test/SavePSResourceTests/SavePSResourceV3Tests.ps1 index c82a11b54..cb414ce50 100644 --- a/test/SavePSResourceTests/SavePSResourceV3Tests.ps1 +++ b/test/SavePSResourceTests/SavePSResourceV3Tests.ps1 @@ -28,7 +28,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Save specific module resource by name" { Save-PSResource -Name $testModuleName -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty (Get-ChildItem $pkgDir.FullName) | Should -HaveCount 1 } @@ -44,7 +44,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Should not save resource given nonexistant name" { Save-PSResource -Name NonExistentModule -Repository $NuGetGalleryName -Path $SaveDir -ErrorVariable err -ErrorAction SilentlyContinue -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "NonExistentModule" + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "NonExistentModule" $pkgDir.Name | Should -BeNullOrEmpty } @@ -56,7 +56,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Should save resource given name and exact version" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0" @@ -64,7 +64,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Should save resource given name and exact version with bracket syntax" { Save-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0" @@ -72,7 +72,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Should save resource given name and exact range inclusive [1.0.0, 3.0.0]" { Save-PSResource -Name $testModuleName -Version "[1.0.0, 3.0.0]" -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "3.0.0" @@ -80,7 +80,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Should save resource given name and exact range exclusive (1.0.0, 5.0.0)" { Save-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "3.0.0" @@ -94,15 +94,15 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { catch {} - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -BeNullOrEmpty $Error.Count | Should -BeGreaterThan 0 - $Error[0].FullyQualifiedErrorId | Should -Be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" + $Error[0].FullyQualifiedErrorId | Should -Be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" } It "Save resource with latest (including prerelease) version given Prerelease parameter" { Save-PSResource -Name $testModuleName -Prerelease -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "5.2.5" @@ -112,24 +112,24 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { ### the input object is of type string (ie "true"). It "Save PSResourceInfo object piped in for prerelease version object" -Pending{ Find-PSResource -Name $testModuleName -Version "5.2.5-alpha001" -Repository $NuGetGalleryName | Save-PSResource -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty - (Get-ChildItem -Path $pkgDir.FullName) | Should -HaveCount 1 + (Get-ChildItem -Path $pkgDir.FullName) | Should -HaveCount 1 } It "Save module as a nupkg" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $NuGetGalleryName -Path $SaveDir -AsNupkg -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "test_module.1.0.0.nupkg" + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "test_module.1.0.0.nupkg" $pkgDir | Should -Not -BeNullOrEmpty } It "Save module and include XML metadata file" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $NuGetGalleryName -Path $SaveDir -IncludeXml -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0" - $xmlFile = Get-ChildItem -Path $pkgDirVersion.FullName | Where-Object Name -eq "PSGetModuleInfo.xml" + $xmlFile = Get-ChildItem -Path $pkgDirVersion.FullName | Where-Object Name -EQ "PSGetModuleInfo.xml" $xmlFile | Should -Not -BeNullOrEmpty } @@ -146,4 +146,13 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { $err.Count | Should -BeGreaterThan 0 $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" } -} \ No newline at end of file + + # Save resource that requires license + It "Install resource that requires accept license with -AcceptLicense flag" { + Save-PSResource -Repository $NuGetGalleryName -TrustRepository -Path $SaveDir ` + -Name "test_module_with_license" -AcceptLicense + $pkg = Get-InstalledPSResource -Path $SaveDir "test_module_with_license" + $pkg.Name | Should -Be "test_module_with_license" + $pkg.Version | Should -Be "2.0.0" + } +} From b8a3013ace3b0e08e3645e27e807c1f17ef9fff3 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Mon, 21 Oct 2024 18:24:23 -0700 Subject: [PATCH 109/160] Implement Group Policy settings for PSResource Repository (#1730) --- doBuild.ps1 | 12 +- global.json | 2 +- src/InstallPSResourceGetPolicyDefinitions.ps1 | 88 +++++++ src/PSGet.Format.ps1xml | 2 + src/PSResourceRepository.adml | 20 ++ src/PSResourceRepository.admx | 45 ++++ src/code/FindHelper.cs | 142 +++++++---- src/code/GroupPolicyRepositoryEnforcement.cs | 226 ++++++++++++++++++ src/code/InstallHelper.cs | 14 ++ src/code/InternalHooks.cs | 6 + src/code/PSRepositoryInfo.cs | 8 +- src/code/PublishHelper.cs | 13 + src/code/RepositorySettings.cs | 34 ++- src/code/ServerFactory.cs | 3 +- test/GroupPolicyEnforcement.Tests.ps1 | 104 ++++++++ 15 files changed, 660 insertions(+), 59 deletions(-) create mode 100644 src/InstallPSResourceGetPolicyDefinitions.ps1 create mode 100644 src/PSResourceRepository.adml create mode 100644 src/PSResourceRepository.admx create mode 100644 src/code/GroupPolicyRepositoryEnforcement.cs create mode 100644 test/GroupPolicyEnforcement.Tests.ps1 diff --git a/doBuild.ps1 b/doBuild.ps1 index 6eab1d609..15293d99b 100644 --- a/doBuild.ps1 +++ b/doBuild.ps1 @@ -34,9 +34,19 @@ function DoBuild Copy-Item -Path "./LICENSE" -Dest "$BuildOutPath" # Copy notice - Write-Verbose -Verbose -Message "Copying ThirdPartyNotices.txt to '$BuildOutPath'" + Write-Verbose -Verbose -Message "Copying Notice.txt to '$BuildOutPath'" Copy-Item -Path "./Notice.txt" -Dest "$BuildOutPath" + # Copy Group Policy files + Write-Verbose -Verbose -Message "Copying InstallPSResourceGetPolicyDefinitions.ps1 to '$BuildOutPath'" + Copy-Item -Path "${SrcPath}/InstallPSResourceGetPolicyDefinitions.ps1" -Dest "$BuildOutPath" -Force + + Write-Verbose -Verbose -Message "Copying PSResourceRepository.adml to '$BuildOutPath'" + Copy-Item -Path "${SrcPath}/PSResourceRepository.adml" -Dest "$BuildOutPath" -Force + + Write-Verbose -Verbose -Message "Copying PSResourceRepository.admx to '$BuildOutPath'" + Copy-Item -Path "${SrcPath}/PSResourceRepository.admx" -Dest "$BuildOutPath" -Force + # Build and place binaries if ( Test-Path "${SrcPath}/code" ) { Write-Verbose -Verbose -Message "Building assembly and copying to '$BuildOutPath'" diff --git a/global.json b/global.json index 8acf2f3a1..120c43985 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.400" + "version": "8.0.403" } } diff --git a/src/InstallPSResourceGetPolicyDefinitions.ps1 b/src/InstallPSResourceGetPolicyDefinitions.ps1 new file mode 100644 index 000000000..e0f2d15d4 --- /dev/null +++ b/src/InstallPSResourceGetPolicyDefinitions.ps1 @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.Synopsis + Group Policy tools use administrative template files (.admx, .adml) to populate policy settings in the user interface. + This allows administrators to manage registry-based policy settings. + This script installs PSResourceGet Administrative Templates for Windows. +.Notes + The PSResourceRepository.admx and PSResourceRepository.adml files are + expected to be at the location specified by the Path parameter with default value of the location of this script. +#> +[CmdletBinding()] +param +( + [ValidateNotNullOrEmpty()] + [string] $Path = $PSScriptRoot +) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +function Test-Elevated +{ + [CmdletBinding()] + [OutputType([bool])] + Param() + + # if the current Powershell session was called with administrator privileges, + # the Administrator Group's well-known SID will show up in the Groups for the current identity. + # Note that the SID won't show up unless the process is elevated. + return (([Security.Principal.WindowsIdentity]::GetCurrent()).Groups -contains "S-1-5-32-544") +} +$IsWindowsOs = $PSHOME.EndsWith('\WindowsPowerShell\v1.0', [System.StringComparison]::OrdinalIgnoreCase) -or $IsWindows + +if (-not $IsWindowsOs) +{ + throw 'This script must be run on Windows.' +} + +if (-not (Test-Elevated)) +{ + throw 'This script must be run from an elevated process.' +} + +if ([System.Management.Automation.Platform]::IsNanoServer) +{ + throw 'Group policy definitions are not supported on Nano Server.' +} + +$admxName = 'PSResourceRepository.admx' +$admlName = 'PSResourceRepository.adml' +$admx = Get-Item -Path (Join-Path -Path $Path -ChildPath $admxName) +$adml = Get-Item -Path (Join-Path -Path $Path -ChildPath $admlName) +$admxTargetPath = Join-Path -Path $env:WINDIR -ChildPath "PolicyDefinitions" +$admlTargetPath = Join-Path -Path $admxTargetPath -ChildPath "en-US" + +$files = @($admx, $adml) +foreach ($file in $files) +{ + if (-not (Test-Path -Path $file)) + { + throw "Could not find $($file.Name) at $Path" + } +} + +Write-Verbose "Copying $admx to $admxTargetPath" +Copy-Item -Path $admx -Destination $admxTargetPath -Force +$admxTargetFullPath = Join-Path -Path $admxTargetPath -ChildPath $admxName +if (Test-Path -Path $admxTargetFullPath) +{ + Write-Verbose "$admxName was installed successfully" +} +else +{ + Write-Error "Could not install $admxName" +} + +Write-Verbose "Copying $adml to $admlTargetPath" +Copy-Item -Path $adml -Destination $admlTargetPath -Force +$admlTargetFullPath = Join-Path -Path $admlTargetPath -ChildPath $admlName +if (Test-Path -Path $admlTargetFullPath) +{ + Write-Verbose "$admlName was installed successfully" +} +else +{ + Write-Error "Could not install $admlName" +} diff --git a/src/PSGet.Format.ps1xml b/src/PSGet.Format.ps1xml index 18140432b..e81b0728a 100644 --- a/src/PSGet.Format.ps1xml +++ b/src/PSGet.Format.ps1xml @@ -94,6 +94,7 @@ + @@ -102,6 +103,7 @@ Uri Trusted Priority + IsAllowedByPolicy diff --git a/src/PSResourceRepository.adml b/src/PSResourceRepository.adml new file mode 100644 index 000000000..5da96427f --- /dev/null +++ b/src/PSResourceRepository.adml @@ -0,0 +1,20 @@ + + + + PSResourceGet Repository Policy + This creates an allow list of repositories for PSResourceGet. + + + At least Windows 11* + PSResourceGet Repository Policy + This creates an allow list of repositories for PSResourceGet. + PSResourceGet Repository Policies + + + + Please create an allow list of repositories using a name value pair like following: Name=PSGallery;Uri=https://www.powershellgallery.com/api/v2 + + + + + diff --git a/src/PSResourceRepository.admx b/src/PSResourceRepository.admx new file mode 100644 index 000000000..6e8db3ec6 --- /dev/null +++ b/src/PSResourceRepository.admx @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/code/FindHelper.cs b/src/code/FindHelper.cs index a2a14fa72..327d0e024 100644 --- a/src/code/FindHelper.cs +++ b/src/code/FindHelper.cs @@ -9,6 +9,7 @@ using System.Management.Automation; using System.Net; using System.Runtime.ExceptionServices; +using System.Text.RegularExpressions; using System.Threading; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets @@ -182,9 +183,24 @@ public IEnumerable FindByResourceName( } List repositoryNamesToSearch = new List(); + for (int i = 0; i < repositoriesToSearch.Count; i++) { PSRepositoryInfo currentRepository = repositoriesToSearch[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + repositoryNamesToSearch.Add(currentRepository.Name); _networkCredential = Utils.SetNetworkCredential(currentRepository, _networkCredential, _cmdletPassedIn); ServerApiCall currentServer = ServerFactory.GetServer(currentRepository, _cmdletPassedIn, _networkCredential); @@ -230,7 +246,7 @@ public IEnumerable FindByResourceName( // Scenarios: Find-PSResource -Name "pkg" -Repository *Gallery -> write error if only if pkg wasn't found in any matching repositories. foreach(string pkgName in pkgsDiscovered) { - var msg = repository == null ? $"Package '{pkgName}' could not be found in any registered repositories." : + var msg = repository == null ? $"Package '{pkgName}' could not be found in any registered repositories." : $"Package '{pkgName}' could not be found in registered repositories: '{string.Join(", ", repositoryNamesToSearch)}'."; _cmdletPassedIn.WriteError(new ErrorRecord( @@ -355,6 +371,20 @@ public IEnumerable FindByCommandOrDscResource( for (int i = 0; i < repositoriesToSearch.Count; i++) { PSRepositoryInfo currentRepository = repositoriesToSearch[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + repositoryNamesToSearch.Add(currentRepository.Name); _networkCredential = Utils.SetNetworkCredential(currentRepository, _networkCredential, _cmdletPassedIn); ServerApiCall currentServer = ServerFactory.GetServer(currentRepository, _cmdletPassedIn, _networkCredential); @@ -393,9 +423,9 @@ public IEnumerable FindByCommandOrDscResource( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"'{String.Join(", ", _tag)}' could not be found", currentResult.exception), - "FindCmdOrDscToPSResourceObjFailure", - ErrorCategory.NotSpecified, + new ResourceNotFoundException($"'{String.Join(", ", _tag)}' could not be found", currentResult.exception), + "FindCmdOrDscToPSResourceObjFailure", + ErrorCategory.NotSpecified, this); if (shouldReportErrorForEachRepo) @@ -421,7 +451,7 @@ public IEnumerable FindByCommandOrDscResource( if (!isCmdOrDSCTagFound && !shouldReportErrorForEachRepo) { string parameterName = isSearchingForCommands ? "CommandName" : "DSCResourceName"; - var msg = repository == null ? $"Package with {parameterName} '{String.Join(", ", _tag)}' could not be found in any registered repositories." : + var msg = repository == null ? $"Package with {parameterName} '{String.Join(", ", _tag)}' could not be found in any registered repositories." : $"Package with {parameterName} '{String.Join(", ", _tag)}' could not be found in registered repositories: '{string.Join(", ", repositoryNamesToSearch)}'."; _cmdletPassedIn.WriteError(new ErrorRecord( @@ -545,6 +575,20 @@ public IEnumerable FindByTag( for (int i = 0; i < repositoriesToSearch.Count; i++) { PSRepositoryInfo currentRepository = repositoriesToSearch[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + repositoryNamesToSearch.Add(currentRepository.Name); _networkCredential = Utils.SetNetworkCredential(currentRepository, _networkCredential, _cmdletPassedIn); ServerApiCall currentServer = ServerFactory.GetServer(currentRepository, _cmdletPassedIn, _networkCredential); @@ -591,9 +635,9 @@ public IEnumerable FindByTag( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Tags '{String.Join(", ", _tag)}' could not be found" , currentResult.exception), - "FindTagConvertToPSResourceFailure", - ErrorCategory.InvalidResult, + new ResourceNotFoundException($"Tags '{String.Join(", ", _tag)}' could not be found" , currentResult.exception), + "FindTagConvertToPSResourceFailure", + ErrorCategory.InvalidResult, this); if (shouldReportErrorForEachRepo) @@ -615,7 +659,7 @@ public IEnumerable FindByTag( if (!isTagFound && !shouldReportErrorForEachRepo) { - var msg = repository == null ? $"Package with Tags '{String.Join(", ", _tag)}' could not be found in any registered repositories." : + var msg = repository == null ? $"Package with Tags '{String.Join(", ", _tag)}' could not be found in any registered repositories." : $"Package with Tags '{String.Join(", ", _tag)}' could not be found in registered repositories: '{string.Join(", ", repositoryNamesToSearch)}'."; _cmdletPassedIn.WriteError(new ErrorRecord( @@ -665,9 +709,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - currentResult.exception, - "FindAllConvertToPSResourceFailure", - ErrorCategory.InvalidResult, + currentResult.exception, + "FindAllConvertToPSResourceFailure", + ErrorCategory.InvalidResult, this)); continue; @@ -721,9 +765,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - currentResult.exception, - "FindNameGlobbingConvertToPSResourceFailure", - ErrorCategory.InvalidResult, + currentResult.exception, + "FindNameGlobbingConvertToPSResourceFailure", + ErrorCategory.InvalidResult, this)); continue; @@ -772,9 +816,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { // This scenario may occur when the package version requested is unlisted. _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Package with name '{pkgName}'{tagsAsString} could not be found in repository '{repository.Name}'"), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{pkgName}'{tagsAsString} could not be found in repository '{repository.Name}'"), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this)); continue; @@ -783,9 +827,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - currentResult.exception, - "FindNameConvertToPSResourceFailure", - ErrorCategory.ObjectNotFound, + currentResult.exception, + "FindNameConvertToPSResourceFailure", + ErrorCategory.ObjectNotFound, this)); continue; @@ -804,8 +848,8 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( new ArgumentException("Name cannot contain or equal wildcard when using specific version."), - "InvalidWildCardUsage", - ErrorCategory.InvalidOperation, + "InvalidWildCardUsage", + ErrorCategory.InvalidOperation, this)); continue; @@ -846,9 +890,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { // This scenario may occur when the package version requested is unlisted. _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Package with name '{pkgName}', version '{_nugetVersion.ToNormalizedString()}'{tagsAsString} could not be found in repository '{repository.Name}'"), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{pkgName}', version '{_nugetVersion.ToNormalizedString()}'{tagsAsString} could not be found in repository '{repository.Name}'"), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this)); continue; @@ -858,8 +902,8 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( currentResult.exception, - "FindVersionConvertToPSResourceFailure", - ErrorCategory.ObjectNotFound, + "FindVersionConvertToPSResourceFailure", + ErrorCategory.ObjectNotFound, this)); continue; @@ -879,8 +923,8 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( new ArgumentException("Name cannot contain or equal wildcard when using version range"), - "InvalidWildCardUsage", - ErrorCategory.InvalidOperation, + "InvalidWildCardUsage", + ErrorCategory.InvalidOperation, this)); } else @@ -897,7 +941,7 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( new ArgumentException("-Tag parameter cannot be specified when using version range."), - "InvalidWildCardUsage", + "InvalidWildCardUsage", ErrorCategory.InvalidOperation, this)); @@ -925,14 +969,14 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( currentResult.exception, - "FindVersionGlobbingConvertToPSResourceFailure", - ErrorCategory.ObjectNotFound, + "FindVersionGlobbingConvertToPSResourceFailure", + ErrorCategory.ObjectNotFound, this)); continue; } - // Check to see if version falls within version range + // Check to see if version falls within version range PSResourceInfo foundPkg = currentResult.returnedObject; string versionStr = $"{foundPkg.Version}"; if (foundPkg.IsPrerelease) @@ -1014,7 +1058,7 @@ private bool TryAddToPackagesFound(PSResourceInfo foundPkg) _packagesFound.Add(foundPkg.Name, new List { foundPkgVersion }); addedToHash = true; } - + _cmdletPassedIn.WriteDebug($"Found package '{foundPkg.Name}' version '{foundPkg.Version}'"); return addedToHash; @@ -1070,9 +1114,9 @@ internal IEnumerable FindDependencyPackages( { // This scenario may occur when the package version requested is unlisted. _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'"), - "DependencyPackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'"), + "DependencyPackageNotFound", + ErrorCategory.ObjectNotFound, this)); yield return null; continue; @@ -1081,9 +1125,9 @@ internal IEnumerable FindDependencyPackages( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'", currentResult.exception), - "DependencyPackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'", currentResult.exception), + "DependencyPackageNotFound", + ErrorCategory.ObjectNotFound, this)); yield return null; continue; @@ -1130,9 +1174,9 @@ internal IEnumerable FindDependencyPackages( if (responses.IsFindResultsEmpty()) { _cmdletPassedIn.WriteError(new ErrorRecord( - new InvalidOrEmptyResponse($"Dependency package with name {dep.Name} and version range {dep.VersionRange} could not be found in repository '{repository.Name}"), - "FindDepPackagesFindVersionGlobbingFailure", - ErrorCategory.InvalidResult, + new InvalidOrEmptyResponse($"Dependency package with name {dep.Name} and version range {dep.VersionRange} could not be found in repository '{repository.Name}"), + "FindDepPackagesFindVersionGlobbingFailure", + ErrorCategory.InvalidResult, this)); yield return null; continue; @@ -1143,16 +1187,16 @@ internal IEnumerable FindDependencyPackages( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Dependency package with name '{dep.Name}' and version range '{dep.VersionRange}' could not be found in repository '{repository.Name}'", currentResult.exception), - "DependencyPackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Dependency package with name '{dep.Name}' and version range '{dep.VersionRange}' could not be found in repository '{repository.Name}'", currentResult.exception), + "DependencyPackageNotFound", + ErrorCategory.ObjectNotFound, this)); - + yield return null; continue; } - // Check to see if version falls within version range + // Check to see if version falls within version range PSResourceInfo foundDep = currentResult.returnedObject; string depVersionStr = $"{foundDep.Version}"; if (foundDep.IsPrerelease) { diff --git a/src/code/GroupPolicyRepositoryEnforcement.cs b/src/code/GroupPolicyRepositoryEnforcement.cs new file mode 100644 index 000000000..ac4f7ee98 --- /dev/null +++ b/src/code/GroupPolicyRepositoryEnforcement.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using Microsoft.Win32; + +namespace Microsoft.PowerShell.PSResourceGet.Cmdlets +{ + /// + /// This class is used to enforce group policy for repositories. + /// + public class GroupPolicyRepositoryEnforcement + { + const string userRoot = "HKEY_CURRENT_USER"; + const string psresourcegetGPPath = @"SOFTWARE\Policies\Microsoft\PSResourceGetRepository"; + const string gpRootPath = @"Software\Microsoft\Windows\CurrentVersion\Group Policy Objects"; + + private GroupPolicyRepositoryEnforcement() + { + } + + /// + /// This method is used to see if the group policy is enabled. + /// + /// + /// True if the group policy is enabled, false otherwise. + public static bool IsGroupPolicyEnabled() + { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) + { + // Always return false for non-Windows platforms and Group Policy is not available. + return false; + } + + if (InternalHooks.EnableGPRegistryHook) + { + return InternalHooks.GPEnabledStatus; + } + + var values = ReadGPFromRegistry(); + + if (values is not null && values.Count > 0) + { + return true; + } + + return false; + } + + /// + /// Get allowed list of URIs for allowed repositories. + /// + /// Array of allowed URIs. + /// Thrown when the group policy is not enabled. + public static Uri[]? GetAllowedRepositoryURIs() + { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) + { + throw new PlatformNotSupportedException("Group policy is only supported on Windows."); + } + + if (InternalHooks.EnableGPRegistryHook) + { + var uri = new Uri(InternalHooks.AllowedUri); + return new Uri[] { uri }; + } + + if (!IsGroupPolicyEnabled()) + { + return null; + } + else + { + List allowedUris = new List(); + + var allowedRepositories = ReadGPFromRegistry(); + + if (allowedRepositories is not null && allowedRepositories.Count > 0) + { + foreach (var allowedRepository in allowedRepositories) + { + allowedUris.Add(allowedRepository.Value); + } + } + + return allowedUris.ToArray(); + } + } + + internal static bool IsRepositoryAllowed(Uri repositoryUri) + { + bool isAllowed = false; + + if(GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled()) + { + var allowedList = GroupPolicyRepositoryEnforcement.GetAllowedRepositoryURIs(); + + if (allowedList != null && allowedList.Length > 0) + { + isAllowed = allowedList.Any(uri => uri.Equals(repositoryUri)); + } + } + else + { + isAllowed = true; + } + + return isAllowed; + } + + private static List>? ReadGPFromRegistry() + { + List> allowedRepositories = new List>(); + + using (var key = Registry.CurrentUser.OpenSubKey(gpRootPath)) + { + if (key is null) + { + return null; + } + + var subKeys = key.GetSubKeyNames(); + + if (subKeys is null) + { + return null; + } + + foreach (var subKey in subKeys) + { + if (subKey.EndsWith("Machine")) + { + continue; + } + + using (var psrgKey = key.OpenSubKey(subKey + "\\" + psresourcegetGPPath)) + { + if (psrgKey is null) + { + // this GPO does not have PSResourceGetRepository key + continue; + } + + var valueNames = psrgKey.GetValueNames(); + + // This means it is disabled + if (valueNames is null || valueNames.Length == 0 || valueNames.Length == 1 && valueNames[0].Equals("**delvals.", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + else + { + foreach (var valueName in valueNames) + { + if (valueName.Equals("**delvals.", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var value = psrgKey.GetValue(valueName); + + if (value is null) + { + throw new InvalidOperationException("Invalid registry value."); + } + + string valueString = value.ToString(); + var kvRegValue = ConvertRegValue(valueString); + allowedRepositories.Add(kvRegValue); + } + } + } + } + } + + return allowedRepositories; + } + + private static KeyValuePair ConvertRegValue(string regValue) + { + if (string.IsNullOrEmpty(regValue)) + { + throw new ArgumentException("Registry value is empty."); + } + + var KvPairs = regValue.Split(new char[] { ';' }); + + string? nameValue = null; + string? uriValue = null; + + foreach (var kvPair in KvPairs) + { + var kv = kvPair.Split(new char[] { '=' }, 2); + + if (kv.Length != 2) + { + throw new InvalidOperationException("Invalid registry value."); + } + + if (kv[0].Equals("Name", StringComparison.OrdinalIgnoreCase)) + { + nameValue = kv[1]; + } + + if (kv[0].Equals("Uri", StringComparison.OrdinalIgnoreCase)) + { + uriValue = kv[1]; + } + } + + if (nameValue is not null && uriValue is not null) + { + return new KeyValuePair(nameValue, new Uri(uriValue)); + } + else + { + throw new InvalidOperationException("Invalid registry value."); + } + } + } +} \ No newline at end of file diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 4d86af911..5f4b0773c 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -269,6 +269,20 @@ private List ProcessRepositories( for (int i = 0; i < listOfRepositories.Count && _pkgNamesToInstall.Count > 0; i++) { PSRepositoryInfo currentRepository = listOfRepositories[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + string repoName = currentRepository.Name; sourceTrusted = currentRepository.Trusted || trustRepository; diff --git a/src/code/InternalHooks.cs b/src/code/InternalHooks.cs index 9b5ea0294..2078d1d41 100644 --- a/src/code/InternalHooks.cs +++ b/src/code/InternalHooks.cs @@ -9,6 +9,12 @@ public class InternalHooks { internal static bool InvokedFromCompat; + internal static bool EnableGPRegistryHook; + + internal static bool GPEnabledStatus; + + internal static string AllowedUri; + public static void SetTestHook(string property, object value) { var fieldInfo = typeof(InternalHooks).GetField(property, BindingFlags.Static | BindingFlags.NonPublic); diff --git a/src/code/PSRepositoryInfo.cs b/src/code/PSRepositoryInfo.cs index 7faa02bc4..b660c6690 100644 --- a/src/code/PSRepositoryInfo.cs +++ b/src/code/PSRepositoryInfo.cs @@ -27,7 +27,7 @@ public enum APIVersion #region Constructor - public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCredentialInfo credentialInfo, APIVersion apiVersion) + public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCredentialInfo credentialInfo, APIVersion apiVersion, bool allowed) { Name = name; Uri = uri; @@ -35,6 +35,7 @@ public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCred Trusted = trusted; CredentialInfo = credentialInfo; ApiVersion = apiVersion; + IsAllowedByPolicy = allowed; } #endregion @@ -88,6 +89,11 @@ public enum RepositoryProviderType /// public APIVersion ApiVersion { get; } + // + /// is it allowed by policy + /// + public bool IsAllowedByPolicy { get; set; } + #endregion } } diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 6e15dd459..5470da611 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -355,6 +355,19 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe return; } + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(repository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{repository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + return; + } + _networkCredential = Utils.SetNetworkCredential(repository, _networkCredential, _cmdletPassedIn); // Check if dependencies already exist within the repo if: diff --git a/src/code/RepositorySettings.cs b/src/code/RepositorySettings.cs index b42e7e581..97e9f80b7 100644 --- a/src/code/RepositorySettings.cs +++ b/src/code/RepositorySettings.cs @@ -9,6 +9,7 @@ using System.Management.Automation; using System.Xml; using System.Xml.Linq; +using Microsoft.PowerShell.PSResourceGet.Cmdlets; using static Microsoft.PowerShell.PSResourceGet.UtilClasses.PSRepositoryInfo; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses @@ -279,7 +280,9 @@ public static PSRepositoryInfo Add(string repoName, Uri repoUri, int repoPriorit throw new PSInvalidOperationException(String.Format("Adding to repository store failed: {0}", e.Message)); } - return new PSRepositoryInfo(repoName, repoUri, repoPriority, repoTrusted, repoCredentialInfo, apiVersion); + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(repoUri) : true; + + return new PSRepositoryInfo(repoName, repoUri, repoPriority, repoTrusted, repoCredentialInfo, apiVersion, isAllowed); } /// @@ -436,13 +439,22 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio node.Attribute(PSCredentialInfo.SecretNameAttribute).Value); } + if (GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled()) + { + var allowedList = GroupPolicyRepositoryEnforcement.GetAllowedRepositoryURIs(); + + } + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(thisUrl) : true; + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); updatedRepo = new PSRepositoryInfo(repoName, thisUrl, Int32.Parse(node.Attribute("Priority").Value), Boolean.Parse(node.Attribute("Trusted").Value), thisCredentialInfo, - resolvedAPIVersion); + resolvedAPIVersion, + isAllowed); // Close the file root.Save(FullRepositoryPath); @@ -522,6 +534,9 @@ public static List Remove(string[] repoNames, out string[] err string attributeUrlUriName = urlAttributeExists ? "Url" : "Uri"; Uri repoUri = new Uri(node.Attribute(attributeUrlUriName).Value); + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(repoUri) : true; + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(repoUri); removedRepos.Add( new PSRepositoryInfo(repo, @@ -529,7 +544,8 @@ public static List Remove(string[] repoNames, out string[] err Int32.Parse(node.Attribute("Priority").Value), Boolean.Parse(node.Attribute("Trusted").Value), repoCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true))); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true), + isAllowed)); // Remove item from file node.Remove(); @@ -654,12 +670,16 @@ public static List Read(string[] repoNames, out string[] error } RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(thisUrl) : true; + PSRepositoryInfo currentRepoItem = new PSRepositoryInfo(repo.Attribute("Name").Value, thisUrl, Int32.Parse(repo.Attribute("Priority").Value), Boolean.Parse(repo.Attribute("Trusted").Value), thisCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), repo.Attribute("APIVersion").Value, ignoreCase: true)); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), repo.Attribute("APIVersion").Value, ignoreCase: true), + isAllowed); foundRepos.Add(currentRepoItem); } @@ -758,12 +778,16 @@ public static List Read(string[] repoNames, out string[] error } RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(thisUrl) : true; + PSRepositoryInfo currentRepoItem = new PSRepositoryInfo(node.Attribute("Name").Value, thisUrl, Int32.Parse(node.Attribute("Priority").Value), Boolean.Parse(node.Attribute("Trusted").Value), thisCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true)); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true), + isAllowed); foundRepos.Add(currentRepoItem); } diff --git a/src/code/ServerFactory.cs b/src/code/ServerFactory.cs index 5127346ab..10c43b1a3 100644 --- a/src/code/ServerFactory.cs +++ b/src/code/ServerFactory.cs @@ -1,11 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using Microsoft.PowerShell.PSResourceGet.UtilClasses; using System.Collections; using System.Management.Automation; -using System.Management.Automation.Runspaces; using System.Net; +using Microsoft.PowerShell.PSResourceGet.UtilClasses; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets { diff --git a/test/GroupPolicyEnforcement.Tests.ps1 b/test/GroupPolicyEnforcement.Tests.ps1 new file mode 100644 index 000000000..2353056e2 --- /dev/null +++ b/test/GroupPolicyEnforcement.Tests.ps1 @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft Corporation. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$checkIfWindows = [System.Environment]::OSVersion.Platform -eq 'Win32NT' + +# Add Pester test to check the API for GroupPolicyEnforcement +Describe 'GroupPolicyEnforcement API Tests' -Tags 'CI' { + + It 'IsGroupPolicyEnabled should return the correct policy enforcement status' -Skip:(-not $checkIfWindows) { + $actualStatus = [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::IsGroupPolicyEnabled() + $actualStatus | Should -BeFalse + } + + It 'IsGroupPolicyEnabled should return false on non-windows platform' -Skip:$checkIfWindows { + [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::IsGroupPolicyEnabled() | Should -BeFalse + } + + It 'GetAllowedRepositoryURIs return null if Group Policy is not enabled' -Skip:(-not $checkIfWindows) { + [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::GetAllowedRepositoryURIs() | Should -BeNullOrEmpty + + try { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', "https://www.example.com/") + + $allowedReps = [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::GetAllowedRepositoryURIs() + $allowedReps.AbsoluteUri | Should -Be @("https://www.example.com/") + } + finally { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', $null) + } + } +} + +Describe 'GroupPolicyEnforcement Cmdlet Tests' -Tags 'CI' { + BeforeEach { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', "https://www.example.com/") + } + + AfterEach { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', $null) + } + + It 'Get-PSResourceRepository lists the allowed repository' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' + $psrep = Get-PSResourceRepository -Name 'Example' + $psrep | Should -Not -BeNullOrEmpty + $psrep.IsAllowedByPolicy | Should -BeTrue + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } + + It 'Find-PSResource is blocked by policy' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' -ApiVersion 'v3' + { Find-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Throw "Repository 'PSGallery' is not allowed by Group Policy." + + # Allow PSGallery and it should not fail + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', " https://www.powershellgallery.com/api/v2") + { Find-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Not -Throw + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } + + It 'Install-PSResource is blocked by policy' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' -ApiVersion 'v3' + { Install-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Throw "Repository 'PSGallery' is not allowed by Group Policy." + + # Allow PSGallery and it should not fail + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', " https://www.powershellgallery.com/api/v2") + { Install-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop -TrustRepository} | Should -Not -Throw + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } + + It 'Save-PSResource is blocked by policy' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' -ApiVersion 'v3' + { Save-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Throw "Repository 'PSGallery' is not allowed by Group Policy." + + # Allow PSGallery and it should not fail + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', " https://www.powershellgallery.com/api/v2") + { Save-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop -TrustRepository} | Should -Not -Throw + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } +} From 5bbc687de1199bb8ada9adf050bb80f07e81131d Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Mon, 21 Oct 2024 22:42:36 -0400 Subject: [PATCH 110/160] Bugfix - Correctly match package names from local repos (#1731) * Fix pattern matching code to match specific pkgName.version.nupkg not packageName* to avoid unwanted wildcard clashes * remove unused code * remove unused code some more * use Regex instead of WildcardPattern for FindVersion() too * add tests * remove comment * add ErrorVariable reference * addres PR feedback about comments and debug message * add comment --- src/code/LocalServerApiCalls.cs | 91 +++++++++++++------ .../FindPSResourceLocal.Tests.ps1 | 32 +++++++ 2 files changed, 94 insertions(+), 29 deletions(-) diff --git a/src/code/LocalServerApiCalls.cs b/src/code/LocalServerApiCalls.cs index 484ed351b..b22b3efb6 100644 --- a/src/code/LocalServerApiCalls.cs +++ b/src/code/LocalServerApiCalls.cs @@ -255,32 +255,48 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu FindResults findResponse = new FindResults(); errRecord = null; - WildcardPattern pkgNamePattern = new WildcardPattern($"{packageName}.*", WildcardOptions.IgnoreCase); NuGetVersion latestVersion = new NuGetVersion("0.0.0.0"); String latestVersionPath = String.Empty; string actualPkgName = packageName; + // this regex pattern matches packageName followed by a version (4 digit or 3 with prerelease word) + string regexPattern = $"{packageName}" + @".\d+\.\d+\.\d+(?:-\w+|.\d)*.nupkg"; + Regex rx = new Regex(regexPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); + _cmdletPassedIn.WriteDebug($"package file name pattern to be searched for is: {regexPattern}"); + foreach (string path in Directory.GetFiles(Repository.Uri.LocalPath)) { string packageFullName = Path.GetFileName(path); + MatchCollection matches = rx.Matches(packageFullName); + if (matches.Count == 0) + { + continue; + } - if (!String.IsNullOrEmpty(packageFullName) && pkgNamePattern.IsMatch(packageFullName)) + Match match = matches[0]; + + GroupCollection groups = match.Groups; + if (groups.Count == 0) { - NuGetVersion nugetVersion = GetInfoFromFileName(packageFullName: packageFullName, packageName: packageName, actualName: out actualPkgName, out errRecord); - _cmdletPassedIn.WriteDebug($"Version parsed as '{nugetVersion}'"); + continue; + } - if (errRecord != null) - { - return findResponse; - } + Capture group = groups[0]; - if ((!nugetVersion.IsPrerelease || includePrerelease) && (nugetVersion > latestVersion)) + NuGetVersion nugetVersion = GetInfoFromFileName(packageFullName: packageFullName, packageName: packageName, actualName: out actualPkgName, out errRecord); + _cmdletPassedIn.WriteDebug($"Version parsed as '{nugetVersion}'"); + + if (errRecord != null) + { + return findResponse; + } + + if ((!nugetVersion.IsPrerelease || includePrerelease) && (nugetVersion > latestVersion)) + { + if (nugetVersion > latestVersion) { - if (nugetVersion > latestVersion) - { - latestVersion = nugetVersion; - latestVersionPath = path; - } + latestVersion = nugetVersion; + latestVersionPath = path; } } } @@ -371,29 +387,46 @@ private FindResults FindVersionHelper(string packageName, string version, string return findResponse; } - WildcardPattern pkgNamePattern = new WildcardPattern($"{packageName}.*", WildcardOptions.IgnoreCase); + // this regex pattern matches packageName followed by the requested version + string regexPattern = $"{packageName}.{requiredVersion.ToNormalizedString()}" + @".nupkg"; + Regex rx = new Regex(regexPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); + _cmdletPassedIn.WriteDebug($"pattern is: {regexPattern}"); string pkgPath = String.Empty; string actualPkgName = String.Empty; + foreach (string path in Directory.GetFiles(Repository.Uri.LocalPath)) { string packageFullName = Path.GetFileName(path); - if (!String.IsNullOrEmpty(packageFullName) && pkgNamePattern.IsMatch(packageFullName)) + MatchCollection matches = rx.Matches(packageFullName); + if (matches.Count == 0) { - NuGetVersion nugetVersion = GetInfoFromFileName(packageFullName: packageFullName, packageName: packageName, actualName: out actualPkgName, out errRecord); - _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{nugetVersion}'"); + continue; + } - if (errRecord != null) - { - return findResponse; - } + Match match = matches[0]; - if (nugetVersion == requiredVersion) - { - _cmdletPassedIn.WriteDebug("Found matching version"); - string pkgFullName = $"{actualPkgName}.{nugetVersion.ToString()}.nupkg"; - pkgPath = Path.Combine(Repository.Uri.LocalPath, pkgFullName); - break; - } + GroupCollection groups = match.Groups; + if (groups.Count == 0) + { + continue; + } + + Capture group = groups[0]; + + NuGetVersion nugetVersion = GetInfoFromFileName(packageFullName: packageFullName, packageName: packageName, actualName: out actualPkgName, out errRecord); + _cmdletPassedIn.WriteDebug($"Version parsed as '{nugetVersion}'"); + + if (errRecord != null) + { + return findResponse; + } + + if (nugetVersion == requiredVersion) + { + _cmdletPassedIn.WriteDebug("Found matching version"); + string pkgFullName = $"{actualPkgName}.{nugetVersion.ToString()}.nupkg"; + pkgPath = Path.Combine(Repository.Uri.LocalPath, pkgFullName); + break; } } diff --git a/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 index dda4ddf50..b6e43716a 100644 --- a/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 @@ -14,6 +14,7 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $localUNCRepo = 'psgettestlocal3' $testModuleName = "test_local_mod" $testModuleName2 = "test_local_mod2" + $similarTestModuleName = "test_local_mod.similar" $commandName = "cmd1" $dscResourceName = "dsc1" $prereleaseLabel = "" @@ -31,6 +32,9 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { New-TestModule -moduleName $testModuleName2 -repoName $localRepo -packageVersion "5.0.0" -prereleaseLabel "" -tags $tagsEscaped New-TestModule -moduleName $testModuleName2 -repoName $localRepo -packageVersion "5.2.5" -prereleaseLabel $prereleaseLabel -tags $tagsEscaped + + New-TestModule -moduleName $similarTestModuleName -repoName $localRepo -packageVersion "4.0.0" -prereleaseLabel "" -tags $tagsEscaped + New-TestModule -moduleName $similarTestModuleName -repoName $localRepo -packageVersion "5.0.0" -prereleaseLabel "" -tags $tagsEscaped } AfterAll { @@ -74,6 +78,7 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { } It "should not find resource given nonexistant Name" { + # FindName() $res = Find-PSResource -Name NonExistantModule -Repository $localRepo -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty $err.Count | Should -Not -Be 0 @@ -81,6 +86,17 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $res | Should -BeNullOrEmpty } + It "find resource given specific Name when another package with similar name (with period) exists" { + # FindName() + $res = Find-PSResource -Name $testModuleName -Repository $localRepo + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0" + + $res = Find-PSResource -Name $similarTestModuleName -Repository $localRepo + $res.Name | Should -Be $similarTestModuleName + $res.Version | Should -Be "5.0.0" + } + It "find resource(s) given wildcard Name" { # FindNameGlobbing $res = Find-PSResource -Name "test_local_*" -Repository $localRepo @@ -129,6 +145,22 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $resPrerelease.Prerelease | Should -Be "alpha001" } + It "find resource given specific Name when another package with similar name (with period) exists" { + # FindVersion() + # Package $testModuleName version 4.0.0 does not exist + # previously if Find-PSResource -Version against local repo did not find that package's version it kept looking at + # similar named packages and would fault. This test is to ensure only the specified package and its version is checked + $res = Find-PSResource -Name $testModuleName -Version "4.0.0" -Repository $localRepo -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -Not -Be 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $res | Should -BeNullOrEmpty + + $res = Find-PSResource -Name $similarTestModuleName -Version "4.0.0" -Repository $localRepo + $res.Name | Should -Be $similarTestModuleName + $res.Version | Should -Be "4.0.0" + } + It "find resources, including Prerelease version resources, when given Prerelease parameter" { # FindVersionGlobbing() $resWithoutPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $localRepo From d7d580847a310b56ed758353494de30acbfe424c Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 22 Oct 2024 11:39:38 -0700 Subject: [PATCH 111/160] Update changelog (for v1.0.6) and release notes (v.1.0.6, v1.0.5, v1.0.4, v1.0.4.1) (#1732) --- CHANGELOG/1.0.md | 4 +++ src/Microsoft.PowerShell.PSResourceGet.psd1 | 29 +++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/CHANGELOG/1.0.md b/CHANGELOG/1.0.md index 7719f3586..f4956b644 100644 --- a/CHANGELOG/1.0.md +++ b/CHANGELOG/1.0.md @@ -1,5 +1,9 @@ # 1.0 Changelog +## [1.0.6](https://github.com/PowerShell/PSResourceGet/compare/v1.0.5..v1.0.6) - 2024-10-10 + +- Bump System.Text.Json to 8.0.5 + ## [1.0.5](https://github.com/PowerShell/PSResourceGet/compare/v1.0.4.1...v1.0.5) - 2024-05-13 ### Bug Fixes diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index 9fea3b273..42581a573 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -84,6 +84,35 @@ - Fix for swallowed exceptions (#1569) - Fix for PSResourceGet not working in Constrained Languange Mode (#1564) +## 1.0.6 + +- Bump System.Text.Json to 8.0.5 + +## [1.0.5](https://github.com/PowerShell/PSResourceGet/compare/v1.0.4.1...v1.0.5) - 2024-05-13 + +### Bug Fixes +- Update `nuget.config` to use PowerShell packages feed (#1649) +- Refactor V2ServerAPICalls and NuGetServerAPICalls to use object-oriented query/filter builder (#1645 Thanks @sean-r-williams!) +- Fix unnecessary `and` for version globbing in V2ServerAPICalls (#1644 Thanks again @sean-r-williams!) +- Fix requiring `tags` in server response (#1627 Thanks @evelyn-bi!) +- Add 10 minute timeout to HTTPClient (#1626) +- Fix save script without `-IncludeXml` (#1609, #1614 Thanks @o-l-a-v!) +- PAT token fix to translate into HttpClient 'Basic Authorization'(#1599 Thanks @gerryleys!) +- Fix incorrect request url when installing from ADO (#1597 Thanks @antonyoni!) +- Improved exception handling (#1569) +- Ensure that .NET methods are not called in order to enable use in Constrained Language Mode (#1564) +- PSResourceGet packaging update + +## [1.0.4.1](https://github.com/PowerShell/PSResourceGet/compare/v1.0.4...v1.0.4.1) - 2024-04-05 + +- PSResourceGet packaging update + +## [1.0.4](https://github.com/PowerShell/PSResourceGet/compare/v1.0.3...v1.0.4) - 2024-04-05 + +### Patch + +- Dependency package updates + ## 1.0.3 ### Bug Fixes From de7732e6d6b4f309530dad4c695c253b4f567fed Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 22 Oct 2024 12:05:52 -0700 Subject: [PATCH 112/160] Update version, changelog, releasenotes for 1.1.0-RC1 (#1733) --- CHANGELOG/preview.md | 17 +++++++++++++++++ src/Microsoft.PowerShell.PSResourceGet.psd1 | 19 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG/preview.md b/CHANGELOG/preview.md index 539630d0d..a27564140 100644 --- a/CHANGELOG/preview.md +++ b/CHANGELOG/preview.md @@ -1,3 +1,20 @@ +## [1.1.0-RC1](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-preview2...v1.1.0-RC1) - 2024-09-13 + +### New Features + +- Group Policy configurations for enabling or disabling PSResource repositories (#1730) + +### Bug Fixes + +- Fix packaging name matching when searching in local repositories (#1731) +- `Compress-PSResource` `-PassThru` now passes `FileInfo` instead of string (#1720) +- Fix for `Compress-PSResource` not properly compressing scripts (#1719) +- Add `AcceptLicense` to Save-PSResource (#1718 Thanks @o-l-a-v!) +- Better support for NuGet v2 feeds (#1713 Thanks @o-l-a-v!) +- Better handling of `-WhatIf` support in `Install-PSResource` (#1531 Thanks @o-l-a-v!) +- Fix for some nupkgs failing to extract due to empty directories (#1707 Thanks @o-l-a-v!) +- Fix for searching for `-Name *` in `Find-PSResource` (#1706 Thanks @o-l-a-v!) + ## [1.1.0-preview2](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-preview1...v1.1.0-preview2) - 2024-09-13 ### New Features diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index 42581a573..3a497db4e 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -46,7 +46,7 @@ 'udres') PrivateData = @{ PSData = @{ - Prerelease = 'preview2' + Prerelease = 'RC1' Tags = @('PackageManagement', 'PSEdition_Desktop', 'PSEdition_Core', @@ -56,6 +56,23 @@ ProjectUri = 'https://go.microsoft.com/fwlink/?LinkId=828955' LicenseUri = 'https://go.microsoft.com/fwlink/?LinkId=829061' ReleaseNotes = @' +## 1.1.0-RC1 + +### New Features + +- Group Policy configurations for enabling or disabling PSResource repositories (#1730) + +### Bug Fixes + +- Fix packaging name matching when searching in local repositories (#1731) +- `Compress-PSResource` `-PassThru` now passes `FileInfo` instead of string (#1720) +- Fix for `Compress-PSResource` not properly compressing scripts (#1719) +- Add `AcceptLicense` to Save-PSResource (#1718 Thanks @o-l-a-v!) +- Better support for NuGet v2 feeds (#1713 Thanks @o-l-a-v!) +- Better handling of `-WhatIf` support in `Install-PSResource` (#1531 Thanks @o-l-a-v!) +- Fix for some nupkgs failing to extract due to empty directories (#1707 Thanks @o-l-a-v!) +- Fix for searching for `-Name *` in `Find-PSResource` (#1706 Thanks @o-l-a-v!) + ## 1.1.0-preview2 ### New Features From 94dff16df6ff618ea035169583e2c44589ff3caf Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 29 Oct 2024 10:17:18 -0700 Subject: [PATCH 113/160] Add 'ToLower' before name comparison and split (#1738) --- .ci/test.yml | 4 ++-- buildtools.psm1 | 18 +++++++++++++++++- src/code/LocalServerApiCalls.cs | 8 ++++---- src/code/Utils.cs | 18 ++++++++++++++++-- .../FindPSResourceLocal.Tests.ps1 | 10 ++++++++++ .../InstallPSResourceLocal.Tests.ps1 | 12 +++++------- .../PublishPSResource.Tests.ps1 | 15 ++++++++++----- 7 files changed, 64 insertions(+), 21 deletions(-) diff --git a/.ci/test.yml b/.ci/test.yml index c5a20e40e..cd8c8c88f 100644 --- a/.ci/test.yml +++ b/.ci/test.yml @@ -48,7 +48,7 @@ jobs: - ${{ parameters.powershellExecutable }}: | $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' Write-Verbose -Verbose "Install Microsoft.PowerShell.PSResourceGet to temp module path" - Save-Module -Name Microsoft.PowerShell.PSResourceGet -MinimumVersion 0.9.0-rc1 -Path $modulePath -AllowPrerelease -Force + Save-Module -Name Microsoft.PowerShell.PSResourceGet -Path $modulePath -Force -Verbose Write-Verbose -Verbose "Install Pester 4.X to temp module path" Save-Module -Name "Pester" -MaximumVersion 4.99 -Path $modulePath -Force displayName: Install Microsoft.PowerShell.PSResourceGet and Pester @@ -59,7 +59,7 @@ jobs: Write-Verbose -Verbose "Importing build utilities (buildtools.psd1)" Import-Module -Name (Join-Path -Path '${{ parameters.buildDirectory }}' -ChildPath 'buildtools.psd1') -Force # - Install-ModulePackageForTest -PackagePath "$(System.ArtifactsDirectory)" + Install-ModulePackageForTest -PackagePath "$(System.ArtifactsDirectory)" -ErrorAction stop -Verbose displayName: Install module for test from downloaded artifact workingDirectory: ${{ parameters.buildDirectory }} diff --git a/buildtools.psm1 b/buildtools.psm1 index 1df564eb1..9aab19832 100644 --- a/buildtools.psm1 +++ b/buildtools.psm1 @@ -120,7 +120,23 @@ function Install-ModulePackageForTest { } Write-Verbose -Verbose -Message "Installing module $($config.ModuleName) to build output path $installationPath" - Save-PSResource -Name $config.ModuleName -Repository $localRepoName -Path $installationPath -SkipDependencyCheck -Prerelease -Confirm:$false -TrustRepository + $psgetModuleBase = (get-command save-psresource).Module.ModuleBase + $psgetVersion = (get-command save-psresource).Module.Version.ToString() + $psgetPrerelease = (get-command find-psresource).module.PrivateData.PSData.Prerelease + Write-Verbose -Verbose -Message "PSResourceGet module base imported: $psgetModuleBase" + Write-Verbose -Verbose -Message "PSResourceGet version base imported: $psgetVersion" + Write-Verbose -Verbose -Message "PSResourceGet prerelease base imported: $psgetPrerelease" + #Save-PSResource -Name $config.ModuleName -Repository $localRepoName -Path $installationPath -SkipDependencyCheck -Prerelease -Confirm:$false -TrustRepository + + Register-PSRepository -Name $localRepoName -SourceLocation $packagePathWithNupkg -InstallationPolicy Trusted -Verbose + $psgetv2ModuleBase = (get-command save-module).Module.ModuleBase + $psgetv2Version = (get-command save-module).Module.Version.ToString() + $psgetv2Prerelease = (get-command save-module).module.PrivateData.PSData.Prerelease + Write-Verbose -Verbose -Message "PowerShellGet module base imported: $psgetv2ModuleBase" + Write-Verbose -Verbose -Message "PowerShellGet version base imported: $psgetv2Version" + Write-Verbose -Verbose -Message "PowerShellGet prerelease base imported: $psgetv2Prerelease" + Save-Module -Name $config.ModuleName -Repository $localRepoName -Path $installationPath -Force -Verbose -AllowPrerelease -Confirm:$false + Unregister-PSRepository -Name $localRepoName Write-Verbose -Verbose -Message "Unregistering local package repo: $localRepoName" Unregister-PSResourceRepository -Name $localRepoName -Confirm:$false diff --git a/src/code/LocalServerApiCalls.cs b/src/code/LocalServerApiCalls.cs index b22b3efb6..1dca86200 100644 --- a/src/code/LocalServerApiCalls.cs +++ b/src/code/LocalServerApiCalls.cs @@ -685,7 +685,7 @@ private Hashtable GetMetadataFromNupkg(string packageName, string packagePath, s string psd1FilePath = String.Empty; string ps1FilePath = String.Empty; string nuspecFilePath = String.Empty; - Utils.GetMetadataFilesFromPath(tempDiscoveryPath, packageName, out psd1FilePath, out ps1FilePath, out nuspecFilePath); + Utils.GetMetadataFilesFromPath(tempDiscoveryPath, packageName, out psd1FilePath, out ps1FilePath, out nuspecFilePath, out string properCasingPkgName); List pkgTags = new List(); @@ -710,7 +710,7 @@ private Hashtable GetMetadataFromNupkg(string packageName, string packagePath, s pkgMetadata.Add("ProjectUri", projectUri); pkgMetadata.Add("IconUri", iconUri); pkgMetadata.Add("ReleaseNotes", releaseNotes); - pkgMetadata.Add("Id", packageName); + pkgMetadata.Add("Id", properCasingPkgName); pkgMetadata.Add(_fileTypeKey, Utils.MetadataFileType.ModuleManifest); pkgTags.AddRange(pkgHashTags); @@ -730,7 +730,7 @@ private Hashtable GetMetadataFromNupkg(string packageName, string packagePath, s } pkgMetadata = parsedScript.ToHashtable(); - pkgMetadata.Add("Id", packageName); + pkgMetadata.Add("Id", properCasingPkgName); pkgMetadata.Add(_fileTypeKey, Utils.MetadataFileType.ScriptFile); pkgTags.AddRange(pkgMetadata["Tags"] as string[]); @@ -916,7 +916,7 @@ private NuGetVersion GetInfoFromFileName(string packageFullName, string packageN string[] packageWithoutName = packageFullName.ToLower().Split(new string[]{ $"{packageName.ToLower()}." }, StringSplitOptions.RemoveEmptyEntries); string packageVersionAndExtension = packageWithoutName[0]; - string[] originalFileNameParts = packageFullName.Split(new string[]{ $".{packageVersionAndExtension}" }, StringSplitOptions.RemoveEmptyEntries); + string[] originalFileNameParts = packageFullName.ToLower().Split(new string[]{ $".{packageVersionAndExtension.ToLower()}" }, StringSplitOptions.RemoveEmptyEntries); actualName = String.IsNullOrEmpty(originalFileNameParts[0]) ? packageName : originalFileNameParts[0]; int extensionDot = packageVersionAndExtension.LastIndexOf('.'); string version = packageVersionAndExtension.Substring(0, extensionDot); diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 769329d84..da80d3f42 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1172,11 +1172,12 @@ internal static HashSet GetInstalledPackages(List pathsToSearch, return pkgsInstalledOnMachine; } - internal static void GetMetadataFilesFromPath(string dirPath, string packageName, out string psd1FilePath, out string ps1FilePath, out string nuspecFilePath) + internal static void GetMetadataFilesFromPath(string dirPath, string packageName, out string psd1FilePath, out string ps1FilePath, out string nuspecFilePath, out string properCasingPkgName) { psd1FilePath = String.Empty; ps1FilePath = String.Empty; nuspecFilePath = String.Empty; + properCasingPkgName = packageName; var discoveredFiles = Directory.GetFiles(dirPath, "*.*", SearchOption.AllDirectories); string pkgNamePattern = $"{packageName}*"; @@ -1185,16 +1186,29 @@ internal static void GetMetadataFilesFromPath(string dirPath, string packageName { if (rgx.IsMatch(file)) { - if (file.EndsWith("psd1")) + string fileName = Path.GetFileName(file); + if (fileName.EndsWith("psd1")) { + if (string.Compare($"{packageName}.psd1", fileName, StringComparison.OrdinalIgnoreCase) == 0) + { + properCasingPkgName = Path.GetFileNameWithoutExtension(file); + } psd1FilePath = file; } else if (file.EndsWith("nuspec")) { + if (string.Compare($"{packageName}.nuspec", fileName, StringComparison.OrdinalIgnoreCase) == 0) + { + properCasingPkgName = Path.GetFileNameWithoutExtension(file); + } nuspecFilePath = file; } else if (file.EndsWith("ps1")) { + if (string.Compare($"{packageName}.ps1", fileName, StringComparison.OrdinalIgnoreCase) == 0) + { + properCasingPkgName = Path.GetFileNameWithoutExtension(file); + } ps1FilePath = file; } } diff --git a/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 index b6e43716a..ec0a9d873 100644 --- a/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 @@ -14,6 +14,7 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $localUNCRepo = 'psgettestlocal3' $testModuleName = "test_local_mod" $testModuleName2 = "test_local_mod2" + $testModuleName3 = "Test_Local_Mod3" $similarTestModuleName = "test_local_mod.similar" $commandName = "cmd1" $dscResourceName = "dsc1" @@ -33,6 +34,8 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { New-TestModule -moduleName $testModuleName2 -repoName $localRepo -packageVersion "5.0.0" -prereleaseLabel "" -tags $tagsEscaped New-TestModule -moduleName $testModuleName2 -repoName $localRepo -packageVersion "5.2.5" -prereleaseLabel $prereleaseLabel -tags $tagsEscaped + New-TestModule -moduleName $testModuleName3 -repoName $localRepo -packageVersion "1.0.0" -prereleaseLabel "" -tags @() + New-TestModule -moduleName $similarTestModuleName -repoName $localRepo -packageVersion "4.0.0" -prereleaseLabel "" -tags $tagsEscaped New-TestModule -moduleName $similarTestModuleName -repoName $localRepo -packageVersion "5.0.0" -prereleaseLabel "" -tags $tagsEscaped } @@ -48,6 +51,13 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $res.Version | Should -Be "5.0.0" } + It "find resource given specific Name with incorrect casing (should return correct casing)" { + # FindName() + $res = Find-PSResource -Name "test_local_mod3" -Repository $localRepo + $res.Name | Should -Be $testModuleName3 + $res.Version | Should -Be "1.0.0" + } + It "find resource given specific Name, Version null (module) from a UNC-based local repository" { # FindName() $res = Find-PSResource -Name $testModuleName -Repository $localUNCRepo diff --git a/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 index 437fcd1ad..f5ec1d02b 100644 --- a/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 @@ -25,7 +25,7 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { Register-LocalRepos Register-LocalTestNupkgsRepo - $prereleaseLabel = "alpha001" + $prereleaseLabel = "Alpha001" $tags = @() New-TestModule -moduleName $testModuleName -repoName $localRepo -packageVersion "1.0.0" -prereleaseLabel "" -tags $tags @@ -131,12 +131,12 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { $pkg.Version | Should -Be "3.0.0" } - It "Install resource with latest (including prerelease) version given Prerelease parameter" { + It "Install resource with latest (including prerelease) version given Prerelease parameter (prerelease casing should be correct)" { Install-PSResource -Name $testModuleName -Prerelease -Repository $localRepo -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.2.5" - $pkg.Prerelease | Should -Be "alpha001" + $pkg.Prerelease | Should -Be "Alpha001" } It "Install resource with cmdlet names from a module already installed with -NoClobber (should not clobber)" { @@ -205,7 +205,7 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { # Windows only It "Install resource under AllUsers scope - Windows only" -Skip:(!((Get-IsWindows) -and (Test-IsAdmin))) { - Install-PSResource -Name $testModuleName -Repository $localRepo -TrustRepository -Scope AllUsers -Verbose + Install-PSResource -Name $testModuleName -Repository $localRepo -TrustRepository -Scope AllUsers $pkg = Get-InstalledPSResource $testModuleName -Scope AllUsers $pkg.Name | Should -Be $testModuleName $pkg.InstalledLocation.ToString().Contains("Program Files") | Should -Be $true @@ -290,10 +290,8 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { $nupkgName = "Microsoft.Web.Webview2" $nupkgVersion = "1.0.2792.45" $repoPath = Get-PSResourceRepository $localNupkgRepo - Write-Verbose -Verbose "repoPath $($repoPath.Uri)" $searchPkg = Find-PSResource -Name $nupkgName -Version $nupkgVersion -Repository $localNupkgRepo - Write-Verbose -Verbose "search name: $($searchPkg.Name)" - Install-PSResource -Name $nupkgName -Version $nupkgVersion -Repository $localNupkgRepo -TrustRepository -Verbose + Install-PSResource -Name $nupkgName -Version $nupkgVersion -Repository $localNupkgRepo -TrustRepository $pkg = Get-InstalledPSResource $nupkgName $pkg.Name | Should -Be $nupkgName $pkg.Version | Should -Be $nupkgVersion diff --git a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 index 1b2a70d84..84e941dfa 100644 --- a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 @@ -270,16 +270,20 @@ Describe "Test Publish-PSResource" -tags 'CI' { $dependencyVersion = "2.0.0" New-ModuleManifest -Path (Join-Path -Path $script:DependencyModuleBase -ChildPath "$script:DependencyModuleName.psd1") -ModuleVersion $dependencyVersion -Description "$script:DependencyModuleName module" - Publish-PSResource -Path $script:DependencyModuleBase + Publish-PSResource -Path $script:DependencyModuleBase -Repository $testRepository2 + $pkg1 = Find-PSResource $script:DependencyModuleName -Repository $testRepository2 + $pkg1 | Should -Not -BeNullOrEmpty + $pkg1.Version | Should -Be $dependencyVersion # Create module to test $version = "1.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -RequiredModules @(@{ModuleName = 'PackageManagement'; ModuleVersion = '2.0.0' }) - Publish-PSResource -Path $script:PublishModuleBase + Publish-PSResource -Path $script:PublishModuleBase -Repository $testRepository2 - $nupkg = Get-ChildItem $script:repositoryPath | select-object -Last 1 - $nupkg.Name | Should -Be "$script:PublishModuleName.$version.nupkg" + $pkg2 = Find-PSResource $script:DependencyModuleName -Repository $testRepository2 + $pkg2 | Should -Not -BeNullOrEmpty + $pkg2.Version | Should -Be $dependencyVersion } It "Publish a module with a dependency that is not published, should throw" { @@ -685,7 +689,7 @@ Describe "Test Publish-PSResource" -tags 'CI' { $expectedPath = Join-Path -Path $script:repositoryPath2 -ChildPath "$ParentModuleName.$ParentVersion.nupkg" (Get-ChildItem $script:repositoryPath2).FullName | Should -Contain $expectedPath } - +<# It "Publish a module with required modules (both in string format and hashtable format)" { # look at functions in test utils for creating a module with prerelease $ModuleName = "ParentModule" @@ -720,4 +724,5 @@ Describe "Test Publish-PSResource" -tags 'CI' { $expectedPath = Join-Path -Path $script:repositoryPath2 -ChildPath "$ModuleName.$ModuleVersion.nupkg" (Get-ChildItem $script:repositoryPath2).FullName | Should -Contain $expectedPath } +#> } From 455722deff1c18f8e39fad47bd09bcc53b37ca33 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Tue, 29 Oct 2024 11:44:47 -0700 Subject: [PATCH 114/160] Update to use OCI spec APIs for Container Registry (#1737) --- src/code/ContainerRegistryServerAPICalls.cs | 94 ++++++++------------- 1 file changed, 36 insertions(+), 58 deletions(-) diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs index f5310d38c..f9dc060a9 100644 --- a/src/code/ContainerRegistryServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -43,7 +43,7 @@ internal class ContainerRegistryServerAPICalls : ServerApiCall const string containerRegistryOAuthTokenUrlTemplate = "https://{0}/oauth2/token"; // 0 - registry const string containerRegistryManifestUrlTemplate = "https://{0}/v2/{1}/manifests/{2}"; // 0 - registry, 1 - repo(modulename), 2 - tag(version) const string containerRegistryBlobDownloadUrlTemplate = "https://{0}/v2/{1}/blobs/{2}"; // 0 - registry, 1 - repo(modulename), 2 - layer digest - const string containerRegistryFindImageVersionUrlTemplate = "https://{0}/acr/v1/{1}/_tags{2}"; // 0 - registry, 1 - repo(modulename), 2 - /tag(version) + const string containerRegistryFindImageVersionUrlTemplate = "https://{0}/v2/{1}/tags/list"; // 0 - registry, 1 - repo(modulename) const string containerRegistryStartUploadTemplate = "https://{0}/v2/{1}/blobs/uploads/"; // 0 - registry, 1 - packagename const string containerRegistryEndUploadTemplate = "https://{0}{1}&digest=sha256:{2}"; // 0 - registry, 1 - location, 2 - digest @@ -413,6 +413,7 @@ internal string GetContainerRegistryAccessToken(out ErrorRecord errRecord) else { _cmdletPassedIn.WriteVerbose("Repository is unauthenticated"); + return null; } } @@ -572,27 +573,19 @@ internal async Task GetContainerRegistryBlobAsync(string packageNam /// internal JObject FindContainerRegistryImageTags(string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) { - /* response returned looks something like: - * "registry": "myregistry.azurecr.io" - * "imageName": "hello-world" - * "tags": [ - * { - * ""name"": ""1.0.0"", - * ""digest"": ""sha256:92c7f9c92844bbbb5d0a101b22f7c2a7949e40f8ea90c8b3bc396879d95e899a"", - * ""createdTime"": ""2023-12-23T18:06:48.9975733Z"", - * ""lastUpdateTime"": ""2023-12-23T18:06:48.9975733Z"", - * ""signed"": false, - * ""changeableAttributes"": { - * ""deleteEnabled"": true, - * ""writeEnabled"": true, - * ""readEnabled"": true, - * ""listEnabled"": true - * } - * }] - */ + /* + { + "name": "", + "tags": [ + "", + "", + "" + ] + } + */ _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindContainerRegistryImageTags()"); string resolvedVersion = string.Equals(version, "*", StringComparison.OrdinalIgnoreCase) ? null : $"/{version}"; - string findImageUrl = string.Format(containerRegistryFindImageVersionUrlTemplate, Registry, packageName, resolvedVersion); + string findImageUrl = string.Format(containerRegistryFindImageVersionUrlTemplate, Registry, packageName); var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); return GetHttpResponseJObjectUsingDefaultHeaders(findImageUrl, HttpMethod.Get, defaultHeaders, out errRecord); } @@ -1664,51 +1657,36 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp foreach (var pkgVersionTagInfo in allPkgVersions) { - using (JsonDocument pkgVersionEntry = JsonDocument.Parse(pkgVersionTagInfo.ToString())) + string pkgVersionString = pkgVersionTagInfo.ToString(); + // determine if the package version that is a repository tag is a valid NuGetVersion + if (!NuGetVersion.TryParse(pkgVersionString, out NuGetVersion pkgVersion)) { - JsonElement rootDom = pkgVersionEntry.RootElement; - if (!rootDom.TryGetProperty("name", out JsonElement pkgVersionElement)) - { - errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain version element ('name') for package '{packageName}' in '{Repository.Name}'."), - "FindNameFailure", - ErrorCategory.InvalidResult, - this); - - return null; - } - - string pkgVersionString = pkgVersionElement.ToString(); - // determine if the package version that is a repository tag is a valid NuGetVersion - if (!NuGetVersion.TryParse(pkgVersionString, out NuGetVersion pkgVersion)) - { - errRecord = new ErrorRecord( - new ArgumentException($"Version {pkgVersionString} to be parsed from metadata is not a valid NuGet version."), - "FindNameFailure", - ErrorCategory.InvalidArgument, - this); + errRecord = new ErrorRecord( + new ArgumentException($"Version {pkgVersionString} to be parsed from metadata is not a valid NuGet version."), + "FindNameFailure", + ErrorCategory.InvalidArgument, + this); - return null; - } + return null; + } - _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); - if (isSpecificVersionSearch) + if (isSpecificVersionSearch) + { + if (pkgVersion.ToNormalizedString() == specificVersion.ToNormalizedString()) { - if (pkgVersion.ToNormalizedString() == specificVersion.ToNormalizedString()) - { - // accounts for FindVersion() scenario - sortedPkgs.Add(pkgVersion, pkgVersionString); - break; - } + // accounts for FindVersion() scenario + sortedPkgs.Add(pkgVersion, pkgVersionString); + break; } - else + } + else + { + if (versionRange.Satisfies(pkgVersion) && (!pkgVersion.IsPrerelease || includePrerelease)) { - if (versionRange.Satisfies(pkgVersion) && (!pkgVersion.IsPrerelease || includePrerelease)) - { - // accounts for FindVersionGlobbing() and FindName() scenario - sortedPkgs.Add(pkgVersion, pkgVersionString); - } + // accounts for FindVersionGlobbing() and FindName() scenario + sortedPkgs.Add(pkgVersion, pkgVersionString); } } } From ceb1ce6ddc9c969c1f09061b916cde45deae52ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20R=C3=B8nnestad=20Birkeland?= <6450056+o-l-a-v@users.noreply.github.com> Date: Wed, 30 Oct 2024 22:44:56 +0100 Subject: [PATCH 115/160] Changelog: Highlight that #1713 was for fixing Azure DevOps feeds of type NuGet v2 (#1736) --- CHANGELOG/1.0.md | 8 +++++--- CHANGELOG/preview.md | 9 +++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG/1.0.md b/CHANGELOG/1.0.md index f4956b644..20348dea3 100644 --- a/CHANGELOG/1.0.md +++ b/CHANGELOG/1.0.md @@ -7,6 +7,7 @@ ## [1.0.5](https://github.com/PowerShell/PSResourceGet/compare/v1.0.4.1...v1.0.5) - 2024-05-13 ### Bug Fixes + - Update `nuget.config` to use PowerShell packages feed (#1649) - Refactor V2ServerAPICalls and NuGetServerAPICalls to use object-oriented query/filter builder (#1645 Thanks @sean-r-williams!) - Fix unnecessary `and` for version globbing in V2ServerAPICalls (#1644 Thanks again @sean-r-williams!) @@ -422,7 +423,7 @@ All tests have been reviewed and rewritten as needed. ## 3.0.0-beta8 -### New Features +### New Features - Add `-Type` parameter to `Install-PSResource` - Add 'sudo' check for admin privileges in Unix in `Install-PSResource` @@ -436,7 +437,7 @@ All tests have been reviewed and rewritten as needed. ## 3.0.0-beta7 -### New Features +### New Features - Completed functionality for `Update-PSResource` - `Input-Object` parameter for `Install-PSResource` @@ -499,4 +500,5 @@ All tests have been reviewed and rewritten as needed. ## 3.0.0-beta1 ### BREAKING CHANGE -- Preview version of PowerShellGet. Many features are not fully implemented yet. Please see https://devblogs.microsoft.com/powershell/powershellget-3-0-preview1 for more details. + +- Preview version of PowerShellGet. Many features are not fully implemented yet. Please see for more details. diff --git a/CHANGELOG/preview.md b/CHANGELOG/preview.md index a27564140..28a334ffc 100644 --- a/CHANGELOG/preview.md +++ b/CHANGELOG/preview.md @@ -1,3 +1,5 @@ +# Preview Changelog + ## [1.1.0-RC1](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-preview2...v1.1.0-RC1) - 2024-09-13 ### New Features @@ -8,9 +10,9 @@ - Fix packaging name matching when searching in local repositories (#1731) - `Compress-PSResource` `-PassThru` now passes `FileInfo` instead of string (#1720) -- Fix for `Compress-PSResource` not properly compressing scripts (#1719) +- Fix for `Compress-PSResource` not properly compressing scripts (#1719) - Add `AcceptLicense` to Save-PSResource (#1718 Thanks @o-l-a-v!) -- Better support for NuGet v2 feeds (#1713 Thanks @o-l-a-v!) +- Better support for Azure DevOps Artifacts NuGet v2 feeds (#1713 Thanks @o-l-a-v!) - Better handling of `-WhatIf` support in `Install-PSResource` (#1531 Thanks @o-l-a-v!) - Fix for some nupkgs failing to extract due to empty directories (#1707 Thanks @o-l-a-v!) - Fix for searching for `-Name *` in `Find-PSResource` (#1706 Thanks @o-l-a-v!) @@ -34,8 +36,7 @@ ### New Features -- Support for Azure Container Registries (#1495, #1497-#1499, #1501, #1502, #1505, #1522, #1545, #1548, #1550, #1554, #1560, #1567, -#1573, #1576, #1587, #1588, #1589, #1594, #1598, #1600, #1602, #1604, #1615) +- Support for Azure Container Registries (#1495, #1497-#1499, #1501, #1502, #1505, #1522, #1545, #1548, #1550, #1554, #1560, #1567, #1573, #1576, #1587, #1588, #1589, #1594, #1598, #1600, #1602, #1604, #1615) ### Bug Fixes From b4a7dabdb6269afdcb01c9484c4d932414686d83 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 30 Oct 2024 18:22:32 -0400 Subject: [PATCH 116/160] Fix package name regex to account for 4 digit versions (#1739) --- src/code/LocalServerApiCalls.cs | 2 +- .../FindPSResourceLocal.Tests.ps1 | 12 ++++++++++++ ...valonia.1.0.1518.46-preview.230207.17.nupkg | Bin 0 -> 227713 bytes 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 test/testFiles/testNupkgs/webview2.avalonia.1.0.1518.46-preview.230207.17.nupkg diff --git a/src/code/LocalServerApiCalls.cs b/src/code/LocalServerApiCalls.cs index 1dca86200..551a3fb3d 100644 --- a/src/code/LocalServerApiCalls.cs +++ b/src/code/LocalServerApiCalls.cs @@ -260,7 +260,7 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu string actualPkgName = packageName; // this regex pattern matches packageName followed by a version (4 digit or 3 with prerelease word) - string regexPattern = $"{packageName}" + @".\d+\.\d+\.\d+(?:-\w+|.\d)*.nupkg"; + string regexPattern = $"{packageName}" + @".\d+\.\d+\.\d+(?:[a-zA-Z0-9-.]+|.\d)?.nupkg"; Regex rx = new Regex(regexPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); _cmdletPassedIn.WriteDebug($"package file name pattern to be searched for is: {regexPattern}"); diff --git a/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 index ec0a9d873..1377cac2f 100644 --- a/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 @@ -19,8 +19,10 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $commandName = "cmd1" $dscResourceName = "dsc1" $prereleaseLabel = "" + $localNupkgRepo = "localNupkgRepo" Get-NewPSResourceRepositoryFile Register-LocalRepos + Register-LocalTestNupkgsRepo $localRepoUriAddress = Join-Path -Path $TestDrive -ChildPath "testdir" $tagsEscaped = @("'Test'", "'Tag2'", "'PSCommand_$cmdName'", "'PSDscResource_$dscName'") @@ -319,4 +321,14 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $err.Count | Should -Not -Be 0 $err[0].FullyQualifiedErrorId | Should -BeExactly "FindTagsPackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } + + It "find package where prerelease label includes digits and period (i.e prerelease label is not just words)" { + $nupkgName = "WebView2.Avalonia" + $nupkgVersion = "1.0.1518.46" + $prereleaseLabel = "preview.230207.17" + $res = Find-PSResource -Name $nupkgName -Prerelease -Repository $localNupkgRepo + $res.Name | Should -Be $nupkgName + $res.Version | Should -Be $nupkgVersion + $res.Prerelease | Should -Be $prereleaseLabel + } } diff --git a/test/testFiles/testNupkgs/webview2.avalonia.1.0.1518.46-preview.230207.17.nupkg b/test/testFiles/testNupkgs/webview2.avalonia.1.0.1518.46-preview.230207.17.nupkg new file mode 100644 index 0000000000000000000000000000000000000000..e47c80773678e0a95c9eef2565c755502671e181 GIT binary patch literal 227713 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⋙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 z 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 R<5b`eA^6D}4T3r3e*gpEg988n literal 0 HcmV?d00001 From 8a339b08e6df701777bade12e35f43467a8b755d Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Wed, 30 Oct 2024 16:35:23 -0700 Subject: [PATCH 117/160] Update pipeline files to be signed (#1735) --- .pipelines/PSResourceGet-Official.yml | 2 +- CHANGELOG/preview.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pipelines/PSResourceGet-Official.yml b/.pipelines/PSResourceGet-Official.yml index bc6a10f14..d096cf33e 100644 --- a/.pipelines/PSResourceGet-Official.yml +++ b/.pipelines/PSResourceGet-Official.yml @@ -152,7 +152,7 @@ extends: inputs: command: 'sign' signing_profile: external_distribution - files_to_sign: '**\*.psd1;**\*.psm1;**\*.ps1xml;**\Microsoft*.dll' + files_to_sign: '**\*.ps1;**\*.psd1;**\*.psm1;**\*.ps1xml;**\Microsoft*.dll' search_root: $(signSrcPath) - pwsh: | diff --git a/CHANGELOG/preview.md b/CHANGELOG/preview.md index 28a334ffc..f248fd013 100644 --- a/CHANGELOG/preview.md +++ b/CHANGELOG/preview.md @@ -1,6 +1,6 @@ # Preview Changelog -## [1.1.0-RC1](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-preview2...v1.1.0-RC1) - 2024-09-13 +## [1.1.0-RC1](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-preview2...v1.1.0-RC1) - 2024-10-22 ### New Features From de278b496be8971a20080027142bcead9ebccf7c Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 30 Oct 2024 16:37:02 -0700 Subject: [PATCH 118/160] Prepend prefix for MAR operations (#1741) --- src/code/ContainerRegistryServerAPICalls.cs | 22 ++++++++++++--- src/code/InternalHooks.cs | 2 ++ src/code/PSRepositoryInfo.cs | 13 +++++++++ src/code/PublishHelper.cs | 11 ++++++++ src/code/RepositorySettings.cs | 4 +-- ...SResourceContainerRegistryServer.Tests.ps1 | 18 +++++++++++++ ...SResourceContainerRegistryServer.Tests.ps1 | 27 ++++++++++++++++++- ...SResourceContainerRegistryServer.Tests.ps1 | 21 +++++++++++++++ 8 files changed, 111 insertions(+), 7 deletions(-) diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs index f9dc060a9..d32b2300f 100644 --- a/src/code/ContainerRegistryServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -286,7 +286,8 @@ public override Stream InstallPackage(string packageName, string packageVersion, return results; } - results = InstallVersion(packageName, packageVersion, out errRecord); + string packageNameForInstall = PrependMARPrefix(packageName); + results = InstallVersion(packageNameForInstall, packageVersion, out errRecord); return results; } @@ -1601,13 +1602,14 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp string registryUrl = Repository.Uri.ToString(); string packageNameLowercase = packageName.ToLower(); + string packageNameForFind = PrependMARPrefix(packageNameLowercase); string containerRegistryAccessToken = GetContainerRegistryAccessToken(out errRecord); if (errRecord != null) { return emptyHashResponses; } - var foundTags = FindContainerRegistryImageTags(packageNameLowercase, "*", containerRegistryAccessToken, out errRecord); + var foundTags = FindContainerRegistryImageTags(packageNameForFind, "*", containerRegistryAccessToken, out errRecord); if (errRecord != null || foundTags == null) { return emptyHashResponses; @@ -1616,7 +1618,7 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp List latestVersionResponse = new List(); List allVersionsList = foundTags["tags"].ToList(); - SortedDictionary sortedQualifyingPkgs = GetPackagesWithRequiredVersion(allVersionsList, versionType, versionRange, requiredVersion, packageNameLowercase, includePrerelease, out errRecord); + SortedDictionary sortedQualifyingPkgs = GetPackagesWithRequiredVersion(allVersionsList, versionType, versionRange, requiredVersion, packageNameForFind, includePrerelease, out errRecord); if (errRecord != null) { return emptyHashResponses; @@ -1627,7 +1629,7 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp foreach (var pkgVersionTag in pkgsInDescendingOrder) { string exactTagVersion = pkgVersionTag.Value.ToString(); - Hashtable metadata = GetContainerRegistryMetadata(packageNameLowercase, exactTagVersion, containerRegistryAccessToken, out errRecord); + Hashtable metadata = GetContainerRegistryMetadata(packageNameForFind, exactTagVersion, containerRegistryAccessToken, out errRecord); if (errRecord != null || metadata.Count == 0) { return emptyHashResponses; @@ -1694,6 +1696,18 @@ private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionTyp return sortedPkgs; } + private string PrependMARPrefix(string packageName) + { + string prefix = string.IsNullOrEmpty(InternalHooks.MARPrefix) ? PSRepositoryInfo.MARPrefix : InternalHooks.MARPrefix; + + // If the repostitory is MAR and its not a wildcard search, we need to prefix the package name with MAR prefix. + string updatedPackageName = Repository.IsMARRepository() && packageName.Trim() != "*" + ? string.Concat(prefix, packageName) + : packageName; + + return updatedPackageName; + } + #endregion } } diff --git a/src/code/InternalHooks.cs b/src/code/InternalHooks.cs index 2078d1d41..0578485ca 100644 --- a/src/code/InternalHooks.cs +++ b/src/code/InternalHooks.cs @@ -15,6 +15,8 @@ public class InternalHooks internal static string AllowedUri; + internal static string MARPrefix; + public static void SetTestHook(string property, object value) { var fieldInfo = typeof(InternalHooks).GetField(property, BindingFlags.Static | BindingFlags.NonPublic); diff --git a/src/code/PSRepositoryInfo.cs b/src/code/PSRepositoryInfo.cs index b660c6690..b74d52cff 100644 --- a/src/code/PSRepositoryInfo.cs +++ b/src/code/PSRepositoryInfo.cs @@ -11,6 +11,10 @@ namespace Microsoft.PowerShell.PSResourceGet.UtilClasses /// public sealed class PSRepositoryInfo { + #region constants + internal const string MARPrefix = "psresource/"; + #endregion + #region Enums public enum APIVersion @@ -95,5 +99,14 @@ public enum RepositoryProviderType public bool IsAllowedByPolicy { get; set; } #endregion + + #region Methods + + internal bool IsMARRepository() + { + return (ApiVersion == APIVersion.ContainerRegistry && Uri.Host.Contains("mcr.microsoft.com")); + } + + #endregion } } diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 5470da611..0eec8e0d9 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -368,6 +368,17 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe return; } + if (repository.IsMARRepository()) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{repository.Name}' is a MAR repository and cannot be published to."), + "MARRepositoryPublishError", + ErrorCategory.PermissionDenied, + this)); + + return; + } + _networkCredential = Utils.SetNetworkCredential(repository, _networkCredential, _cmdletPassedIn); // Check if dependencies already exist within the repo if: diff --git a/src/code/RepositorySettings.cs b/src/code/RepositorySettings.cs index 97e9f80b7..e9f2693e2 100644 --- a/src/code/RepositorySettings.cs +++ b/src/code/RepositorySettings.cs @@ -862,7 +862,7 @@ private static PSRepositoryInfo.APIVersion GetRepoAPIVersion(Uri repoUri) // repositories with Uri Scheme "temp" may have PSPath Uri's like: "Temp:\repo" and we should consider them as local repositories. return PSRepositoryInfo.APIVersion.Local; } - else if (repoUri.AbsoluteUri.EndsWith(".azurecr.io") || repoUri.AbsoluteUri.EndsWith(".azurecr.io/")) + else if (repoUri.AbsoluteUri.EndsWith(".azurecr.io") || repoUri.AbsoluteUri.EndsWith(".azurecr.io/") || repoUri.AbsoluteUri.Contains("mcr.microsoft.com")) { return PSRepositoryInfo.APIVersion.ContainerRegistry; } @@ -876,7 +876,7 @@ private static RepositoryProviderType GetRepositoryProviderType(Uri repoUri) { string absoluteUri = repoUri.AbsoluteUri; // We want to use contains instead of EndsWith to accomodate for trailing '/' - if (absoluteUri.Contains("azurecr.io")){ + if (absoluteUri.Contains("azurecr.io") || absoluteUri.Contains("mcr.microsoft.com")){ return RepositoryProviderType.ACR; } // TODO: add a regex for this match diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index 5de4d7c46..b15833980 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -228,3 +228,21 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Type.ToString() | Should -Be "Script" } } + +Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { + BeforeAll { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", "azure-powershell/"); + Register-PSResourceRepository -Name "MAR" -Uri "https://mcr.microsoft.com" -ApiVersion "ContainerRegistry" + } + + AfterAll { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", $null); + Unregister-PSResourceRepository -Name "MAR" + } + + It "Should find resource given specific Name, Version null" { + $res = Find-PSResource -Name "Az.Accounts" -Repository "MAR" + $res.Name | Should -Be "Az.Accounts" + $res.Version | Should -Be "3.0.4" + } +} diff --git a/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 index 835043da7..2ade007f2 100644 --- a/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 @@ -137,7 +137,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { It "Install resource with a dependency (should install both parent and dependency)" { Install-PSResource -Name $testModuleParentName -Repository $ACRRepoName -TrustRepository - + $parentPkg = Get-InstalledPSResource $testModuleParentName $parentPkg.Name | Should -Be $testModuleParentName $parentPkg.Version | Should -Be "1.0.0" @@ -307,3 +307,28 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidatio Set-PSResourceRepository PoshTestGallery -Trusted } } + +Describe 'Test Install-PSResource for MAR Repository' -tags 'CI' { + BeforeAll { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", "azure-powershell/"); + Register-PSResourceRepository -Name "MAR" -Uri "https://mcr.microsoft.com" -ApiVersion "ContainerRegistry" + } + + AfterAll { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", $null); + Unregister-PSResourceRepository -Name "MAR" + } + + It "Should find resource given specific Name, Version null" { + try { + $pkg = Install-PSResource -Name "Az.Accounts" -Repository "MAR" -PassThru -TrustRepository -Reinstall + $pkg.Name | Should -Be "Az.Accounts" + $pkg.Version | Should -Be "3.0.4" + } + finally { + if ($pkg) { + Uninstall-PSResource -Name "Az.Accounts" -Version "3.0.4" + } + } + } +} diff --git a/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 index 6c4150dac..1426efe11 100644 --- a/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 @@ -512,3 +512,24 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Version | Should -Be $version } } + +Describe 'Test Publish-PSResource for MAR Repository' -tags 'CI' { + BeforeAll { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", "azure-powershell/"); + Register-PSResourceRepository -Name "MAR" -Uri "https://mcr.microsoft.com" -ApiVersion "ContainerRegistry" + } + + AfterAll { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", $null); + Unregister-PSResourceRepository -Name "MAR" + } + + It "Should find resource given specific Name, Version null" { + $fileName = "NonExistent.psd1" + $modulePath = New-Item -Path "$TestDrive\NonExistent" -ItemType Directory -Force + $psd1Path = Join-Path -Path $modulePath -ChildPath $fileName + New-ModuleManifest -Path $psd1Path -ModuleVersion "1.0.0" -Description "NonExistent module" + + { Publish-PSResource -Path $modulePath -Repository "MAR" -ErrorAction Stop } | Should -Throw -ErrorId "MARRepositoryPublishError,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + } +} From 949c26aed924aca64cc4d7a4d36271562eed5d01 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:13:13 -0700 Subject: [PATCH 119/160] Update changelog, release notes version for v1.1.0-RC2 (#1742) --- CHANGELOG/preview.md | 11 +++++++++++ src/Microsoft.PowerShell.PSResourceGet.psd1 | 13 ++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG/preview.md b/CHANGELOG/preview.md index f248fd013..a6dead0e6 100644 --- a/CHANGELOG/preview.md +++ b/CHANGELOG/preview.md @@ -1,3 +1,14 @@ +## [1.1.0-RC2](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-RC1...v1.1.0-RC2) - 2024-10-30 + +### New Features +- Full Microsoft Artifact Registry integration (#1741) + +### Bug Fixes + +- Update to use OCI v2 APIs for Container Registry (#1737) +- Bug fixes for finding and installing from local repositories on Linux machines (#1738) +- Bug fix for finding package name with 4 part version from local repositories (#1739) + # Preview Changelog ## [1.1.0-RC1](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-preview2...v1.1.0-RC1) - 2024-10-22 diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index 3a497db4e..91e27ed57 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -46,7 +46,7 @@ 'udres') PrivateData = @{ PSData = @{ - Prerelease = 'RC1' + Prerelease = 'RC2' Tags = @('PackageManagement', 'PSEdition_Desktop', 'PSEdition_Core', @@ -56,6 +56,17 @@ ProjectUri = 'https://go.microsoft.com/fwlink/?LinkId=828955' LicenseUri = 'https://go.microsoft.com/fwlink/?LinkId=829061' ReleaseNotes = @' +## 1.1.0-RC2 + +### New Features +- Full Microsoft Artifact Registry integration (#1741) + +### Bug Fixes + +- Update to use OCI v2 APIs for Container Registry (#1737) +- Bug fixes for finding and installing from local repositories on Linux machines (#1738) +- Bug fix for finding package name with 4 part version from local repositories (#1739) + ## 1.1.0-RC1 ### New Features From 5f7c4553bb7f853ba6059e3c9b6071f261bd5621 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Fri, 15 Nov 2024 13:24:18 -0800 Subject: [PATCH 120/160] Update changelog, release notes, version for v1.1.0-rc3 release --- CHANGELOG/preview.md | 6 ++++++ src/Microsoft.PowerShell.PSResourceGet.psd1 | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG/preview.md b/CHANGELOG/preview.md index a6dead0e6..53d1677b2 100644 --- a/CHANGELOG/preview.md +++ b/CHANGELOG/preview.md @@ -1,3 +1,9 @@ +## ## [1.1.0-rc3](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-RC2...v1.1.0-rc3) - 2024-11-15 + +### Bug Fix +- Include missing commits + + ## [1.1.0-RC2](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-RC1...v1.1.0-RC2) - 2024-10-30 ### New Features diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index 91e27ed57..2c4d38c3c 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -46,7 +46,7 @@ 'udres') PrivateData = @{ PSData = @{ - Prerelease = 'RC2' + Prerelease = 'rc3' Tags = @('PackageManagement', 'PSEdition_Desktop', 'PSEdition_Core', @@ -56,6 +56,11 @@ ProjectUri = 'https://go.microsoft.com/fwlink/?LinkId=828955' LicenseUri = 'https://go.microsoft.com/fwlink/?LinkId=829061' ReleaseNotes = @' +## 1.1.0-rc3 + +### Bug Fix +- Include missing commits + ## 1.1.0-RC2 ### New Features From d3090c267912bffaa321074970df63bca3d33a3d Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 19 Nov 2024 12:23:07 -0800 Subject: [PATCH 121/160] Fix broken `-Quiet` parameter for `Save-PSResource` (#1745) --- src/code/SavePSResource.cs | 2 +- test/SavePSResourceTests/SavePSResourceLocalTests.ps1 | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/code/SavePSResource.cs b/src/code/SavePSResource.cs index ce5581ad7..26d481fce 100644 --- a/src/code/SavePSResource.cs +++ b/src/code/SavePSResource.cs @@ -161,7 +161,6 @@ public string TemporaryPath /// /// Check validation for signed and catalog files - /// [Parameter] public SwitchParameter AuthenticodeCheck { get; set; } @@ -169,6 +168,7 @@ public string TemporaryPath /// /// Suppresses progress information. /// + [Parameter] public SwitchParameter Quiet { get; set; } /// diff --git a/test/SavePSResourceTests/SavePSResourceLocalTests.ps1 b/test/SavePSResourceTests/SavePSResourceLocalTests.ps1 index ba94dc71e..36eb5330c 100644 --- a/test/SavePSResourceTests/SavePSResourceLocalTests.ps1 +++ b/test/SavePSResourceTests/SavePSResourceLocalTests.ps1 @@ -198,4 +198,10 @@ Describe 'Test Save-PSResource for local repositories' -tags 'CI' { $err.Count | Should -Not -BeNullOrEmpty $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" } + + It "Save module using -Quiet" { + $res = Save-PSResource -Name $moduleName -Version "1.0.0" -Repository $localRepo -Path $SaveDir -PassThru -TrustRepository -Quiet + $res.Name | Should -Be $moduleName + $res.Version | Should -Be "1.0.0" + } } From 5adcf9b4fc22e852da60331f9e3b6969790e3e8e Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Tue, 3 Dec 2024 13:39:28 -0800 Subject: [PATCH 122/160] Check for case insensitive License.txt when RequireLicense is specified (#1757) --- src/code/InstallHelper.cs | 40 ++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 5f4b0773c..e31c2b86c 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -1338,7 +1338,6 @@ private bool CallAcceptLicense(PSResourceInfo p, string moduleManifest, string t _cmdletPassedIn.WriteDebug("In InstallHelper::CallAcceptLicense()"); error = null; var requireLicenseAcceptance = false; - var success = true; if (File.Exists(moduleManifest)) { @@ -1366,22 +1365,44 @@ private bool CallAcceptLicense(PSResourceInfo p, string moduleManifest, string t if (!_acceptLicense) { var PkgTempInstallPath = Path.Combine(tempInstallPath, p.Name, newVersion); - var LicenseFilePath = Path.Combine(PkgTempInstallPath, "License.txt"); + if (!Directory.Exists(PkgTempInstallPath)) + { + error = new ErrorRecord( + new ArgumentException($"Package '{p.Name}' could not be installed: Temporary installation path does not exist."), + "TempPathNotFound", + ErrorCategory.ObjectNotFound, + _cmdletPassedIn); + + return false; + } + + string[] files = Directory.GetFiles(PkgTempInstallPath); + + bool foundLicense = false; + string LicenseFilePath = string.Empty; + foreach (string file in files) + { + if (string.Equals(Path.GetFileName(file), "License.txt", StringComparison.OrdinalIgnoreCase)) + { + foundLicense = true; + LicenseFilePath = Path.GetFullPath(file); + break; + } + } - if (!File.Exists(LicenseFilePath)) + if (!foundLicense) { error = new ErrorRecord( new ArgumentException($"Package '{p.Name}' could not be installed: License.txt not found. License.txt must be provided when user license acceptance is required."), "LicenseTxtNotFound", ErrorCategory.ObjectNotFound, - _cmdletPassedIn);; - success = false; + _cmdletPassedIn); - return success; + return false; } // Otherwise read LicenseFile - string licenseText = System.IO.File.ReadAllText(LicenseFilePath); + string licenseText = File.ReadAllText(LicenseFilePath); var acceptanceLicenseQuery = $"Do you accept the license terms for module '{p.Name}'?"; var message = licenseText + "\r\n" + acceptanceLicenseQuery; @@ -1404,12 +1425,13 @@ private bool CallAcceptLicense(PSResourceInfo p, string moduleManifest, string t "ForceAcceptLicense", ErrorCategory.InvalidArgument, _cmdletPassedIn); - success = false; + + return false; } } } - return success; + return true; } /// From 232ae948224fcbd27c0c2c28cdfca56048ade3fe Mon Sep 17 00:00:00 2001 From: Sydney Smith <43417619+SydneyhSmith@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:34:06 -0800 Subject: [PATCH 123/160] Update README.md (#1759) Added support lifecycle documentation --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 5222ac17a..e94344ae1 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,11 @@ C:\> Import-Module C:\Repos\PSResourceGet\out\PSResourceGet c:\> PowerShell C:\> Import-Module C:\Repos\PSResourceGet\out\PSResourceGet\PSResourceGet.psd1 ``` +## Module Support Lifecycle +Microsoft.PowerShell.PSResourceGet follows the support lifecycle of the version of PowerShell that it ships in. +For example, PSResourceGet 1.0.x shipped in PowerShell 7.4 which is an LTS release so it will be supported for 3 years. +Preview versions of the module, or versions that ship in preview versions of PowerShell are not supported. +Versions of PSResourceGet that do not ship in a version of PowerShell will be fixed forward. ## Code of Conduct From 2ec92f25d4c016939d9bb73a4c11dd7198d9f21f Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Thu, 5 Dec 2024 13:02:45 -0500 Subject: [PATCH 124/160] Bugfix for local repo casing issue on Linux (#1750) --- src/code/LocalServerApiCalls.cs | 34 ++++--------------- .../FindPSResourceLocal.Tests.ps1 | 7 ++++ 2 files changed, 13 insertions(+), 28 deletions(-) diff --git a/src/code/LocalServerApiCalls.cs b/src/code/LocalServerApiCalls.cs index 551a3fb3d..324db1081 100644 --- a/src/code/LocalServerApiCalls.cs +++ b/src/code/LocalServerApiCalls.cs @@ -260,29 +260,18 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu string actualPkgName = packageName; // this regex pattern matches packageName followed by a version (4 digit or 3 with prerelease word) - string regexPattern = $"{packageName}" + @".\d+\.\d+\.\d+(?:[a-zA-Z0-9-.]+|.\d)?.nupkg"; - Regex rx = new Regex(regexPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); + string regexPattern = $"{packageName}" + @"(\.\d+){1,3}(?:[a-zA-Z0-9-.]+|.\d)?\.nupkg"; _cmdletPassedIn.WriteDebug($"package file name pattern to be searched for is: {regexPattern}"); foreach (string path in Directory.GetFiles(Repository.Uri.LocalPath)) { string packageFullName = Path.GetFileName(path); - MatchCollection matches = rx.Matches(packageFullName); - if (matches.Count == 0) - { - continue; - } - - Match match = matches[0]; - - GroupCollection groups = match.Groups; - if (groups.Count == 0) + bool isMatch = Regex.IsMatch(packageFullName, regexPattern, RegexOptions.IgnoreCase); + if (!isMatch) { continue; } - Capture group = groups[0]; - NuGetVersion nugetVersion = GetInfoFromFileName(packageFullName: packageFullName, packageName: packageName, actualName: out actualPkgName, out errRecord); _cmdletPassedIn.WriteDebug($"Version parsed as '{nugetVersion}'"); @@ -389,7 +378,6 @@ private FindResults FindVersionHelper(string packageName, string version, string // this regex pattern matches packageName followed by the requested version string regexPattern = $"{packageName}.{requiredVersion.ToNormalizedString()}" + @".nupkg"; - Regex rx = new Regex(regexPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); _cmdletPassedIn.WriteDebug($"pattern is: {regexPattern}"); string pkgPath = String.Empty; string actualPkgName = String.Empty; @@ -397,22 +385,12 @@ private FindResults FindVersionHelper(string packageName, string version, string foreach (string path in Directory.GetFiles(Repository.Uri.LocalPath)) { string packageFullName = Path.GetFileName(path); - MatchCollection matches = rx.Matches(packageFullName); - if (matches.Count == 0) - { - continue; - } - - Match match = matches[0]; - - GroupCollection groups = match.Groups; - if (groups.Count == 0) + bool isMatch = Regex.IsMatch(packageFullName, regexPattern, RegexOptions.IgnoreCase); + if (!isMatch) { continue; } - Capture group = groups[0]; - NuGetVersion nugetVersion = GetInfoFromFileName(packageFullName: packageFullName, packageName: packageName, actualName: out actualPkgName, out errRecord); _cmdletPassedIn.WriteDebug($"Version parsed as '{nugetVersion}'"); @@ -425,7 +403,7 @@ private FindResults FindVersionHelper(string packageName, string version, string { _cmdletPassedIn.WriteDebug("Found matching version"); string pkgFullName = $"{actualPkgName}.{nugetVersion.ToString()}.nupkg"; - pkgPath = Path.Combine(Repository.Uri.LocalPath, pkgFullName); + pkgPath = path; break; } } diff --git a/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 index 1377cac2f..b89a245ee 100644 --- a/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 @@ -60,6 +60,13 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $res.Version | Should -Be "1.0.0" } + It "find resource given specific Name with incorrect casing and Version (should return correct casing)" { + # FindVersion() + $res = Find-PSResource -Name "test_local_mod3" -Version "1.0.0" -Repository $localRepo + $res.Name | Should -Be $testModuleName3 + $res.Version | Should -Be "1.0.0" + } + It "find resource given specific Name, Version null (module) from a UNC-based local repository" { # FindName() $res = Find-PSResource -Name $testModuleName -Repository $localUNCRepo From 3e23ecb6e6bf5b18e87e8a65abf44ba327095d82 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Fri, 6 Dec 2024 13:22:28 -0500 Subject: [PATCH 125/160] Bugfix for Install-PSResource Null pointer when package is present only in upstream feed in ADO (#1760) --- src/code/V3ServerAPICalls.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index 8f5297ecc..c184426a8 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -1622,9 +1622,11 @@ private HttpContent HttpRequestCallForContent(string requestUrlV3, out ErrorReco "HttpRequestCallForContentFailure", ErrorCategory.InvalidResult, this); + + return null; } - if (string.IsNullOrEmpty(content.ToString())) + if (string.IsNullOrEmpty(content?.ToString())) { _cmdletPassedIn.WriteDebug("Response is empty"); } From 1d825a382f93cec8ac6171075b056a349c0d67b0 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 7 Jan 2025 11:43:51 -0500 Subject: [PATCH 126/160] Bugfix for ContainerRegistry repository to parse out dependencies from metadata (#1766) --- src/code/PSResourceInfo.cs | 11 ++++++++--- ...ndPSResourceContainerRegistryServer.Tests.ps1 | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 0e14f6ce9..db88bfa8a 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -970,13 +970,18 @@ public static bool TryConvertFromContainerRegistryJson( { metadata["Dependencies"] = ParseContainerRegistryDependencies(requiredModulesElement, out errorMsg).ToArray(); } + if (string.Equals(packageName, "Az", StringComparison.OrdinalIgnoreCase) || packageName.StartsWith("Az.", StringComparison.OrdinalIgnoreCase)) { - if (rootDom.TryGetProperty("PrivateData", out JsonElement privateDataElement) && privateDataElement.TryGetProperty("PSData", out JsonElement psDataElement)) + if (rootDom.TryGetProperty("ModuleList", out JsonElement moduleListDepsElement)) + { + metadata["Dependencies"] = ParseContainerRegistryDependencies(moduleListDepsElement, out errorMsg).ToArray(); + } + else if (rootDom.TryGetProperty("PrivateData", out JsonElement privateDataElement) && privateDataElement.TryGetProperty("PSData", out JsonElement psDataElement)) { - if (psDataElement.TryGetProperty("ModuleList", out JsonElement moduleListDepsElement)) + if (psDataElement.TryGetProperty("ModuleList", out JsonElement privateDataModuleListDepsElement)) { - metadata["Dependencies"] = ParseContainerRegistryDependencies(moduleListDepsElement, out errorMsg).ToArray(); + metadata["Dependencies"] = ParseContainerRegistryDependencies(privateDataModuleListDepsElement, out errorMsg).ToArray(); } } } diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index b15833980..b7ffdfb8e 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -227,22 +227,32 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Version | Should -Be "1.0.0" $res.Type.ToString() | Should -Be "Script" } + + It "Should find resource with dependency, given Name and Version" { + $res = Find-PSResource -Name "Az.Storage" -Version "8.0.0" -Repository $ACRRepoName + $res.Dependencies.Length | Should -Be 1 + $res.Dependencies[0].Name | Should -Be "Az.Accounts" + } } Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { BeforeAll { - [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", "azure-powershell/"); Register-PSResourceRepository -Name "MAR" -Uri "https://mcr.microsoft.com" -ApiVersion "ContainerRegistry" } AfterAll { - [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", $null); Unregister-PSResourceRepository -Name "MAR" } It "Should find resource given specific Name, Version null" { $res = Find-PSResource -Name "Az.Accounts" -Repository "MAR" $res.Name | Should -Be "Az.Accounts" - $res.Version | Should -Be "3.0.4" + $res.Version | Should -Be "4.0.0" + } + + It "Should find resource and its dependency given specific Name and Version" { + $res = Find-PSResource -Name "Az.Storage" -Version "8.0.0" -Repository "MAR" + $res.Dependencies.Length | Should -Be 1 + $res.Dependencies[0].Name | Should -Be "Az.Accounts" } } From c7969145ac5ed62cee72fc5dc1f5c2cbde74cacc Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 7 Jan 2025 14:01:50 -0500 Subject: [PATCH 127/160] Modify filter query parameter (#1761) --- src/code/V2ServerAPICalls.cs | 108 +++++++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 24 deletions(-) diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index 59b1a3ab4..38b5640de 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -355,7 +355,7 @@ public override FindResults FindName(string packageName, bool includePrerelease, filterBuilder.AddCriterion($"Id eq '{packageName}'"); } - filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"); + filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion eq true" : "IsLatestVersion eq true"); if (type != ResourceType.None) { filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); } @@ -424,7 +424,7 @@ public override FindResults FindNameWithTag(string packageName, string[] tags, b filterBuilder.AddCriterion($"Id eq '{packageName}'"); } - filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"); + filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion eq true" : "IsLatestVersion eq true"); if (type != ResourceType.None) { filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); } @@ -915,14 +915,26 @@ private string FindAllFromTypeEndPoint(bool includePrerelease, bool isSearchingM // JFrog/Artifactory requires an empty search term to enumerate all packages in the feed if (_isJFrogRepo) { queryBuilder.SearchTerm = "''"; - } - if (includePrerelease) { - queryBuilder.AdditionalParameters["includePrerelease"] = "true"; - filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); - } else { - filterBuilder.AddCriterion("IsLatestVersion"); + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsAbsoluteLatestVersion correctly + filterBuilder.AddCriterion("IsAbsoluteLatestVersion eq true"); + } else { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsLatestVersion correctly + filterBuilder.AddCriterion("IsLatestVersion eq true"); + } } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + } + var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrlV2, out errRecord); } @@ -952,16 +964,24 @@ private string FindTagFromEndpoint(string[] tags, bool includePrerelease, bool i queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; } - // JFrog/Artifactory requires an empty search term to enumerate all packages in the feed - if (_isJFrogRepo) { - queryBuilder.SearchTerm = "''"; - } - if (includePrerelease) { queryBuilder.AdditionalParameters["includePrerelease"] = "true"; - filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsAbsoluteLatestVersion correctly + filterBuilder.AddCriterion("IsAbsoluteLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } } else { - filterBuilder.AddCriterion("IsLatestVersion"); + if (_isJFrogRepo) { + filterBuilder.AddCriterion("IsLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsLatestVersion"); + } } filterBuilder.AddCriterion($"substringof('PS{(isSearchingModule ? "Module" : "Script")}', Tags) eq true"); @@ -996,12 +1016,25 @@ private string FindCommandOrDscResource(string[] tags, bool includePrerelease, b if (includePrerelease) { queryBuilder.AdditionalParameters["includePrerelease"] = "true"; - filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsAbsoluteLatestVersion correctly + filterBuilder.AddCriterion("IsAbsoluteLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } } else { - filterBuilder.AddCriterion("IsLatestVersion"); + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsLatestVersion correctly + filterBuilder.AddCriterion("IsLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsLatestVersion"); + } } - // can only find from Modules endpoint var tagPrefix = isSearchingForCommands ? "PSCommand_" : "PSDscResource_"; @@ -1038,12 +1071,25 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl if (includePrerelease) { queryBuilder.AdditionalParameters["includePrerelease"] = "true"; - filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsAbsoluteLatestVersion correctly + filterBuilder.AddCriterion("IsAbsoluteLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } } else { - filterBuilder.AddCriterion("IsLatestVersion"); + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsLatestVersion correctly + filterBuilder.AddCriterion("IsLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsLatestVersion"); + } } - var names = packageName.Split(new char[] {'*'}, StringSplitOptions.RemoveEmptyEntries); if (names.Length == 0) @@ -1131,14 +1177,28 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; } + // JFrog/Artifactory requires an empty search term to enumerate all packages in the feed if (includePrerelease) { queryBuilder.AdditionalParameters["includePrerelease"] = "true"; - filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsAbsoluteLatestVersion correctly + filterBuilder.AddCriterion("IsAbsoluteLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } } else { - filterBuilder.AddCriterion("IsLatestVersion"); + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsLatestVersion correctly + filterBuilder.AddCriterion("IsLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsLatestVersion"); + } } - var names = packageName.Split(new char[] {'*'}, StringSplitOptions.RemoveEmptyEntries); if (!_isPSGalleryRepo) From 50ebac20a25951f290db37d6a6055cc3ca2de4ed Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 8 Jan 2025 13:55:50 -0500 Subject: [PATCH 128/160] Add support for publishing .nupkg file to ACR (#1763) --- src/code/ContainerRegistryServerAPICalls.cs | 13 +- src/code/PSResourceInfo.cs | 19 +- src/code/PublishHelper.cs | 212 +++++++++++++++++- src/code/Utils.cs | 73 ++++++ ...SResourceContainerRegistryServer.Tests.ps1 | 39 ++++ .../temp-testmodule-nupkgpath.1.0.0.nupkg | Bin 0 -> 3393 bytes .../temp-testnupkg-nupkgpath.1.0.0.nupkg | Bin 0 -> 2067 bytes .../temp-testscript-nupkgpath.1.0.0.nupkg | Bin 0 -> 2211 bytes 8 files changed, 338 insertions(+), 18 deletions(-) create mode 100644 test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg create mode 100644 test/testFiles/testNupkgs/temp-testnupkg-nupkgpath.1.0.0.nupkg create mode 100644 test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs index d32b2300f..d9175c0b0 100644 --- a/src/code/ContainerRegistryServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -649,9 +649,9 @@ internal Hashtable GetContainerRegistryMetadata(string packageName, string exact pkgVersionString += $"-{pkgPrereleaseLabelElement.ToString()}"; } } - else if (rootDom.TryGetProperty("Version", out pkgVersionElement)) + else if (rootDom.TryGetProperty("Version", out pkgVersionElement) || rootDom.TryGetProperty("version", out pkgVersionElement)) { - // script metadata will have "Version" property + // script metadata will have "Version" property, but nupkg only based .nuspec will have lowercase "version" property and JsonElement.TryGetProperty() is case sensitive pkgVersionString = pkgVersionElement.ToString(); } else @@ -1115,12 +1115,11 @@ private static Collection> GetDefaultHeaders(string #endregion #region Publish Methods - /// /// Helper method that publishes a package to the container registry. /// This gets called from Publish-PSResource. /// - internal bool PushNupkgContainerRegistry(string psd1OrPs1File, + internal bool PushNupkgContainerRegistry( string outputNupkgDir, string packageName, string modulePrefix, @@ -1128,10 +1127,14 @@ internal bool PushNupkgContainerRegistry(string psd1OrPs1File, ResourceType resourceType, Hashtable parsedMetadataHash, Hashtable dependencies, + bool isNupkgPathSpecified, + string originalNupkgPath, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::PushNupkgContainerRegistry()"); - string fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, packageName + "." + packageVersion.ToNormalizedString() + ".nupkg"); + + // if isNupkgPathSpecified, then we need to publish the original .nupkg file, as it may be signed + string fullNupkgFile = isNupkgPathSpecified ? originalNupkgPath : System.IO.Path.Combine(outputNupkgDir, packageName + "." + packageVersion.ToNormalizedString() + ".nupkg"); string pkgNameForUpload = string.IsNullOrEmpty(modulePrefix) ? packageName : modulePrefix + "/" + packageName; string packageNameLowercase = pkgNameForUpload.ToLower(); diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index db88bfa8a..409665edc 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -836,7 +836,8 @@ public static bool TryConvertFromContainerRegistryJson( // Version // For scripts (i.e with "Version" property) the version can contain prerelease label - if (rootDom.TryGetProperty("Version", out JsonElement scriptVersionElement)) + // For nupkg only based packages the .nuspec's metadata attributes will be lowercase + if (rootDom.TryGetProperty("Version", out JsonElement scriptVersionElement) || rootDom.TryGetProperty("version", out scriptVersionElement)) { versionValue = scriptVersionElement.ToString(); pkgVersion = ParseHttpVersion(versionValue, out string prereleaseLabel); @@ -883,25 +884,25 @@ public static bool TryConvertFromContainerRegistryJson( metadata["NormalizedVersion"] = parsedNormalizedVersion.ToNormalizedString(); // License Url - if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement)) + if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement) || rootDom.TryGetProperty("licenseUrl", out licenseUrlElement)) { metadata["LicenseUrl"] = ParseHttpUrl(licenseUrlElement.ToString()) as Uri; } // Project Url - if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement)) + if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement) || rootDom.TryGetProperty("projectUrl", out projectUrlElement)) { metadata["ProjectUrl"] = ParseHttpUrl(projectUrlElement.ToString()) as Uri; } // Icon Url - if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement)) + if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement) || rootDom.TryGetProperty("iconUrl", out iconUrlElement)) { metadata["IconUrl"] = ParseHttpUrl(iconUrlElement.ToString()) as Uri; } // Tags - if (rootDom.TryGetProperty("Tags", out JsonElement tagsElement)) + if (rootDom.TryGetProperty("Tags", out JsonElement tagsElement) || rootDom.TryGetProperty("tags", out tagsElement)) { string[] pkgTags = Utils.EmptyStrArray; if (tagsElement.ValueKind == JsonValueKind.Array) @@ -937,7 +938,7 @@ public static bool TryConvertFromContainerRegistryJson( } // Author - if (rootDom.TryGetProperty("Authors", out JsonElement authorsElement)) + if (rootDom.TryGetProperty("Authors", out JsonElement authorsElement) || rootDom.TryGetProperty("authors", out authorsElement)) { metadata["Authors"] = authorsElement.ToString(); @@ -948,19 +949,19 @@ public static bool TryConvertFromContainerRegistryJson( } // Copyright - if (rootDom.TryGetProperty("Copyright", out JsonElement copyrightElement)) + if (rootDom.TryGetProperty("Copyright", out JsonElement copyrightElement) || rootDom.TryGetProperty("copyright", out copyrightElement)) { metadata["Copyright"] = copyrightElement.ToString(); } // Description - if (rootDom.TryGetProperty("Description", out JsonElement descriptiontElement)) + if (rootDom.TryGetProperty("Description", out JsonElement descriptiontElement) || rootDom.TryGetProperty("description", out descriptiontElement)) { metadata["Description"] = descriptiontElement.ToString(); } // ReleaseNotes - if (rootDom.TryGetProperty("ReleaseNotes", out JsonElement releaseNotesElement)) + if (rootDom.TryGetProperty("ReleaseNotes", out JsonElement releaseNotesElement) || rootDom.TryGetProperty("releaseNotes", out releaseNotesElement)) { metadata["ReleaseNotes"] = releaseNotesElement.ToString(); } diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 0eec8e0d9..66daea84e 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Management.Automation; using System.Net; using System.Net.Http; @@ -440,12 +441,28 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe { ContainerRegistryServerAPICalls containerRegistryServer = new ContainerRegistryServerAPICalls(repository, _cmdletPassedIn, _networkCredential, userAgentString); - var pkgMetadataFile = (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish; + if (_isNupkgPathSpecified) + { + // copy the .nupkg to a temp path (outputNupkgDir field) as we don't want to tamper with the original, possibly signed, .nupkg file + string copiedNupkgFilePath = CopyNupkgFileToTempPath(nupkgFilePath: Path, errRecord: out ErrorRecord copyErrRecord); + if (copyErrRecord != null) + { + _cmdletPassedIn.WriteError(copyErrRecord); + return; + } + + // get package info (name, version, metadata hashtable) from the copied .nupkg package and then populate appropriate fields (_pkgName, _pkgVersion, parsedMetadata) + GetPackageInfoFromNupkg(nupkgFilePath: copiedNupkgFilePath, errRecord: out ErrorRecord pkgInfoErrRecord); + if (pkgInfoErrRecord != null) + { + _cmdletPassedIn.WriteError(pkgInfoErrRecord); + return; + } + } - if (!containerRegistryServer.PushNupkgContainerRegistry(pkgMetadataFile, outputNupkgDir, _pkgName, modulePrefix, _pkgVersion, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgContainerRegistryError)) + if (!containerRegistryServer.PushNupkgContainerRegistry(outputNupkgDir, _pkgName, modulePrefix, _pkgVersion, resourceType, parsedMetadata, dependencies, _isNupkgPathSpecified, Path, out ErrorRecord pushNupkgContainerRegistryError)) { _cmdletPassedIn.WriteError(pushNupkgContainerRegistryError); - // exit out of processing return; } } @@ -455,6 +472,7 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe { outputNupkgDir = pathToNupkgToPublish; } + // This call does not throw any exceptions, but it will write unsuccessful responses to the console if (!PushNupkg(outputNupkgDir, repository.Name, repository.Uri.ToString(), out ErrorRecord pushNupkgError)) { @@ -474,7 +492,8 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe } finally { - if (!_isNupkgPathSpecified) + // For scenarios such as Publish-PSResource -NupkgPath -Repository , the outputNupkgDir will be set to NupkgPath path, and a temp outputDir folder will not have been created and thus doesn't need to attempt to be deleted + if (Directory.Exists(outputDir)) { _cmdletPassedIn.WriteVerbose(string.Format("Deleting temporary directory '{0}'", outputDir)); Utils.DeleteDirectory(outputDir); @@ -1243,6 +1262,191 @@ private bool CheckDependenciesExist(Hashtable dependencies, string repositoryNam return true; } + /// + /// This method is called by Publish-PSResource when the -NupkgPath parameter is specified + /// The method copies the .nupkg file to a temp path (populated at outputNupkgDir field) as we dont' want to extract and read original .nupkg file + /// + private string CopyNupkgFileToTempPath(string nupkgFilePath, out ErrorRecord errRecord) + { + errRecord = null; + string destinationFilePath = String.Empty; + var packageFullName = System.IO.Path.GetFileName(nupkgFilePath); + try + { + if (!Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + if (!Directory.Exists(outputNupkgDir)) + { + Directory.CreateDirectory(outputNupkgDir); + } + } + + destinationFilePath = System.IO.Path.Combine(outputNupkgDir, packageFullName); + File.Copy(Path, destinationFilePath); + } + catch (Exception e) + { + errRecord = new ErrorRecord( + new ArgumentException($"Error moving .nupkg at -NupkgPath to temp nupkg dir path '{outputNupkgDir}' due to: '{e.Message}'."), + "ErrorMovingNupkg", + ErrorCategory.NotSpecified, + this); + + // exit process record + return destinationFilePath; + } + + return destinationFilePath; + } + + /// + /// Get package info from the .nupkg file provided, inluding package name (_pkgName), package version (_pkgVersion), and metadata parsed into a hashtable (parsedMetadata) + /// + private void GetPackageInfoFromNupkg(string nupkgFilePath, out ErrorRecord errRecord) + { + errRecord = null; + Regex rx = new Regex(@"\.\d+\.", RegexOptions.Compiled | RegexOptions.IgnoreCase); + var packageFullName = System.IO.Path.GetFileName(nupkgFilePath); + MatchCollection matches = rx.Matches(packageFullName); + if (matches.Count == 0) + { + return; + } + + Match match = matches[0]; + + GroupCollection groups = match.Groups; + if (groups.Count == 0) + { + return; + } + + Capture group = groups[0]; + + string pkgFoundName = packageFullName.Substring(0, group.Index); + + string version = packageFullName.Substring(group.Index + 1, packageFullName.LastIndexOf('.') - group.Index - 1); + _cmdletPassedIn.WriteDebug($"Found package '{pkgFoundName}', version '{version}', from packageFullName '{packageFullName}' at path '{Path}'"); + + if (!NuGetVersion.TryParse(version, out NuGetVersion nugetVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Error parsing version '{version}' into NuGetVersion instance."), + "ErrorParsingNuGetVersion", + ErrorCategory.NotSpecified, + this); + + return; + } + + _pkgName = pkgFoundName; + _pkgVersion = nugetVersion; + parsedMetadata = GetMetadataFromNupkg(nupkgFilePath, _pkgName, out errRecord); + } + + /// + /// Extract copied .nupkg, find metadata file (either .ps1, .psd1, or .nuspec) and read metadata into a hashtable + /// + internal Hashtable GetMetadataFromNupkg(string copiedNupkgPath, string packageName, out ErrorRecord errRecord) + { + Hashtable pkgMetadata = new Hashtable(StringComparer.OrdinalIgnoreCase); + errRecord = null; + + // in temp directory create an "extract" folder to which we'll copy .nupkg to, extract contents, etc. + string nupkgDirPath = Directory.GetParent(copiedNupkgPath).FullName; //someGuid/nupkg/myPkg.nupkg -> /someGuid/nupkg + string tempPath = Directory.GetParent(nupkgDirPath).FullName; // someGuid + var extractPath = System.IO.Path.Combine(tempPath, "extract"); // someGuid/extract + + try + { + var dir = Directory.CreateDirectory(extractPath); + dir.Attributes &= ~FileAttributes.ReadOnly; + + // change extension to .zip + string zipFilePath = System.IO.Path.ChangeExtension(copiedNupkgPath, ".zip"); + File.Move(copiedNupkgPath, zipFilePath); + + // extract from .zip + _cmdletPassedIn.WriteDebug($"Extracting '{zipFilePath}' to '{extractPath}'"); + System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, extractPath); + + string psd1FilePath = String.Empty; + string ps1FilePath = String.Empty; + string nuspecFilePath = String.Empty; + Utils.GetMetadataFilesFromPath(extractPath, packageName, out psd1FilePath, out ps1FilePath, out nuspecFilePath, out string properCasingPkgName); + + List pkgTags = new List(); + + if (File.Exists(psd1FilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read module manifest file '{psd1FilePath}'"); + if (!Utils.TryReadManifestFile(psd1FilePath, out pkgMetadata, out Exception readManifestError)) + { + errRecord = new ErrorRecord( + readManifestError, + "GetMetadataFromNupkgFailure", + ErrorCategory.ParserError, + this); + + return pkgMetadata; + } + } + else if (File.Exists(ps1FilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read script file '{ps1FilePath}'"); + if (!PSScriptFileInfo.TryTestPSScriptFileInfo(ps1FilePath, out PSScriptFileInfo parsedScript, out ErrorRecord[] errors, out string[] verboseMsgs)) + { + errRecord = new ErrorRecord( + new InvalidDataException($"PSScriptFile could not be read properly"), + "GetMetadataFromNupkgFailure", + ErrorCategory.ParserError, + this); + + return pkgMetadata; + } + + pkgMetadata = parsedScript.ToHashtable(); + } + else if (File.Exists(nuspecFilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read nuspec file '{nuspecFilePath}'"); + pkgMetadata = Utils.GetMetadataFromNuspec(nuspecFilePath, _cmdletPassedIn, out errRecord); + if (errRecord != null) + { + return pkgMetadata; + } + } + else + { + errRecord = new ErrorRecord( + new InvalidDataException($".nupkg package must contain either .psd1, .ps1, or .nuspec file and none were found"), + "GetMetadataFromNupkgFailure", + ErrorCategory.InvalidData, + this); + + return pkgMetadata; + } + } + catch (Exception e) + { + errRecord = new ErrorRecord( + new InvalidOperationException($"Temporary folder for installation could not be created or set due to: {e.Message}"), + "GetMetadataFromNupkgFailure", + ErrorCategory.InvalidOperation, + this); + } + finally + { + if (Directory.Exists(extractPath)) + { + Utils.DeleteDirectory(extractPath); + } + } + + return pkgMetadata; + } + #endregion } } diff --git a/src/code/Utils.cs b/src/code/Utils.cs index da80d3f42..d51ba0fdb 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -23,6 +23,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using System.Xml; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses { @@ -1583,6 +1584,11 @@ public static void DeleteDirectoryWithRestore(string dirPath) /// public static void DeleteDirectory(string dirPath) { + if (!Directory.Exists(dirPath)) + { + throw new Exception($"Path '{dirPath}' that was attempting to be deleted does not exist."); + } + // Remove read only file attributes first foreach (var dirFilePath in Directory.GetFiles(dirPath,"*",SearchOption.AllDirectories)) { @@ -1830,6 +1836,73 @@ public static void CreateFile(string filePath) #endregion + #region Nuspec file parsing methods + + public static Hashtable GetMetadataFromNuspec(string nuspecFilePath, PSCmdlet cmdletPassedIn, out ErrorRecord errorRecord) + { + Hashtable nuspecHashtable = new Hashtable(StringComparer.InvariantCultureIgnoreCase); + + XmlDocument nuspecXmlDocument = LoadXmlDocument(nuspecFilePath, cmdletPassedIn, out errorRecord); + if (errorRecord != null) + { + return nuspecHashtable; + } + + try + { + XmlNodeList elemList = nuspecXmlDocument.GetElementsByTagName("metadata"); + for(int i = 0; i < elemList.Count; i++) + { + XmlNode metadataInnerXml = elemList[i]; + + for(int j= 0; j + /// Method that loads file content into XMLDocument. Used when reading .nuspec file. + /// + public static XmlDocument LoadXmlDocument(string filePath, PSCmdlet cmdletPassedIn, out ErrorRecord errRecord) + { + errRecord = null; + XmlDocument doc = new XmlDocument(); + doc.PreserveWhitespace = true; + try { doc.Load(filePath); } + catch (Exception e) + { + errRecord = new ErrorRecord( + exception: e, + "LoadXmlDocumentFailure", + ErrorCategory.ReadError, + cmdletPassedIn); + } + + return doc; + } + + #endregion + } #endregion diff --git a/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 index 1426efe11..af57385a1 100644 --- a/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 @@ -97,6 +97,9 @@ Describe "Test Publish-PSResource" -tags 'CI' { # Path to specifically to that invalid test scripts folder $script:testScriptsFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testScripts" + + # Path to specifically to that invalid test nupkgs folder + $script:testNupkgsFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testNupkgs" } AfterEach { if(!(Test-Path $script:PublishModuleBase)) @@ -511,6 +514,42 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Name | Should -Be $script:PublishModuleName $results[0].Version | Should -Be $version } + + It "Publish a package given NupkgPath to a package with .psd1" { + $packageName = "temp-testmodule-nupkgpath" + $version = "1.0.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } + + It "Publish a package given NupkgPath to a package with .ps1" { + $packageName = "temp-testscript-nupkgpath" + $version = "1.0.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } + + It "Publish a package given NupkgPath to a package with .nuspec" { + $packageName = "temp-testnupkg-nupkgpath" + $version = "1.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } } Describe 'Test Publish-PSResource for MAR Repository' -tags 'CI' { diff --git a/test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg b/test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg new file mode 100644 index 0000000000000000000000000000000000000000..a0f7d11d86d7c2aab04394d18fa26ff462815058 GIT binary patch literal 3393 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 literal 0 HcmV?d00001 diff --git a/test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg b/test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg new file mode 100644 index 0000000000000000000000000000000000000000..e3adbf814133c78523a2e2e62abfe9b6ded238a1 GIT binary patch literal 2211 zcmcJQXIN8N8plIPz=%?U^fH1VD-ePhs!pUyT@6STgb5*(03jwJ;0Wp%1Qn!&Cc;q0 zGLQv_j+C$jL`pycNEeb377zs#qzl>Wepr>A+5NQlJonz`p7WmndG7Cd-~Wqt5E7OK zfk2|5gMsOubM3Ds)CE8w5kU}WKQQY{!iAFc;GduAsXh2sghcGp%_bUWA~Uo-$@O*Y zy(&&f$Nr+_F`3&}Qw$4ZwxRbeEdEl}{`17u>i9;gf|sb5o$8h>kxHkD!G5rjzwtp{QE_cpAHaR+qqbKcA&Q|C1i%Ut_68O_`GI9YK)gj0!3E9MF{`7>#_t=wE z$&q8(6Ljr|rnI5Ebazj0xzRW)g~s@_R=3W#f8BQ^TJd6wY#;|lNMv>J4$KREjJ4l= zo3*a%!+V?!V?4EGnLqnb9qHfJfYq*nZ4(0i9<%Jwr!GOTe8L&vD0-@CK1-f9WsmaB zWYY@Ond3{dJ{o~0;jWV-vX#!1?VdOSGq*9eppIh2n`|e zoD1g1U+8uE5~N7Pwr6u>3COtM!KM?~}&C&Tj`I$CFF` zrq?ukB|%85N7#mKP0?=ezu_dU;ne7<;S`khYuM8#^I;xtImX@@m*h357l#e0<4+M+ z3R4zLw+HgH+i!Jf%-AH6PaCAzwDmKyAF_(`>Jue-3eYx9-Ha%(pUQnnma5?70j;D; zZ;qM}RLU59R<0WZorFHVj{oJ}34_KQ&5iDKfuZJ`0x?31qSR)HmSL0{D`9Ct(C7gI zyo2glf4+kFg%u-xSN5eZ`!{M3C)~{NmhX12cyRlS%6pM-Q*t(KgY*)B)dxU>0dFtM zZ~_HKp!m96CgR8!;Zb3sxvA#(*3)9v>o+GcoSKm-ij#eLnstuiM?sp-xaESpKHZYE zeaz1NW*p~H6rW#M0)IcE?8x{u z!{k{xq?j6Ls9VAv&}Qs86P~+VC(5WNp*=U9?)Kv($cy}Br{Hi&W&KVc$JHlDhPX8R zestLUhdgx%a#6gPk7HcbsYdF_XREQ(z|ET|q>e-KCzRxj#Ua@T*6I_5W3S%(yYzdl zRzYEtwVW+>d<_@)#s93!6!89+MF0RM;Bihs1QCM`!35&;$T$);7>gt8h2ba|e+&hq zhYcs;bcv*JB924}1{N?Fg#K~;;|K#J4s!w-;BR7rL>T)6vwldE<47YMoJbDy#}Y%G zSxGqv38woz$|IL{0Fyfo6O06 z{U&0rbHB&0iSP&Mnvw##i7s0nXJCdOEI+z1uepn23u^*|6QD{TbQTPq)14BG337@@ z$IoMv9ifm@Vg-HW>5p|Tl!;P_e2v%_H=-l(<~=KpvpT#XJ9-6oT=hy{OEdP7GD(-$ zazPGWrM&7^y>#OFL8Xgmxw^Rx$rtt}ilDmbj!_fOlkh&Z{LB=KJ9o>P`$q1B^!Q45 z+?a^=F_+3M!&{-6psd4}wf89;KikVB>1H3Q@i)3VQ9d&Q$?zI-wX;JR3~H761j$WR zjwv1+3S5rT;DUtKE0|q+$(rDX%)((-u%TN$V{(b+#>)H+G*{PJ>RX$YmVb7Tum!Pw zi_p3zsJ*;GQZU)EJ%7KnW45@x)1nw-!QMVp+^gRfX{_uhQs3P_t9j*VZ(aAYS;wMk zt-Z&TBW*gXzrQ=_-N7TPZf{J_!T8_zPh<&g;x|E)9yW_}BK7bQ)Qe9C;#_x5waknE zJ1qnRr9pq*{D82puCH1l+Tq)cd!7EvE=M5G{+I86t@cOG_C<{WWbB`-0Y3M&)aQ1- qx8A= Date: Thu, 9 Jan 2025 22:49:15 +0000 Subject: [PATCH 129/160] Merged PR 33601: Update changelog and dotnet sdk version Update changelog and dotnet sdk version ---- #### AI description (iteration 1) #### PR Classification Documentation and bug fixes. #### PR Summary This pull request updates the changelog and the .NET SDK version, along with several bug fixes. - `src/Microsoft.PowerShell.PSResourceGet.psd1`: Added release notes for version 1.1.0 and removed the prerelease tag. - `CHANGELOG/1.1.md`: Added a new changelog file for version 1.1.0 detailing the bug fixes. - `global.json`: Updated .NET SDK version from 8.0.403 to 8.0.404. --- CHANGELOG/1.1.md | 12 ++++++++++++ global.json | 2 +- src/Microsoft.PowerShell.PSResourceGet.psd1 | 14 +++++++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG/1.1.md diff --git a/CHANGELOG/1.1.md b/CHANGELOG/1.1.md new file mode 100644 index 000000000..ba6cce61e --- /dev/null +++ b/CHANGELOG/1.1.md @@ -0,0 +1,12 @@ +## [1.1.0](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-rc3...v1.1.0) - 2025-01-09 + +### Bug Fixes + +- Bugfix for publishing .nupkg file to ContainerRegistry repository (#1763) +- Bugfix for PMPs like Artifactory needing modified filter query parameter to proxy upstream (#1761) +- Bugfix for ContainerRegistry repository to parse out dependencies from metadata (#1766) +- Bugfix for Install-PSResource Null pointer occurring when package is present only in upstream feed in ADO (#1760) +- Bugfix for local repository casing issue on Linux (#1750) +- Update README.md (#1759) +- Bug fix for case sensitive License.txt when RequireLicense is specified (#1757) +- Bug fix for broken -Quiet parameter for Save-PSResource (#1745) diff --git a/global.json b/global.json index 120c43985..b832c3a01 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.403" + "version": "8.0.404" } } diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index 2c4d38c3c..30994ba2e 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -46,7 +46,7 @@ 'udres') PrivateData = @{ PSData = @{ - Prerelease = 'rc3' + # Prerelease = '' Tags = @('PackageManagement', 'PSEdition_Desktop', 'PSEdition_Core', @@ -56,6 +56,18 @@ ProjectUri = 'https://go.microsoft.com/fwlink/?LinkId=828955' LicenseUri = 'https://go.microsoft.com/fwlink/?LinkId=829061' ReleaseNotes = @' +## 1.1.0 + +### Bug Fix +- Bugfix for publishing .nupkg file to ContainerRegistry repository (#1763) +- Bugfix for PMPs like Artifactory needing modified filter query parameter to proxy upstream (#1761) +- Bugfix for ContainerRegistry repository to parse out dependencies from metadata (#1766) +- Bugfix for Install-PSResource Null pointer occurring when package is present only in upstream feed in ADO (#1760) +- Bugfix for local repository casing issue on Linux (#1750) +- Update README.md (#1759) +- Bug fix for case sensitive License.txt when RequireLicense is specified (#1757) +- Bug fix for broken -Quiet parameter for Save-PSResource (#1745) + ## 1.1.0-rc3 ### Bug Fix From 9a80c4026c9b5ecb9728987ad45cc700efc79736 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Thu, 9 Jan 2025 22:49:15 +0000 Subject: [PATCH 130/160] Merged PR 33601: Update changelog and dotnet sdk version Update changelog and dotnet sdk version ---- #### AI description (iteration 1) #### PR Classification Documentation and bug fixes. #### PR Summary This pull request updates the changelog and the .NET SDK version, along with several bug fixes. - `src/Microsoft.PowerShell.PSResourceGet.psd1`: Added release notes for version 1.1.0 and removed the prerelease tag. - `CHANGELOG/1.1.md`: Added a new changelog file for version 1.1.0 detailing the bug fixes. - `global.json`: Updated .NET SDK version from 8.0.403 to 8.0.404. --- CHANGELOG/1.1.md | 12 ++++++++++++ global.json | 2 +- src/Microsoft.PowerShell.PSResourceGet.psd1 | 14 +++++++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG/1.1.md diff --git a/CHANGELOG/1.1.md b/CHANGELOG/1.1.md new file mode 100644 index 000000000..ba6cce61e --- /dev/null +++ b/CHANGELOG/1.1.md @@ -0,0 +1,12 @@ +## [1.1.0](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-rc3...v1.1.0) - 2025-01-09 + +### Bug Fixes + +- Bugfix for publishing .nupkg file to ContainerRegistry repository (#1763) +- Bugfix for PMPs like Artifactory needing modified filter query parameter to proxy upstream (#1761) +- Bugfix for ContainerRegistry repository to parse out dependencies from metadata (#1766) +- Bugfix for Install-PSResource Null pointer occurring when package is present only in upstream feed in ADO (#1760) +- Bugfix for local repository casing issue on Linux (#1750) +- Update README.md (#1759) +- Bug fix for case sensitive License.txt when RequireLicense is specified (#1757) +- Bug fix for broken -Quiet parameter for Save-PSResource (#1745) diff --git a/global.json b/global.json index 120c43985..b832c3a01 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.403" + "version": "8.0.404" } } diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index 2c4d38c3c..30994ba2e 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -46,7 +46,7 @@ 'udres') PrivateData = @{ PSData = @{ - Prerelease = 'rc3' + # Prerelease = '' Tags = @('PackageManagement', 'PSEdition_Desktop', 'PSEdition_Core', @@ -56,6 +56,18 @@ ProjectUri = 'https://go.microsoft.com/fwlink/?LinkId=828955' LicenseUri = 'https://go.microsoft.com/fwlink/?LinkId=829061' ReleaseNotes = @' +## 1.1.0 + +### Bug Fix +- Bugfix for publishing .nupkg file to ContainerRegistry repository (#1763) +- Bugfix for PMPs like Artifactory needing modified filter query parameter to proxy upstream (#1761) +- Bugfix for ContainerRegistry repository to parse out dependencies from metadata (#1766) +- Bugfix for Install-PSResource Null pointer occurring when package is present only in upstream feed in ADO (#1760) +- Bugfix for local repository casing issue on Linux (#1750) +- Update README.md (#1759) +- Bug fix for case sensitive License.txt when RequireLicense is specified (#1757) +- Bug fix for broken -Quiet parameter for Save-PSResource (#1745) + ## 1.1.0-rc3 ### Bug Fix From eb2443b1c47d2fbec9e6a591e7fee27b6e41c343 Mon Sep 17 00:00:00 2001 From: Afroz Mohammed Date: Fri, 24 Jan 2025 13:27:23 -0800 Subject: [PATCH 131/160] Fix #1777: Fixes bug with nuspec dependency version range when versions are specified in RequiredModules section Fixes bug with generated nuspec dependency version range when RequiredVersion,MaxiumumVersion and ModuleVersion are specified in RequiredModules section --- src/code/PublishHelper.cs | 57 ++++-- .../CompressPSResource.Tests.ps1 | 177 ++++++++++++++++++ 2 files changed, 220 insertions(+), 14 deletions(-) diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 66daea84e..944a8b7f4 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -1122,16 +1122,38 @@ private string CreateNuspec( if (requiredModules != null) { XmlElement dependenciesElement = doc.CreateElement("dependencies", nameSpaceUri); - foreach (string dependencyName in requiredModules.Keys) { XmlElement element = doc.CreateElement("dependency", nameSpaceUri); - element.SetAttribute("id", dependencyName); - string dependencyVersion = requiredModules[dependencyName].ToString(); - if (!string.IsNullOrEmpty(dependencyVersion)) + + var requiredModulesVersionInfo = (Hashtable)requiredModules[dependencyName]; + string versionRange = ""; + if (requiredModulesVersionInfo.ContainsKey("RequiredVersion")) + { + // For RequiredVersion, use exact version notation [x.x.x] + string requiredModulesVersion = requiredModulesVersionInfo["RequiredVersion"].ToString(); + versionRange = $"[{requiredModulesVersion}]"; + } + else if (requiredModulesVersionInfo.ContainsKey("ModuleVersion") && requiredModulesVersionInfo.ContainsKey("MaximumVersion")) + { + // Version range when both min and max specified: [min,max] + versionRange = $"[{requiredModulesVersionInfo["ModuleVersion"]}, {requiredModulesVersionInfo["MaximumVersion"]}]"; + } + else if (requiredModulesVersionInfo.ContainsKey("ModuleVersion")) + { + // Only min specified: min (which means ≥ min) + versionRange = requiredModulesVersionInfo["ModuleVersion"].ToString(); + } + else if (requiredModulesVersionInfo.ContainsKey("MaximumVersion")) { - element.SetAttribute("version", requiredModules[dependencyName].ToString()); + // Only max specified: (, max] + versionRange = $"(, {requiredModulesVersionInfo["MaximumVersion"]}]"; + } + + if (!string.IsNullOrEmpty(versionRange)) + { + element.SetAttribute("version", versionRange); } dependenciesElement.AppendChild(element); @@ -1173,19 +1195,26 @@ private Hashtable ParseRequiredModules(Hashtable parsedMetadataHash) if (LanguagePrimitives.TryConvertTo(reqModule, out Hashtable moduleHash)) { string moduleName = moduleHash["ModuleName"] as string; - - if (moduleHash.ContainsKey("ModuleVersion")) + var versionInfo = new Hashtable(); + + // RequiredVersion cannot be used with ModuleVersion or MaximumVersion + if (moduleHash.ContainsKey("RequiredVersion")) { - dependenciesHash.Add(moduleName, moduleHash["ModuleVersion"]); + versionInfo["RequiredVersion"] = moduleHash["RequiredVersion"].ToString(); } - else if (moduleHash.ContainsKey("RequiredVersion")) - { - dependenciesHash.Add(moduleName, moduleHash["RequiredVersion"]); - } - else + else { - dependenciesHash.Add(moduleName, string.Empty); + // ModuleVersion and MaximumVersion can be used together + if (moduleHash.ContainsKey("ModuleVersion")) + { + versionInfo["ModuleVersion"] = moduleHash["ModuleVersion"].ToString(); + } + if (moduleHash.ContainsKey("MaximumVersion")) + { + versionInfo["MaximumVersion"] = moduleHash["MaximumVersion"].ToString(); + } } + dependenciesHash.Add(moduleName, versionInfo); } else if (LanguagePrimitives.TryConvertTo(reqModule, out string moduleName)) { diff --git a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 index 7d9fe9770..2ef0e5b05 100644 --- a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 @@ -42,6 +42,39 @@ function CreateTestModule '@ | Out-File -FilePath $moduleSrc } +function CompressExpandRetrieveNuspec +{ + param( + [string]$PublishModuleBase, + [string]$PublishModuleName, + [string]$ModuleVersion, + [string]$RepositoryPath, + [string]$ModuleBasePath, + [string]$TestDrive, + [object[]]$RequiredModules, + [switch]$SkipModuleManifestValidate + ) + + $testFile = Join-Path -Path "TestSubDirectory" -ChildPath "TestSubDirFile.ps1" + $null = New-ModuleManifest -Path (Join-Path -Path $PublishModuleBase -ChildPath "$PublishModuleName.psd1") -ModuleVersion $version -Description "$PublishModuleName module" -RequiredModules $RequiredModules + $null = New-Item -Path (Join-Path -Path $PublishModuleBase -ChildPath $testFile) -Force + + $null = Compress-PSResource -Path $PublishModuleBase -DestinationPath $repositoryPath -SkipModuleManifestValidate:$SkipModuleManifestValidate + + # Must change .nupkg to .zip so that Expand-Archive can work on Windows PowerShell + $nupkgPath = Join-Path -Path $RepositoryPath -ChildPath "$PublishModuleName.$version.nupkg" + $zipPath = Join-Path -Path $RepositoryPath -ChildPath "$PublishModuleName.$version.zip" + Rename-Item -Path $nupkgPath -NewName $zipPath + $unzippedPath = Join-Path -Path $TestDrive -ChildPath "$PublishModuleName" + $null = New-Item $unzippedPath -Itemtype directory -Force + $null = Expand-Archive -Path $zipPath -DestinationPath $unzippedPath + + $nuspecPath = Join-Path -Path $unzippedPath -ChildPath "$PublishModuleName.nuspec" + $nuspecxml = [xml](Get-Content $nuspecPath) + $null = Remove-Item $unzippedPath -Force -Recurse + return $nuspecxml +} + Describe "Test Compress-PSResource" -tags 'CI' { BeforeAll { Get-NewPSResourceRepositoryFile @@ -218,6 +251,150 @@ Describe "Test Compress-PSResource" -tags 'CI' { $fileInfoObject.Name | Should -Be "$script:PublishModuleName.$version.nupkg" } + It "Compress-PSResource creates nuspec dependecy version range when RequiredVersion is in RequiredModules section" { + $version = "1.0.0" + $requiredModules = @( + @{ + 'ModuleName' = 'PSGetTestRequiredModule' + 'GUID' = (New-Guid).Guid + 'RequiredVersion' = '2.0.0' + } + ) + $compressParams = @{ + 'PublishModuleBase' = $script:PublishModuleBase + 'PublishModuleName' = $script:PublishModuleName + 'ModuleVersion' = $version + 'RepositoryPath' = $script:repositoryPath + 'TestDrive' = $TestDrive + 'RequiredModules' = $requiredModules + 'SkipModuleManifestValidate' = $true + } + $nuspecxml = CompressExpandRetrieveNuspec @compressParams + # removing spaces as the nuget packaging is formatting the version range and adding spaces even when the original nuspec file doesn't have spaces. + # e.g (,2.0.0] is being formatted to (, 2.0.0] + $nuspecxml.package.metadata.dependencies.dependency.version.replace(' ', '') | Should -BeExactly '[2.0.0]' + } + + It "Compress-PSResource creates nuspec dependecy version range when ModuleVersion is in RequiredModules section" { + $version = "1.0.0" + $requiredModules = @( + @{ + 'ModuleName' = 'PSGetTestRequiredModule' + 'GUID' = (New-Guid).Guid + 'ModuleVersion' = '2.0.0' + } + ) + $compressParams = @{ + 'PublishModuleBase' = $script:PublishModuleBase + 'PublishModuleName' = $script:PublishModuleName + 'ModuleVersion' = $version + 'RepositoryPath' = $script:repositoryPath + 'TestDrive' = $TestDrive + 'RequiredModules' = $requiredModules + 'SkipModuleManifestValidate' = $true + } + $nuspecxml = CompressExpandRetrieveNuspec @compressParams + $nuspecxml.package.metadata.dependencies.dependency.version.replace(' ', '') | Should -BeExactly '2.0.0' + } + + It "Compress-PSResource creates nuspec dependecy version range when MaximumVersion is in RequiredModules section" { + $version = "1.0.0" + $requiredModules = @( + @{ + 'ModuleName' = 'PSGetTestRequiredModule' + 'GUID' = (New-Guid).Guid + 'MaximumVersion' = '2.0.0' + } + ) + $compressParams = @{ + 'PublishModuleBase' = $script:PublishModuleBase + 'PublishModuleName' = $script:PublishModuleName + 'ModuleVersion' = $version + 'RepositoryPath' = $script:repositoryPath + 'TestDrive' = $TestDrive + 'RequiredModules' = $requiredModules + 'SkipModuleManifestValidate' = $true + } + $nuspecxml = CompressExpandRetrieveNuspec @compressParams + $nuspecxml.package.metadata.dependencies.dependency.version.replace(' ', '') | Should -BeExactly '(,2.0.0]' + } + + It "Compress-PSResource creates nuspec dependecy version range when ModuleVersion and MaximumVersion are in RequiredModules section" { + $version = "1.0.0" + $requiredModules = @( + @{ + 'ModuleName' = 'PSGetTestRequiredModule' + 'GUID' = (New-Guid).Guid + 'ModuleVersion' = '1.0.0' + 'MaximumVersion' = '2.0.0' + } + ) + $compressParams = @{ + 'PublishModuleBase' = $script:PublishModuleBase + 'PublishModuleName' = $script:PublishModuleName + 'ModuleVersion' = $version + 'RepositoryPath' = $script:repositoryPath + 'TestDrive' = $TestDrive + 'RequiredModules' = $requiredModules + 'SkipModuleManifestValidate' = $true + } + $nuspecxml = CompressExpandRetrieveNuspec @compressParams + $nuspecxml.package.metadata.dependencies.dependency.version.replace(' ', '') | Should -BeExactly '[1.0.0,2.0.0]' + } + + It "Compress-PSResource creates nuspec dependecy version range when there are multiple modules in RequiredModules section" { + $version = "1.0.0" + $requiredModules = @( + @{ + 'ModuleName' = 'PSGetTestRequiredModuleRequiredVersion' + 'GUID' = (New-Guid).Guid + 'RequiredVersion' = '1.0.0' + }, + @{ + 'ModuleName' = 'PSGetTestRequiredModuleModuleVersion' + 'GUID' = (New-Guid).Guid + 'ModuleVersion' = '2.0.0' + }, + @{ + 'ModuleName' = 'PSGetTestRequiredModuleMaximumVersion' + 'GUID' = (New-Guid).Guid + 'MaximumVersion' = '3.0.0' + }, + @{ + 'ModuleName' = 'PSGetTestRequiredModuleModuleAndMaximumVersion' + 'GUID' = (New-Guid).Guid + 'ModuleVersion' = '4.0.0' + 'MaximumVersion' = '5.0.0' + } + ) + $compressParams = @{ + 'PublishModuleBase' = $script:PublishModuleBase + 'PublishModuleName' = $script:PublishModuleName + 'ModuleVersion' = $version + 'RepositoryPath' = $script:repositoryPath + 'TestDrive' = $TestDrive + 'RequiredModules' = $requiredModules + 'SkipModuleManifestValidate' = $true + } + $nuspecxml = CompressExpandRetrieveNuspec @compressParams + foreach ($dependency in $nuspecxml.package.metadata.dependencies.dependency) { + switch ($dependency.id) { + "PSGetTestRequiredModuleRequiredVersion" { + $dependency.version.replace(' ', '') | Should -BeExactly '[1.0.0]' + } + "PSGetTestRequiredModuleModuleVersion" { + $dependency.version.replace(' ', '') | Should -BeExactly '2.0.0' + } + "PSGetTestRequiredModuleMaximumVersion" { + $dependency.version.replace(' ', '') | Should -BeExactly '(,3.0.0]' + } + "PSGetTestRequiredModuleModuleAndMaximumVersion" { + $dependency.version.replace(' ', '') | Should -BeExactly '[4.0.0,5.0.0]' + } + } + } + } + <# Test for Signing the nupkg. Signing doesn't work It "Compressed Module is able to be signed with a certificate" { $version = "1.0.0" From 85ae55cbf2b9db181b306d1577d88e0e5374fcf0 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 12 Feb 2025 13:09:10 -0500 Subject: [PATCH 132/160] Update Dependency parsing logic to account for Az packages with Az* naming --- src/code/PSResourceInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 409665edc..0e5aabc85 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -972,7 +972,7 @@ public static bool TryConvertFromContainerRegistryJson( metadata["Dependencies"] = ParseContainerRegistryDependencies(requiredModulesElement, out errorMsg).ToArray(); } - if (string.Equals(packageName, "Az", StringComparison.OrdinalIgnoreCase) || packageName.StartsWith("Az.", StringComparison.OrdinalIgnoreCase)) + if (packageName.StartsWith("Az", StringComparison.OrdinalIgnoreCase)) { if (rootDom.TryGetProperty("ModuleList", out JsonElement moduleListDepsElement)) { From 53562a4a1ea6ee8dad0791d89f91998ee90f981b Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 12 Feb 2025 13:58:33 -0500 Subject: [PATCH 133/160] specify Azpreview explicitly in condition --- src/code/PSResourceInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 0e5aabc85..547828393 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -972,7 +972,7 @@ public static bool TryConvertFromContainerRegistryJson( metadata["Dependencies"] = ParseContainerRegistryDependencies(requiredModulesElement, out errorMsg).ToArray(); } - if (packageName.StartsWith("Az", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(packageName, "Az", StringComparison.OrdinalIgnoreCase) || string.Equals(packageName, "Azpreview", StringComparison.OrdinalIgnoreCase) || packageName.StartsWith("Az.", StringComparison.OrdinalIgnoreCase)) { if (rootDom.TryGetProperty("ModuleList", out JsonElement moduleListDepsElement)) { From d175eaa1a7a2852cc3b2022346a0e0f040f34e43 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 12 Feb 2025 14:04:01 -0500 Subject: [PATCH 134/160] add test for Azpreview from MAR --- .../FindPSResourceContainerRegistryServer.Tests.ps1 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index b7ffdfb8e..43eca7a25 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -255,4 +255,9 @@ Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { $res.Dependencies.Length | Should -Be 1 $res.Dependencies[0].Name | Should -Be "Az.Accounts" } + + It "Should find Azpreview resource and it's dependency given specific Name and Version" { + $res = Find-PSResource -Name "Az.Storage" -Version "13.2.0" -Repository "MAR" + $res.Dependencies.Length | Should -Not -Be 0 + } } From 0f13698eb97c60e3197936546bc92d12d8cc14c8 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 12 Feb 2025 15:42:04 -0500 Subject: [PATCH 135/160] fix test for Azpreview from MAR --- .../FindPSResourceContainerRegistryServer.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index 43eca7a25..69ca8c717 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -257,7 +257,7 @@ Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { } It "Should find Azpreview resource and it's dependency given specific Name and Version" { - $res = Find-PSResource -Name "Az.Storage" -Version "13.2.0" -Repository "MAR" + $res = Find-PSResource -Name "Azpreview" -Version "13.2.0" -Repository "MAR" $res.Dependencies.Length | Should -Not -Be 0 } } From e3cce0a2507df8994bae69a514b858b89b29cb44 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 12 Feb 2025 17:08:56 -0500 Subject: [PATCH 136/160] update outdated az FindName() from MAR test --- .../FindPSResourceContainerRegistryServer.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index 69ca8c717..db2d5f0a2 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -247,7 +247,7 @@ Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { It "Should find resource given specific Name, Version null" { $res = Find-PSResource -Name "Az.Accounts" -Repository "MAR" $res.Name | Should -Be "Az.Accounts" - $res.Version | Should -Be "4.0.0" + $res.Version | Should -Be "4.0.2" } It "Should find resource and its dependency given specific Name and Version" { From ab2781076bea7be094ea09459459350a1421615c Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 12 Feb 2025 18:56:31 -0500 Subject: [PATCH 137/160] check if version is greaterthan not equal --- .../FindPSResourceContainerRegistryServer.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index db2d5f0a2..ccc0142cb 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -247,7 +247,7 @@ Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { It "Should find resource given specific Name, Version null" { $res = Find-PSResource -Name "Az.Accounts" -Repository "MAR" $res.Name | Should -Be "Az.Accounts" - $res.Version | Should -Be "4.0.2" + $res.Version | Should -BeGreaterThan "4.0.0" } It "Should find resource and its dependency given specific Name and Version" { From 5840fa6f6b2fbb00adf34d409022688d524ea22c Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 18 Feb 2025 01:18:33 -0500 Subject: [PATCH 138/160] fix install for a package from MAR that may have different version than its normalized version --- src/code/InstallHelper.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index e31c2b86c..8ac7fa8fe 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -824,6 +824,14 @@ private Hashtable BeginPackageInstall( pkgVersion += $"-{pkgToInstall.Prerelease}"; } } + + // For most repositories/providers the server will use the normalized version, which pkgVersion originally reflects + // However, for container registries the version must exactly match what was in the artifact manifest and then reflected in PSResourceInfo.Version.ToString() + if (currentServer.Repository.ApiVersion == PSRepositoryInfo.APIVersion.ContainerRegistry) + { + pkgVersion = pkgToInstall.Version.ToString(); + } + // Check to see if the pkg is already installed (ie the pkg is installed and the version satisfies the version range provided via param) if (!_reinstall) { From 177af2473208a0157e7957534fd4b4b8b9a720f5 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 18 Feb 2025 13:29:02 -0500 Subject: [PATCH 139/160] add find and install tests for 2 digit pkg --- ...SResourceContainerRegistryServer.Tests.ps1 | 20 +++++++++++ ...SResourceContainerRegistryServer.Tests.ps1 | 35 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index b7ffdfb8e..f0e5b6edc 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -8,6 +8,7 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { BeforeAll{ $testModuleName = "test-module" + $testModuleWith2DigitVersion = "test-2DigitPkg" $testModuleParentName = "test_parent_mod" $testModuleDependencyName = "test_dependency_mod" $testScriptName = "test-script" @@ -82,6 +83,25 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Count | Should -BeGreaterOrEqual 1 } + It "Find resource when version contains different number of digits than the normalized version" { + # the resource has version "1.0", but querying with any equivalent version should work + $res1DigitVersion = Find-PSResource -Name $testModuleWith2DigitVersion -Version "1" -Repository $ACRRepoName + $res1DigitVersion | Should -Not -BeNullOrEmpty + $res1DigitVersion.Version | Should -Be "1.0" + + $res2DigitVersion = Find-PSResource -Name $testModuleWith2DigitVersion -Version "1.0" -Repository $ACRRepoName + $res2DigitVersion | Should -Not -BeNullOrEmpty + $res2DigitVersion.Version | Should -Be "1.0" + + $res3DigitVersion = Find-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0" -Repository $ACRRepoName + $res3DigitVersion | Should -Not -BeNullOrEmpty + $res3DigitVersion.Version | Should -Be "1.0" + + $res4DigitVersion = Find-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0.0" -Repository $ACRRepoName + $res4DigitVersion | Should -Not -BeNullOrEmpty + $res4DigitVersion.Version | Should -Be "1.0" + } + It "Find module and dependencies when -IncludeDependencies is specified" { $res = Find-PSResource -Name $testModuleParentName -Repository $ACRRepoName -IncludeDependencies $res | Should -Not -BeNullOrEmpty diff --git a/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 index 2ade007f2..0dbf394f9 100644 --- a/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 @@ -10,6 +10,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { BeforeAll { $testModuleName = "test-module" $testModuleName2 = "test-module2" + $testModuleWith2DigitVersion = "test-2DigitPkg" $testCamelCaseModuleName = "test-camelCaseModule" $testCamelCaseScriptName = "test-camelCaseScript" $testModuleParentName = "test_parent_mod" @@ -33,7 +34,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { } AfterEach { - Uninstall-PSResource $testModuleName, $testModuleName2, $testCamelCaseModuleName, $testScriptName, $testCamelCaseScriptName -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue + Uninstall-PSResource $testModuleName, $testModuleName2, $testCamelCaseModuleName, $testScriptName, $testCamelCaseScriptName, $testModuleWith2DigitVersion -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue } AfterAll { @@ -75,6 +76,38 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { $pkg.Version | Should -BeExactly "1.0.0" } + It "Install resource when version contains different number of digits than the normalized version- 1 digit specified" { + # the resource has version "1.0", but querying with any equivalent version should work + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1" -Repository $ACRRepoName + $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion + $res | Should -Not -BeNullOrEmpty + $res.Version | Should -Be "1.0" + } + + It "Install resource when version contains different number of digits than the normalized version- 2 digits specified" { + # the resource has version "1.0", but querying with any equivalent version should work + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0" -Repository $ACRRepoName + $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion + $res | Should -Not -BeNullOrEmpty + $res.Version | Should -Be "1.0" + } + + It "Install resource when version contains different number of digits than the normalized version- 3 digits specified" { + # the resource has version "1.0", but querying with any equivalent version should work + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0" -Repository $ACRRepoName + $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion + $res | Should -Not -BeNullOrEmpty + $res.Version | Should -Be "1.0" + } + + It "Install resource when version contains different number of digits than the normalized version- 4 digits specified" { + # the resource has version "1.0", but querying with any equivalent version should work + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0.0" -Repository $ACRRepoName + $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion + $res | Should -Not -BeNullOrEmpty + $res.Version | Should -Be "1.0" + } + It "Install multiple resources by name" { $pkgNames = @($testModuleName, $testModuleName2) Install-PSResource -Name $pkgNames -Repository $ACRRepoName -TrustRepository From 35c9f946f4564d1a5330bfe5182f351aaaed2622 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 18 Feb 2025 14:52:19 -0500 Subject: [PATCH 140/160] add -TrustRepository to install tests --- .../InstallPSResourceContainerRegistryServer.Tests.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 index 0dbf394f9..fab2b912d 100644 --- a/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 @@ -78,7 +78,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { It "Install resource when version contains different number of digits than the normalized version- 1 digit specified" { # the resource has version "1.0", but querying with any equivalent version should work - Install-PSResource -Name $testModuleWith2DigitVersion -Version "1" -Repository $ACRRepoName + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1" -Repository $ACRRepoName -TrustRepository $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion $res | Should -Not -BeNullOrEmpty $res.Version | Should -Be "1.0" @@ -86,7 +86,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { It "Install resource when version contains different number of digits than the normalized version- 2 digits specified" { # the resource has version "1.0", but querying with any equivalent version should work - Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0" -Repository $ACRRepoName + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0" -Repository $ACRRepoName -TrustRepository $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion $res | Should -Not -BeNullOrEmpty $res.Version | Should -Be "1.0" @@ -94,7 +94,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { It "Install resource when version contains different number of digits than the normalized version- 3 digits specified" { # the resource has version "1.0", but querying with any equivalent version should work - Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0" -Repository $ACRRepoName + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0" -Repository $ACRRepoName -TrustRepository $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion $res | Should -Not -BeNullOrEmpty $res.Version | Should -Be "1.0" @@ -102,7 +102,7 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { It "Install resource when version contains different number of digits than the normalized version- 4 digits specified" { # the resource has version "1.0", but querying with any equivalent version should work - Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0.0" -Repository $ACRRepoName + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0.0" -Repository $ACRRepoName -TrustRepository $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion $res | Should -Not -BeNullOrEmpty $res.Version | Should -Be "1.0" From dd175718f84706ac070baa5d3d42967b19856cbe Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 18 Feb 2025 15:13:00 -0500 Subject: [PATCH 141/160] account for prerelease version when using manifest version --- src/code/InstallHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 8ac7fa8fe..3e0d8ae9a 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -829,7 +829,7 @@ private Hashtable BeginPackageInstall( // However, for container registries the version must exactly match what was in the artifact manifest and then reflected in PSResourceInfo.Version.ToString() if (currentServer.Repository.ApiVersion == PSRepositoryInfo.APIVersion.ContainerRegistry) { - pkgVersion = pkgToInstall.Version.ToString(); + pkgVersion = String.IsNullOrEmpty(pkgToInstall.Prerelease) ? pkgToInstall.Version.ToString() : $"{pkgToInstall.Version.ToString()}-{pkgToInstall.Prerelease}"; } // Check to see if the pkg is already installed (ie the pkg is installed and the version satisfies the version range provided via param) From 336d9103c2fbaa5a304618adc2df5d32384a78ec Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 18 Feb 2025 15:38:56 -0500 Subject: [PATCH 142/160] add test for preview install --- .../InstallPSResourceContainerRegistryServer.Tests.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 index fab2b912d..5e4eb76ea 100644 --- a/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 @@ -108,6 +108,15 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { $res.Version | Should -Be "1.0" } + It "Install resource when version contains different number of digits than the normalized version- 4 digits specified and prerelease" { + # the resource has version "1.0", but querying with any equivalent version should work + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.5.0.0" -Repository $ACRRepoName -TrustRepository + $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion + $res | Should -Not -BeNullOrEmpty + $res.Version | Should -Be "1.5" + $res.Prerelease | Should -Be "alpha" + } + It "Install multiple resources by name" { $pkgNames = @($testModuleName, $testModuleName2) Install-PSResource -Name $pkgNames -Repository $ACRRepoName -TrustRepository From f51a8b8cd930b65f9f0d1a4a40468e416aff47ff Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 18 Feb 2025 16:13:12 -0500 Subject: [PATCH 143/160] fix error in test logic --- .../InstallPSResourceContainerRegistryServer.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 index 5e4eb76ea..5f80ace08 100644 --- a/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 @@ -108,9 +108,9 @@ Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { $res.Version | Should -Be "1.0" } - It "Install resource when version contains different number of digits than the normalized version- 4 digits specified and prerelease" { + It "Install resource where version specified is a prerelease version" { # the resource has version "1.0", but querying with any equivalent version should work - Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.5.0.0" -Repository $ACRRepoName -TrustRepository + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.5-alpha" -Prerelease -Repository $ACRRepoName -TrustRepository $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion $res | Should -Not -BeNullOrEmpty $res.Version | Should -Be "1.5" From 9aa85383debbdc83d89dbf1c77f88c88ceda7621 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 19 Feb 2025 11:40:36 -0800 Subject: [PATCH 144/160] Allow wildcard for MAR repository for FindAll and FindByName (#1786) --- src/code/ContainerRegistryServerAPICalls.cs | 92 ++++++++++++++++--- ...SResourceContainerRegistryServer.Tests.ps1 | 14 ++- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs index d9175c0b0..973525daa 100644 --- a/src/code/ContainerRegistryServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -46,6 +46,7 @@ internal class ContainerRegistryServerAPICalls : ServerApiCall const string containerRegistryFindImageVersionUrlTemplate = "https://{0}/v2/{1}/tags/list"; // 0 - registry, 1 - repo(modulename) const string containerRegistryStartUploadTemplate = "https://{0}/v2/{1}/blobs/uploads/"; // 0 - registry, 1 - packagename const string containerRegistryEndUploadTemplate = "https://{0}{1}&digest=sha256:{2}"; // 0 - registry, 1 - location, 2 - digest + const string containerRegistryRepositoryListTemplate = "https://{0}/v2/_catalog"; // 0 - registry #endregion @@ -76,13 +77,13 @@ public ContainerRegistryServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmd public override FindResults FindAll(bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindAll()"); - errRecord = new ErrorRecord( - new InvalidOperationException($"Find all is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), - "FindAllFailure", - ErrorCategory.InvalidOperation, - this); + var findResult = FindPackages("*", includePrerelease, out errRecord); + if (errRecord != null) + { + return emptyResponseResults; + } - return emptyResponseResults; + return findResult; } /// @@ -161,13 +162,13 @@ public override FindResults FindNameWithTag(string packageName, string[] tags, b public override FindResults FindNameGlobbing(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindNameGlobbing()"); - errRecord = new ErrorRecord( - new InvalidOperationException($"FindNameGlobbing all is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), - "FindNameGlobbingFailure", - ErrorCategory.InvalidOperation, - this); + var findResult = FindPackages(packageName, includePrerelease, out errRecord); + if (errRecord != null) + { + return emptyResponseResults; + } - return emptyResponseResults; + return findResult; } /// @@ -591,6 +592,20 @@ internal JObject FindContainerRegistryImageTags(string packageName, string versi return GetHttpResponseJObjectUsingDefaultHeaders(findImageUrl, HttpMethod.Get, defaultHeaders, out errRecord); } + /// + /// Helper method to find all packages on container registry + /// + /// + /// + /// + internal JObject FindAllRepositories(string containerRegistryAccessToken, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindAllRepositories()"); + string repositoryListUrl = string.Format(containerRegistryRepositoryListTemplate, Registry); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + return GetHttpResponseJObjectUsingDefaultHeaders(repositoryListUrl, HttpMethod.Get, defaultHeaders, out errRecord); + } + /// /// Get metadata for a package version. /// @@ -1705,12 +1720,63 @@ private string PrependMARPrefix(string packageName) // If the repostitory is MAR and its not a wildcard search, we need to prefix the package name with MAR prefix. string updatedPackageName = Repository.IsMARRepository() && packageName.Trim() != "*" - ? string.Concat(prefix, packageName) + ? packageName.StartsWith(prefix) ? packageName : string.Concat(prefix, packageName) : packageName; return updatedPackageName; } + private FindResults FindPackages(string packageName, bool includePrerelease, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindPackages()"); + errRecord = null; + string containerRegistryAccessToken = GetContainerRegistryAccessToken(out errRecord); + if (errRecord != null) + { + return emptyResponseResults; + } + + var pkgResult = FindAllRepositories(containerRegistryAccessToken, out errRecord); + if (errRecord != null) + { + return emptyResponseResults; + } + + List repositoriesList = new List(); + var isMAR = Repository.IsMARRepository(); + + // Convert the list of repositories to a list of hashtables + foreach (var repository in pkgResult["repositories"].ToList()) + { + string repositoryName = repository.ToString(); + + if (isMAR && !repositoryName.StartsWith(PSRepositoryInfo.MARPrefix)) + { + continue; + } + + // This remove the 'psresource/' prefix from the repository name for comparison with wildcard. + string moduleName = repositoryName.Substring(11); + + WildcardPattern wildcardPattern = new WildcardPattern(packageName, WildcardOptions.IgnoreCase); + + if (!wildcardPattern.IsMatch(moduleName)) + { + continue; + } + + _cmdletPassedIn.WriteDebug($"Found repository: {repositoryName}"); + + repositoriesList.AddRange(FindPackagesWithVersionHelper(repositoryName, VersionType.VersionRange, versionRange: VersionRange.All, requiredVersion: null, includePrerelease, getOnlyLatest: true, out errRecord)); + if (errRecord != null) + { + return emptyResponseResults; + } + } + + return new FindResults(stringResponse: new string[] { }, hashtableResponse: repositoriesList.ToArray(), responseType: containerRegistryFindResponseType); + } + #endregion } } diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index b7ffdfb8e..3b403b24d 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -247,7 +247,7 @@ Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { It "Should find resource given specific Name, Version null" { $res = Find-PSResource -Name "Az.Accounts" -Repository "MAR" $res.Name | Should -Be "Az.Accounts" - $res.Version | Should -Be "4.0.0" + $res.Version | Should -BeGreaterThan ([Version]"4.0.0") } It "Should find resource and its dependency given specific Name and Version" { @@ -255,4 +255,16 @@ Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { $res.Dependencies.Length | Should -Be 1 $res.Dependencies[0].Name | Should -Be "Az.Accounts" } + + It "Should find resource with wildcard in Name" { + $res = Find-PSResource -Name "Az.App*" -Repository "MAR" + $res | Should -Not -BeNullOrEmpty + $res.Count | Should -BeGreaterThan 1 + } + + It "Should find all resource with wildcard in Name" { + $res = Find-PSResource -Name "*" -Repository "MAR" + $res | Should -Not -BeNullOrEmpty + $res.Count | Should -BeGreaterThan 1 + } } From 009160cd395cbe3951c9294391b78dc3b77ea6f1 Mon Sep 17 00:00:00 2001 From: alerickson <25858831+alerickson@users.noreply.github.com> Date: Thu, 20 Feb 2025 11:42:05 -0800 Subject: [PATCH 145/160] Update README.md --- README.md | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e94344ae1..a0ca6c443 100644 --- a/README.md +++ b/README.md @@ -52,25 +52,22 @@ Please use the [PowerShell Gallery](https://www.powershellgallery.com) to get th ### Build the project -```powershell -# Build for the net472 framework -PS C:\Repos\PSResourceGet> .\build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework net472 - -# Build for the netstandard2.0 framework -PS C:\Repos\PSResourceGet> .\build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework netstandard2.0 -``` - -### Publish the module to a local repository -======= -* Run functional tests +Note: Please ensure you have the exact version of the .NET SDK installed. The current version can be found in the [global.json](https://github.com/PowerShell/PSResourceGet/blob/master/global.json) and installed from the [.NET website](https://dotnet.microsoft.com/en-us/download). + ```powershell + # Build for the net472 framework + PS C:\Repos\PSResourceGet> .\build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework net472 + ``` -```powershell -PS C:\Repos\PSResourceGet> Invoke-Pester -``` +### Run functional tests -```powershell -PS C:\Repos\PSResourceGet> Invoke-Pester -``` +* Run all tests + ```powershell + PS C:\Repos\PSResourceGet> Invoke-Pester + ``` +* Run an individual test + ```powershell + PS C:\Repos\PSResourceGet> Invoke-Pester + ``` ### Import the built module into a new PowerShell session From 9c741e4ce8777f3f126c2c79000752d857f9da27 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Fri, 21 Feb 2025 10:27:14 -0800 Subject: [PATCH 146/160] Use authentication challenge for unauthenticated container registry repository (#1797) --- src/code/ContainerRegistryServerAPICalls.cs | 82 ++++++++++++++++++- ...SResourceContainerRegistryServer.Tests.ps1 | 36 +++++++- 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs index 973525daa..785c7aeae 100644 --- a/src/code/ContainerRegistryServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -38,7 +38,7 @@ internal class ContainerRegistryServerAPICalls : ServerApiCall private static readonly FindResults emptyResponseResults = new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); const string containerRegistryRefreshTokenTemplate = "grant_type=access_token&service={0}&tenant={1}&access_token={2}"; // 0 - registry, 1 - tenant, 2 - access token - const string containerRegistryAccessTokenTemplate = "grant_type=refresh_token&service={0}&scope=repository:*:*&refresh_token={1}"; // 0 - registry, 1 - refresh token + const string containerRegistryAccessTokenTemplate = "grant_type=refresh_token&service={0}&scope=repository:*:*&scope=registry:catalog:*&refresh_token={1}"; // 0 - registry, 1 - refresh token const string containerRegistryOAuthExchangeUrlTemplate = "https://{0}/oauth2/exchange"; // 0 - registry const string containerRegistryOAuthTokenUrlTemplate = "https://{0}/oauth2/token"; // 0 - registry const string containerRegistryManifestUrlTemplate = "https://{0}/v2/{1}/manifests/{2}"; // 0 - registry, 1 - repo(modulename), 2 - tag(version) @@ -46,6 +46,7 @@ internal class ContainerRegistryServerAPICalls : ServerApiCall const string containerRegistryFindImageVersionUrlTemplate = "https://{0}/v2/{1}/tags/list"; // 0 - registry, 1 - repo(modulename) const string containerRegistryStartUploadTemplate = "https://{0}/v2/{1}/blobs/uploads/"; // 0 - registry, 1 - packagename const string containerRegistryEndUploadTemplate = "https://{0}{1}&digest=sha256:{2}"; // 0 - registry, 1 - location, 2 - digest + const string defaultScope = "&scope=repository:*:*&scope=registry:catalog:*"; const string containerRegistryRepositoryListTemplate = "https://{0}/v2/_catalog"; // 0 - registry #endregion @@ -392,12 +393,18 @@ internal string GetContainerRegistryAccessToken(out ErrorRecord errRecord) } else { - bool isRepositoryUnauthenticated = IsContainerRegistryUnauthenticated(Repository.Uri.ToString(), out errRecord); + bool isRepositoryUnauthenticated = IsContainerRegistryUnauthenticated(Repository.Uri.ToString(), out errRecord, out accessToken); if (errRecord != null) { return null; } + if (!string.IsNullOrEmpty(accessToken)) + { + _cmdletPassedIn.WriteVerbose("Anonymous access token retrieved."); + return accessToken; + } + if (!isRepositoryUnauthenticated) { accessToken = Utils.GetAzAccessToken(); @@ -437,15 +444,82 @@ internal string GetContainerRegistryAccessToken(out ErrorRecord errRecord) /// /// Checks if container registry repository is unauthenticated. /// - internal bool IsContainerRegistryUnauthenticated(string containerRegistyUrl, out ErrorRecord errRecord) + internal bool IsContainerRegistryUnauthenticated(string containerRegistyUrl, out ErrorRecord errRecord, out string anonymousAccessToken) { _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::IsContainerRegistryUnauthenticated()"); errRecord = null; + anonymousAccessToken = string.Empty; string endpoint = $"{containerRegistyUrl}/v2/"; HttpResponseMessage response; try { response = _sessionClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, endpoint)).Result; + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + // check if there is a auth challenge header + if (response.Headers.WwwAuthenticate.Count() > 0) + { + var authHeader = response.Headers.WwwAuthenticate.First(); + if (authHeader.Scheme == "Bearer") + { + // check if there is a realm + if (authHeader.Parameter.Contains("realm")) + { + // get the realm + var realm = authHeader.Parameter.Split(',')?.Where(x => x.Contains("realm"))?.FirstOrDefault()?.Split('=')[1]?.Trim('"'); + // get the service + var service = authHeader.Parameter.Split(',')?.Where(x => x.Contains("service"))?.FirstOrDefault()?.Split('=')[1]?.Trim('"'); + + if (string.IsNullOrEmpty(realm) || string.IsNullOrEmpty(service)) + { + errRecord = new ErrorRecord( + new InvalidOperationException("Failed to get realm or service from the auth challenge header."), + "RegistryUnauthenticationCheckError", + ErrorCategory.InvalidResult, + this); + + return false; + } + + string content = "grant_type=access_token&service=" + service + defaultScope; + var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; + + // get the anonymous access token + var url = $"{realm}?service={service}{defaultScope}"; + + // we dont check the errorrecord here because we want to return false if we get a 401 and not throw an error + var results = GetHttpResponseJObjectUsingContentHeaders(url, HttpMethod.Get, content, contentHeaders, out _); + + if (results == null) + { + _cmdletPassedIn.WriteDebug("Failed to get access token from the realm. results is null."); + return false; + } + + if (results["access_token"] == null) + { + _cmdletPassedIn.WriteDebug($"Failed to get access token from the realm. access_token is null. results: {results}"); + return false; + } + + anonymousAccessToken = results["access_token"].ToString(); + _cmdletPassedIn.WriteDebug("Anonymous access token retrieved"); + return true; + } + } + } + } + } + catch (HttpRequestException hre) + { + errRecord = new ErrorRecord( + hre, + "RegistryAnonymousAcquireError", + ErrorCategory.ConnectionError, + this); + + return false; } catch (Exception e) { @@ -1756,7 +1830,7 @@ private FindResults FindPackages(string packageName, bool includePrerelease, out } // This remove the 'psresource/' prefix from the repository name for comparison with wildcard. - string moduleName = repositoryName.Substring(11); + string moduleName = repositoryName.StartsWith("psresource/") ? repositoryName.Substring(11) : repositoryName; WildcardPattern wildcardPattern = new WildcardPattern(packageName, WildcardOptions.IgnoreCase); diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index 3b403b24d..8efa635a9 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -151,12 +151,11 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $err[0].FullyQualifiedErrorId | Should -BeExactly "FindCommandOrDscResourceFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } - It "Should not find all resources given Name '*'" { + It "Should find all resources given Name '*'" { # FindAll() $res = Find-PSResource -Name "*" -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue - $res | Should -BeNullOrEmpty - $err.Count | Should -BeGreaterThan 0 - $err[0].FullyQualifiedErrorId | Should -BeExactly "FindAllFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $res | Should -Not -BeNullOrEmpty + $res.Count | Should -BeGreaterThan 0 } It "Should find script given Name" { @@ -268,3 +267,32 @@ Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { $res.Count | Should -BeGreaterThan 1 } } + +# Skip this test fo +Describe 'Test Find-PSResource for unauthenticated ACR repository' -tags 'CI' { + BeforeAll { + $skipOnWinPS = $PSVersionTable.PSVersion.Major -eq 5 + + if (-not $skipOnWinPS) { + Register-PSResourceRepository -Name "Unauthenticated" -Uri "https://psresourcegetnoauth.azurecr.io/" -ApiVersion "ContainerRegistry" + } + } + + AfterAll { + if (-not $skipOnWinPS) { + Unregister-PSResourceRepository -Name "Unauthenticated" + } + } + + It "Should find resource given specific Name, Version null" { + + if ($skipOnWinPS) { + Set-ItResult -Pending -Because "Skipping test on Windows PowerShell" + return + } + + $res = Find-PSResource -Name "hello-world" -Repository "Unauthenticated" + $res.Name | Should -Be "hello-world" + $res.Version | Should -Be "5.0.0" + } +} From fa0880ba033b5f2aa9de908ff69840ab2d188cd7 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 25 Feb 2025 00:02:20 -0500 Subject: [PATCH 147/160] account for 2 digit version being concatenated with prerelease when creating version for .xml when installing --- src/code/Utils.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index d51ba0fdb..89de7cd10 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -316,9 +316,11 @@ public static string GetNormalizedVersionString( string versionString, string prerelease) { - // versionString may be like 1.2.0.0 or 1.2.0 + // versionString may be like 1.2.0.0 or 1.2.0 or 1.2 // prerelease may be null or "alpha1" // possible passed in examples: + // versionString: "1.2" <- container registry 2 digit version + // versionString: "1.2" prerelease: "alpha1" <- container registry 2 digit version // versionString: "1.2.0" prerelease: "alpha1" // versionString: "1.2.0" prerelease: "" <- doubtful though // versionString: "1.2.0.0" prerelease: "alpha1" @@ -331,9 +333,10 @@ public static string GetNormalizedVersionString( int numVersionDigits = versionString.Split('.').Count(); - if (numVersionDigits == 3) + if (numVersionDigits == 2 || numVersionDigits == 3) { - // versionString: "1.2.0" prerelease: "alpha1" + // versionString: "1.2.0" prerelease: "alpha1" -> 1.2.0-alpha1 + // versionString: "1.2" prerelease: "alpha1" -> 1.2-alpha1 return versionString + "-" + prerelease; } else if (numVersionDigits == 4) From 132413389f626cd0b007d2af2317f69e738c4a2e Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 25 Feb 2025 13:09:55 -0500 Subject: [PATCH 148/160] Get metadata properties when finding a PSResource from a ContainerRegistry --- src/code/PSResourceInfo.cs | 53 ++++++++++++++----- ...SResourceContainerRegistryServer.Tests.ps1 | 20 +++++++ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 547828393..205eac4b4 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -852,9 +852,9 @@ public static bool TryConvertFromContainerRegistryJson( pkgVersion = ParseHttpVersion(versionValue, out string prereleaseLabel); metadata["Version"] = pkgVersion; - if (rootDom.TryGetProperty("PrivateData", out JsonElement privateDataElement) && privateDataElement.TryGetProperty("PSData", out JsonElement psDataElement)) + if (rootDom.TryGetProperty("PrivateData", out JsonElement versionPrivateDataElement) && versionPrivateDataElement.TryGetProperty("PSData", out JsonElement versionPSDataElement)) { - if (psDataElement.TryGetProperty("Prerelease", out JsonElement pkgPrereleaseLabelElement) && !String.IsNullOrEmpty(pkgPrereleaseLabelElement.ToString().Trim())) + if (versionPSDataElement.TryGetProperty("Prerelease", out JsonElement pkgPrereleaseLabelElement) && !String.IsNullOrEmpty(pkgPrereleaseLabelElement.ToString().Trim())) { prereleaseLabel = pkgPrereleaseLabelElement.ToString().Trim(); versionValue += $"-{prereleaseLabel}"; @@ -884,19 +884,19 @@ public static bool TryConvertFromContainerRegistryJson( metadata["NormalizedVersion"] = parsedNormalizedVersion.ToNormalizedString(); // License Url - if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement) || rootDom.TryGetProperty("licenseUrl", out licenseUrlElement)) + if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement) || rootDom.TryGetProperty("licenseUrl", out licenseUrlElement) || rootDom.TryGetProperty("LicenseUri", out licenseUrlElement)) { metadata["LicenseUrl"] = ParseHttpUrl(licenseUrlElement.ToString()) as Uri; } // Project Url - if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement) || rootDom.TryGetProperty("projectUrl", out projectUrlElement)) + if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement) || rootDom.TryGetProperty("projectUrl", out projectUrlElement) || rootDom.TryGetProperty("ProjectUri", out projectUrlElement)) { metadata["ProjectUrl"] = ParseHttpUrl(projectUrlElement.ToString()) as Uri; } // Icon Url - if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement) || rootDom.TryGetProperty("iconUrl", out iconUrlElement)) + if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement) || rootDom.TryGetProperty("iconUrl", out iconUrlElement) || rootDom.TryGetProperty("IconUri", out iconUrlElement)) { metadata["IconUrl"] = ParseHttpUrl(iconUrlElement.ToString()) as Uri; } @@ -938,14 +938,19 @@ public static bool TryConvertFromContainerRegistryJson( } // Author - if (rootDom.TryGetProperty("Authors", out JsonElement authorsElement) || rootDom.TryGetProperty("authors", out authorsElement)) + if (rootDom.TryGetProperty("Authors", out JsonElement authorsElement) || rootDom.TryGetProperty("authors", out authorsElement) || rootDom.TryGetProperty("Author", out authorsElement)) { metadata["Authors"] = authorsElement.ToString(); + } - // CompanyName - // CompanyName is not provided in v3 pkg metadata response, so we've just set it to the author, - // which is often the company - metadata["CompanyName"] = authorsElement.ToString(); + if (rootDom.TryGetProperty("CompanyName", out JsonElement companyNameElement)) + { + metadata["CompanyName"] = companyNameElement.ToString(); + } + else + { + // if CompanyName property is not provided set it to the Author value which is often the same. + metadata["CompanyName"] = metadata["Authors"]; } // Copyright @@ -978,15 +983,39 @@ public static bool TryConvertFromContainerRegistryJson( { metadata["Dependencies"] = ParseContainerRegistryDependencies(moduleListDepsElement, out errorMsg).ToArray(); } - else if (rootDom.TryGetProperty("PrivateData", out JsonElement privateDataElement) && privateDataElement.TryGetProperty("PSData", out JsonElement psDataElement)) + else if (rootDom.TryGetProperty("PrivateData", out JsonElement depsPrivateDataElement) && depsPrivateDataElement.TryGetProperty("PSData", out JsonElement depsPSDataElement)) { - if (psDataElement.TryGetProperty("ModuleList", out JsonElement privateDataModuleListDepsElement)) + if (depsPSDataElement.TryGetProperty("ModuleList", out JsonElement privateDataModuleListDepsElement)) { metadata["Dependencies"] = ParseContainerRegistryDependencies(privateDataModuleListDepsElement, out errorMsg).ToArray(); } } } + if (rootDom.TryGetProperty("PrivateData", out JsonElement privateDataElement) && privateDataElement.TryGetProperty("PSData", out JsonElement psDataElement)) + { + // some properties that may be in PrivateData.PSData: LicenseUri, ProjectUri, IconUri, ReleaseNotes + if (!(metadata.ContainsKey("LicenseUrl") || metadata.ContainsKey("licenseUrl")) && psDataElement.TryGetProperty("LicenseUri", out JsonElement psDataLicenseUriElement)) + { + metadata["LicenseUrl"] = ParseHttpUrl(psDataLicenseUriElement.ToString()) as Uri; + } + + if (!(metadata.ContainsKey("ProjectUrl") || metadata.ContainsKey("ProjectUrl")) && psDataElement.TryGetProperty("ProjectUri", out JsonElement psDataProjectUriElement)) + { + metadata["ProjectUrl"] = ParseHttpUrl(psDataProjectUriElement.ToString()) as Uri; + } + + if (!(metadata.ContainsKey("IconUrl") || metadata.ContainsKey("IconUrl")) && psDataElement.TryGetProperty("IconUri", out JsonElement psDataIconUriElement)) + { + metadata["IconUrl"] = ParseHttpUrl(psDataIconUriElement.ToString()) as Uri; + } + + if (!metadata.ContainsKey("ReleaseNotes") && psDataElement.TryGetProperty("ReleaseNotes", out JsonElement psDataReleaseNotesElement)) + { + metadata["ReleaseNotes"] = psDataReleaseNotesElement.ToString(); + } + } + var additionalMetadataHashtable = new Dictionary { { "NormalizedVersion", metadata["NormalizedVersion"].ToString() } diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index 6bdeeecab..601de8769 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -232,6 +232,26 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.Dependencies.Length | Should -Be 1 $res.Dependencies[0].Name | Should -Be "Az.Accounts" } + + It "Should find resource and its associated author, licenseUri, projectUri, releaseNotes, etc properties" { + $res = Find-PSResource -Name "Az.Storage" -Version "8.0.0" -Repository $ACRRepoName + $res.Author | Should -Be "Microsoft Corporation" + $res.CompanyName | Should -Be "Microsoft Corporation" + $res.LicenseUri | Should -Be "https://aka.ms/azps-license" + $res.ProjectUri | Should -Be "https://github.com/Azure/azure-powershell" + $res.ReleaseNotes.Length | Should -Not -Be 0 + } + + It "Install script with companyname, copyright, and repository source location and validate" { + Install-PSResource -Name "Install-VSCode" -Version "1.4.2" -Repository $PSGalleryName -TrustRepository + + $res = Get-InstalledPSResource "Install-VSCode" -Version "1.4.2" + $res.Name | Should -Be "Install-VSCode" + $res.Version | Should -Be "1.4.2" + $res.CompanyName | Should -Be "Microsoft Corporation" + $res.Copyright | Should -Be "(c) Microsoft Corporation" + $res.RepositorySourceLocation | Should -Be $PSGalleryUri + } } Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { From 02ab696dd405fa242d0edeeaab3962144e0928ca Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 25 Feb 2025 13:21:54 -0500 Subject: [PATCH 149/160] Retrieve Tags property which can be in PrivateData.PSData but is not as common and add test for it --- src/code/PSResourceInfo.cs | 35 +++++++++++++++---- ...SResourceContainerRegistryServer.Tests.ps1 | 1 + 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 205eac4b4..cbab137e6 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -884,19 +884,19 @@ public static bool TryConvertFromContainerRegistryJson( metadata["NormalizedVersion"] = parsedNormalizedVersion.ToNormalizedString(); // License Url - if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement) || rootDom.TryGetProperty("licenseUrl", out licenseUrlElement) || rootDom.TryGetProperty("LicenseUri", out licenseUrlElement)) + if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement) || rootDom.TryGetProperty("licenseUrl", out licenseUrlElement)) { metadata["LicenseUrl"] = ParseHttpUrl(licenseUrlElement.ToString()) as Uri; } // Project Url - if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement) || rootDom.TryGetProperty("projectUrl", out projectUrlElement) || rootDom.TryGetProperty("ProjectUri", out projectUrlElement)) + if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement) || rootDom.TryGetProperty("projectUrl", out projectUrlElement)) { metadata["ProjectUrl"] = ParseHttpUrl(projectUrlElement.ToString()) as Uri; } // Icon Url - if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement) || rootDom.TryGetProperty("iconUrl", out iconUrlElement) || rootDom.TryGetProperty("IconUri", out iconUrlElement)) + if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement) || rootDom.TryGetProperty("iconUrl", out iconUrlElement)) { metadata["IconUrl"] = ParseHttpUrl(iconUrlElement.ToString()) as Uri; } @@ -995,17 +995,17 @@ public static bool TryConvertFromContainerRegistryJson( if (rootDom.TryGetProperty("PrivateData", out JsonElement privateDataElement) && privateDataElement.TryGetProperty("PSData", out JsonElement psDataElement)) { // some properties that may be in PrivateData.PSData: LicenseUri, ProjectUri, IconUri, ReleaseNotes - if (!(metadata.ContainsKey("LicenseUrl") || metadata.ContainsKey("licenseUrl")) && psDataElement.TryGetProperty("LicenseUri", out JsonElement psDataLicenseUriElement)) + if (!metadata.ContainsKey("LicenseUrl") && psDataElement.TryGetProperty("LicenseUri", out JsonElement psDataLicenseUriElement)) { metadata["LicenseUrl"] = ParseHttpUrl(psDataLicenseUriElement.ToString()) as Uri; } - if (!(metadata.ContainsKey("ProjectUrl") || metadata.ContainsKey("ProjectUrl")) && psDataElement.TryGetProperty("ProjectUri", out JsonElement psDataProjectUriElement)) + if (!metadata.ContainsKey("ProjectUrl") && psDataElement.TryGetProperty("ProjectUri", out JsonElement psDataProjectUriElement)) { metadata["ProjectUrl"] = ParseHttpUrl(psDataProjectUriElement.ToString()) as Uri; } - if (!(metadata.ContainsKey("IconUrl") || metadata.ContainsKey("IconUrl")) && psDataElement.TryGetProperty("IconUri", out JsonElement psDataIconUriElement)) + if (!metadata.ContainsKey("IconUrl") && psDataElement.TryGetProperty("IconUri", out JsonElement psDataIconUriElement)) { metadata["IconUrl"] = ParseHttpUrl(psDataIconUriElement.ToString()) as Uri; } @@ -1014,6 +1014,29 @@ public static bool TryConvertFromContainerRegistryJson( { metadata["ReleaseNotes"] = psDataReleaseNotesElement.ToString(); } + + if (!metadata.ContainsKey("Tags") && psDataElement.TryGetProperty("Tags", out JsonElement psDataTagsElement)) + { + string[] pkgTags = Utils.EmptyStrArray; + if (psDataTagsElement.ValueKind == JsonValueKind.Array) + { + var arrayLength = psDataTagsElement.GetArrayLength(); + List tags = new List(arrayLength); + foreach (var tag in psDataTagsElement.EnumerateArray()) + { + tags.Add(tag.ToString()); + } + + pkgTags = tags.ToArray(); + } + else if (psDataTagsElement.ValueKind == JsonValueKind.String) + { + string tagStr = psDataTagsElement.ToString(); + pkgTags = tagStr.Split(Utils.WhitespaceSeparator, StringSplitOptions.RemoveEmptyEntries); + } + + metadata["Tags"] = pkgTags; + } } var additionalMetadataHashtable = new Dictionary diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index 601de8769..89da28899 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -240,6 +240,7 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.LicenseUri | Should -Be "https://aka.ms/azps-license" $res.ProjectUri | Should -Be "https://github.com/Azure/azure-powershell" $res.ReleaseNotes.Length | Should -Not -Be 0 + $res.Tags.Length | Should -Be 5 } It "Install script with companyname, copyright, and repository source location and validate" { From 1c4e7d45fef32866d434f43b8d04c1eb8b72d88e Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 25 Feb 2025 13:23:21 -0500 Subject: [PATCH 150/160] clean up code --- .../FindPSResourceContainerRegistryServer.Tests.ps1 | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 index 89da28899..c9fda5637 100644 --- a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -242,17 +242,6 @@ Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { $res.ReleaseNotes.Length | Should -Not -Be 0 $res.Tags.Length | Should -Be 5 } - - It "Install script with companyname, copyright, and repository source location and validate" { - Install-PSResource -Name "Install-VSCode" -Version "1.4.2" -Repository $PSGalleryName -TrustRepository - - $res = Get-InstalledPSResource "Install-VSCode" -Version "1.4.2" - $res.Name | Should -Be "Install-VSCode" - $res.Version | Should -Be "1.4.2" - $res.CompanyName | Should -Be "Microsoft Corporation" - $res.Copyright | Should -Be "(c) Microsoft Corporation" - $res.RepositorySourceLocation | Should -Be $PSGalleryUri - } } Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { From ecce602b03eb675bb834fcc013c40239f32393a4 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 25 Feb 2025 15:47:36 -0500 Subject: [PATCH 151/160] check privateData JsonElement kind, if Object check for its properties, if string (as in the case of script PSResourceInfo) do not check as will throw error --- src/code/PSResourceInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index cbab137e6..dd840b62d 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -992,7 +992,7 @@ public static bool TryConvertFromContainerRegistryJson( } } - if (rootDom.TryGetProperty("PrivateData", out JsonElement privateDataElement) && privateDataElement.TryGetProperty("PSData", out JsonElement psDataElement)) + if (rootDom.TryGetProperty("PrivateData", out JsonElement privateDataElement) && privateDataElement.ValueKind == JsonValueKind.Object && privateDataElement.TryGetProperty("PSData", out JsonElement psDataElement)) { // some properties that may be in PrivateData.PSData: LicenseUri, ProjectUri, IconUri, ReleaseNotes if (!metadata.ContainsKey("LicenseUrl") && psDataElement.TryGetProperty("LicenseUri", out JsonElement psDataLicenseUriElement)) From a4d16b8a744c42582fe5aa9cd4fc200a6e5e9100 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 26 Feb 2025 10:01:27 -0800 Subject: [PATCH 152/160] Use deploybox to release to Gallery (#1800) --- .pipelines/PSResourceGet-Official.yml | 68 ++++++++++++--------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/.pipelines/PSResourceGet-Official.yml b/.pipelines/PSResourceGet-Official.yml index d096cf33e..ae6e2d484 100644 --- a/.pipelines/PSResourceGet-Official.yml +++ b/.pipelines/PSResourceGet-Official.yml @@ -29,7 +29,7 @@ variables: value: onebranch.azurecr.io/windows/ltsc2022/vse2022:latest # Docker image which is used to build the project https://aka.ms/obpipelines/containers resources: - repositories: + repositories: - repository: onebranchTemplates type: git name: OneBranch.Pipelines/GovernedTemplates @@ -41,6 +41,8 @@ extends: featureFlags: WindowsHostVersion: '1ESWindows2022' customTags: 'ES365AIMigrationTooling' + release: + category: NonAzure globalSdl: disableLegacyManifest: true sbom: @@ -58,7 +60,7 @@ extends: binskim: enabled: true apiscan: - enabled: false + enabled: false stages: - stage: stagebuild @@ -125,15 +127,6 @@ extends: AnalyzeInPipeline: true Language: csharp - - pwsh: | - $module = 'Microsoft.PowerShell.PSResourceGet' - Write-Verbose "installing $module..." -verbose - $ProgressPreference = 'SilentlyContinue' - Install-Module $module -AllowClobber -Force - displayName: Install PSResourceGet 0.9.0 or above for build.psm1 - env: - ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. - # this is installing .NET - pwsh: | Set-Location "$(repoRoot)" @@ -167,14 +160,14 @@ extends: } } displayName: Find all 3rd party files that need to be signed - + - task: onebranch.pipeline.signing@1 displayName: Sign 3rd Party files inputs: command: 'sign' signing_profile: 135020002 files_to_sign: '*.dll' - search_root: $(signSrcPath)/Microsoft.PowerShell.PSResourceGet/UnsignedDependencies + search_root: $(signSrcPath)/Microsoft.PowerShell.PSResourceGet/UnsignedDependencies - pwsh: | $newlySignedDepsPath = Join-Path -Path $(signSrcPath) -ChildPath "Microsoft.PowerShell.PSResourceGet" -AdditionalChildPath "UnsignedDependencies" @@ -216,7 +209,7 @@ extends: value: $(Build.SourcesDirectory)\PSResourceGet\.config\tsaoptions.json # Disable because SBOM was already built in the previous job - name: ob_sdl_sbom_enabled - value: false + value: true - name: signOutPath value: $(repoRoot)/signed - name: ob_signing_setup_enabled @@ -250,15 +243,12 @@ extends: displayName: Capture artifacts directory structure - pwsh: | - $module = 'Microsoft.PowerShell.PSResourceGet' - Write-Verbose "installing $module..." -verbose - $ProgressPreference = 'SilentlyContinue' - Install-Module $module -AllowClobber -Force - displayName: Install PSResourceGet 0.9.0 or above for build.psm1 + # This need to be done before set-location so the module from PSHome is loaded + Import-Module -Name Microsoft.PowerShell.PSResourceGet -Force - - pwsh: | Set-Location "$(signOutPath)\Microsoft.PowerShell.PSResourceGet" - New-Item -ItemType Directory -Path "$(signOutPath)\PublishedNupkg" -Force + $null = New-Item -ItemType Directory -Path "$(signOutPath)\PublishedNupkg" -Force + Register-PSResourceRepository -Name 'localRepo' -Uri "$(signOutPath)\PublishedNupkg" Publish-PSResource -Path "$(signOutPath)\Microsoft.PowerShell.PSResourceGet" -Repository 'localRepo' -Verbose displayName: Create nupkg for publishing @@ -274,7 +264,7 @@ extends: - pwsh: | Set-Location "$(signOutPath)\PublishedNupkg" Write-Host "Contents of signOutPath:" - Get-ChildItem "$(signOutPath)" -Recurse + Get-ChildItem "$(signOutPath)" -Recurse displayName: Find Nupkg - task: CopyFiles@2 @@ -282,10 +272,10 @@ extends: inputs: Contents: $(signOutPath)\PublishedNupkg\Microsoft.PowerShell.PSResourceGet.*.nupkg TargetFolder: $(ob_outputDirectory) - + - pwsh: | Write-Host "Contents of ob_outputDirectory:" - Get-ChildItem "$(ob_outputDirectory)" -Recurse + Get-ChildItem "$(ob_outputDirectory)" -Recurse displayName: Find Signed Nupkg - stage: release @@ -293,12 +283,14 @@ extends: dependsOn: stagebuild variables: version: $[ stageDependencies.build.main.outputs['package.version'] ] - drop: $(Pipeline.Workspace)/drop_build_main + drop: $(Pipeline.Workspace)/drop_stagebuild_nupkg + ob_release_environment: 'Production' + jobs: - job: validation displayName: Manual validation pool: - type: agentless + type: server timeoutInMinutes: 1440 steps: - task: ManualValidation@0 @@ -306,29 +298,31 @@ extends: inputs: instructions: Please validate the release timeoutInMinutes: 1440 + - job: PSGalleryPublish displayName: Publish to PSGallery dependsOn: validation + templateContext: + inputs: + - input: pipelineArtifact + artifactName: drop_stagebuild_nupkg pool: - type: windows + type: release + os: windows variables: ob_outputDirectory: '$(Build.ArtifactStagingDirectory)/ONEBRANCH_ARTIFACT' steps: - - download: current - displayName: Download artifact - - - pwsh: | - Get-ChildItem $(Pipeline.Workspace) -Recurse - displayName: Capture environment - - - pwsh: | - Get-ChildItem "$(Pipeline.Workspace)/drop_stagebuild_nupkg" -Recurse + - task: PowerShell@2 + inputs: + targetType: 'inline' + script: | + Get-ChildItem "$(Pipeline.Workspace)/" -Recurse displayName: Find signed Nupkg - task: NuGetCommand@2 displayName: Push PowerShellGet module artifacts to PSGallery feed inputs: command: push - packagesToPush: '$(Pipeline.Workspace)\drop_stagebuild_nupkg\PSResourceGet\signed\PublishedNupkg\Microsoft.PowerShell.PSResourceGet.*.nupkg' + packagesToPush: '$(Pipeline.Workspace)\PSResourceGet\signed\PublishedNupkg\Microsoft.PowerShell.PSResourceGet.*.nupkg' nuGetFeedType: external publishFeedCredentials: PSGet-PSGalleryPush From c8221215441752ec9ac6883438ac72d237699c5a Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 4 Mar 2025 17:53:12 -0500 Subject: [PATCH 153/160] only convert Version info to hashtable if its not an empty string --- src/code/PublishHelper.cs | 57 +++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 944a8b7f4..4cbfb0f4a 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -1126,38 +1126,43 @@ private string CreateNuspec( { XmlElement element = doc.CreateElement("dependency", nameSpaceUri); element.SetAttribute("id", dependencyName); - - var requiredModulesVersionInfo = (Hashtable)requiredModules[dependencyName]; - string versionRange = ""; - if (requiredModulesVersionInfo.ContainsKey("RequiredVersion")) + + string dependencyVersion = requiredModules[dependencyName].ToString(); + if (!string.IsNullOrEmpty(dependencyVersion)) { - // For RequiredVersion, use exact version notation [x.x.x] - string requiredModulesVersion = requiredModulesVersionInfo["RequiredVersion"].ToString(); - versionRange = $"[{requiredModulesVersion}]"; - } - else if (requiredModulesVersionInfo.ContainsKey("ModuleVersion") && requiredModulesVersionInfo.ContainsKey("MaximumVersion")) - { - // Version range when both min and max specified: [min,max] - versionRange = $"[{requiredModulesVersionInfo["ModuleVersion"]}, {requiredModulesVersionInfo["MaximumVersion"]}]"; - } - else if (requiredModulesVersionInfo.ContainsKey("ModuleVersion")) - { - // Only min specified: min (which means ≥ min) - versionRange = requiredModulesVersionInfo["ModuleVersion"].ToString(); - } - else if (requiredModulesVersionInfo.ContainsKey("MaximumVersion")) - { - // Only max specified: (, max] - versionRange = $"(, {requiredModulesVersionInfo["MaximumVersion"]}]"; - } + var requiredModulesVersionInfo = (Hashtable)requiredModules[dependencyName]; + string versionRange = String.Empty; + if (requiredModulesVersionInfo.ContainsKey("RequiredVersion")) + { + // For RequiredVersion, use exact version notation [x.x.x] + string requiredModulesVersion = requiredModulesVersionInfo["RequiredVersion"].ToString(); + versionRange = $"[{requiredModulesVersion}]"; + } + else if (requiredModulesVersionInfo.ContainsKey("ModuleVersion") && requiredModulesVersionInfo.ContainsKey("MaximumVersion")) + { + // Version range when both min and max specified: [min,max] + versionRange = $"[{requiredModulesVersionInfo["ModuleVersion"]}, {requiredModulesVersionInfo["MaximumVersion"]}]"; + } + else if (requiredModulesVersionInfo.ContainsKey("ModuleVersion")) + { + // Only min specified: min (which means ≥ min) + versionRange = requiredModulesVersionInfo["ModuleVersion"].ToString(); + } + else if (requiredModulesVersionInfo.ContainsKey("MaximumVersion")) + { + // Only max specified: (, max] + versionRange = $"(, {requiredModulesVersionInfo["MaximumVersion"]}]"; + } - if (!string.IsNullOrEmpty(versionRange)) - { - element.SetAttribute("version", versionRange); + if (!string.IsNullOrEmpty(versionRange)) + { + element.SetAttribute("version", versionRange); + } } dependenciesElement.AppendChild(element); } + metadataElement.AppendChild(dependenciesElement); } From 4e674783916cc5aa1eaf245c596697b5f2688317 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Tue, 4 Mar 2025 18:51:18 -0500 Subject: [PATCH 154/160] delete unzippedPath in test after it's written to or subsequent Expand-Archive calls to that path will fail --- test/PublishPSResourceTests/CompressPSResource.Tests.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 index 2ef0e5b05..28f74a742 100644 --- a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 @@ -185,6 +185,7 @@ Describe "Test Compress-PSResource" -tags 'CI' { Expand-Archive -Path $zipPath -DestinationPath $unzippedPath Test-Path -Path (Join-Path -Path $unzippedPath -ChildPath $testFile) | Should -Be $True + $null = Remove-Item $unzippedPath -Force -Recurse } It "Compresses a script" { From a812e2c2f70353bcab96e623eaf6cbd292fc9fd6 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Thu, 6 Mar 2025 12:30:00 -0500 Subject: [PATCH 155/160] Update changelog, release notes, version for v1.1.1 release (#1801) --- CHANGELOG/1.1.md | 12 ++++++++++++ src/Microsoft.PowerShell.PSResourceGet.psd1 | 13 ++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG/1.1.md b/CHANGELOG/1.1.md index ba6cce61e..4fb078eda 100644 --- a/CHANGELOG/1.1.md +++ b/CHANGELOG/1.1.md @@ -1,3 +1,15 @@ +# 1.1 Changelog + +## [1.1.1](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0..v1.1.1) - 2025-03-06 + +- Bugfix to retrieve all metadata properties when finding a PSResource from a ContainerRegistry repository (#1799) +- Update README.md (#1798) +- Use authentication challenge for unauthenticated ContainerRegistry repository (#1797) +- Bugfix for Install-PSResource with varying digit version against ContainerRegistry repository (#1796) +- Bugfix for updating ContainerRegistry dependency parsing logic to account for AzPreview package (#1792) +- Add wildcard support for MAR repository for FindAll and FindByName (#1786) +- Bugfix for nuspec dependency version range calculation for RequiredModules (#1784) + ## [1.1.0](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-rc3...v1.1.0) - 2025-01-09 ### Bug Fixes diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index 30994ba2e..c9ca1cdd9 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -4,7 +4,7 @@ @{ RootModule = './Microsoft.PowerShell.PSResourceGet.dll' NestedModules = @('./Microsoft.PowerShell.PSResourceGet.psm1') - ModuleVersion = '1.1.0' + ModuleVersion = '1.1.1' CompatiblePSEditions = @('Core', 'Desktop') GUID = 'e4e0bda1-0703-44a5-b70d-8fe704cd0643' Author = 'Microsoft Corporation' @@ -56,6 +56,17 @@ ProjectUri = 'https://go.microsoft.com/fwlink/?LinkId=828955' LicenseUri = 'https://go.microsoft.com/fwlink/?LinkId=829061' ReleaseNotes = @' +## 1.1.1 + +### Bug Fix +- Bugfix to retrieve all metadata properties when finding a PSResource from a ContainerRegistry repository (#1799) +- Update README.md (#1798) +- Use authentication challenge for unauthenticated ContainerRegistry repository (#1797) +- Bugfix for Install-PSResource with varying digit version against ContainerRegistry repository (#1796) +- Bugfix for updating ContainerRegistry dependency parsing logic to account for AzPreview package (#1792) +- Add wildcard support for MAR repository for FindAll and FindByName (#1786) +- Bugfix for nuspec dependency version range calculation for RequiredModules (#1784) + ## 1.1.0 ### Bug Fix From be51313cb30c276d91dc15c7e69a2f94e72fb293 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Thu, 6 Mar 2025 11:50:51 -0800 Subject: [PATCH 156/160] Update .NET to 8.0.406 (#1802) --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index b832c3a01..77d62424a 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.404" + "version": "8.0.406" } } From 89ca445d9e50c02607703594c59294aef2c0f416 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Thu, 6 Mar 2025 19:39:04 -0500 Subject: [PATCH 157/160] Remove InstallationPath to Install .NET Dependencies task (#1804) --- .pipelines/PSResourceGet-Official.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.pipelines/PSResourceGet-Official.yml b/.pipelines/PSResourceGet-Official.yml index ae6e2d484..cc51e2e78 100644 --- a/.pipelines/PSResourceGet-Official.yml +++ b/.pipelines/PSResourceGet-Official.yml @@ -115,8 +115,6 @@ extends: inputs: packageType: 'sdk' useGlobalJson: true - # this is to ensure that we are installing the dotnet at the same location as container by default install the dotnet sdks - installationPath: 'C:\Program Files\dotnet\' workingDirectory: $(repoRoot) - task: CodeQL3000Init@0 # Add CodeQL Init task right before your 'Build' step. From b1f82bef5364d9220cf0cc07df12fcb56e3c48cd Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Mon, 24 Mar 2025 11:43:14 -0700 Subject: [PATCH 158/160] Update NuGet assembly versions (#1807) --- src/code/Microsoft.PowerShell.PSResourceGet.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index b76dcc9fc..1d657e1a1 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -25,6 +25,7 @@ + From 3c437c41d0a660b28fa37a13316fb462683925ae Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Wed, 30 Apr 2025 12:22:31 -0400 Subject: [PATCH 159/160] Add CODEOWNERS file (#1818) Co-authored-by: anamnavi --- .github/CODEOWNERS | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..1b53fe53a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# https://help.github.com/articles/about-codeowners/ + +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, +# the following owners will be requested for +# review when someone opens a pull request. +* @anamnavi @alerickson @adityapatwardhan @SydneyhSmith From e2a238b7b886efac28883c1395bfb362fb6c523a Mon Sep 17 00:00:00 2001 From: Sean Wheeler Date: Wed, 7 May 2025 09:27:52 -0700 Subject: [PATCH 160/160] Add SupportsWildcards attribute to Repository parameter of Install-PSResource (#1808) --- src/code/InstallPSResource.cs | 37 ++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/code/InstallPSResource.cs b/src/code/InstallPSResource.cs index a65797268..d8af185f0 100644 --- a/src/code/InstallPSResource.cs +++ b/src/code/InstallPSResource.cs @@ -17,15 +17,15 @@ namespace Microsoft.PowerShell.PSResourceGet.Cmdlets /// The Install-PSResource cmdlet installs a resource. /// It returns nothing. /// - [Cmdlet(VerbsLifecycle.Install, - "PSResource", - DefaultParameterSetName = "NameParameterSet", + [Cmdlet(VerbsLifecycle.Install, + "PSResource", + DefaultParameterSetName = "NameParameterSet", SupportsShouldProcess = true)] [Alias("isres")] public sealed class InstallPSResource : PSCmdlet { - #region Parameters + #region Parameters /// /// Specifies the exact names of resources to install from a repository. @@ -42,7 +42,7 @@ class InstallPSResource : PSCmdlet [Parameter(ParameterSetName = NameParameterSet, ValueFromPipelineByPropertyName = true)] [ValidateNotNullOrEmpty] public string Version { get; set; } - + /// /// Specifies to allow installation of prerelease versions /// @@ -53,6 +53,7 @@ class InstallPSResource : PSCmdlet /// /// Specifies the repositories from which to search for the resource to be installed. /// + [SupportsWildcards] [Parameter(ParameterSetName = NameParameterSet, ValueFromPipelineByPropertyName = true)] [Parameter(ParameterSetName = InputObjectParameterSet, ValueFromPipelineByPropertyName = true)] [ArgumentCompleter(typeof(RepositoryNameCompleter))] @@ -83,9 +84,9 @@ public string TemporaryPath set { - if (WildcardPattern.ContainsWildcardCharacters(value)) - { - throw new PSArgumentException("Wildcard characters are not allowed in the temporary path."); + if (WildcardPattern.ContainsWildcardCharacters(value)) + { + throw new PSArgumentException("Wildcard characters are not allowed in the temporary path."); } // This will throw if path cannot be resolved @@ -99,7 +100,7 @@ public string TemporaryPath /// [Parameter] public SwitchParameter TrustRepository { get; set; } - + /// /// Overwrites a previously installed resource with the same name and version. /// @@ -130,7 +131,7 @@ public string TemporaryPath /// [Parameter] public SwitchParameter SkipDependencyCheck { get; set; } - + /// /// Check validation for signed and catalog files /// @@ -287,7 +288,7 @@ protected override void ProcessRecord() pkgCredential: Credential, reqResourceParams: null); break; - + case InputObjectParameterSet: foreach (var inputObj in InputObject) { string normalizedVersionString = Utils.GetNormalizedVersionString(inputObj.Version.ToString(), inputObj.Prerelease); @@ -362,7 +363,7 @@ protected override void ProcessRecord() ErrorCategory.InvalidData, this)); } - + RequiredResourceHelper(pkgsInFile); break; @@ -379,7 +380,7 @@ protected override void ProcessRecord() } } */ - + Hashtable pkgsHash = null; try { @@ -441,7 +442,7 @@ private void RequiredResourceHelper(Hashtable reqResourceHash) { var pkgNameEmptyOrWhitespaceError = new ErrorRecord( new ArgumentException($"The package name '{pkgName}' provided cannot be an empty string or whitespace."), - "pkgNameEmptyOrWhitespaceError", + "pkgNameEmptyOrWhitespaceError", ErrorCategory.InvalidArgument, this); @@ -454,7 +455,7 @@ private void RequiredResourceHelper(Hashtable reqResourceHash) { var requiredResourceHashtableInputFormatError = new ErrorRecord( new ArgumentException($"The RequiredResource input with name '{pkgName}' does not have a valid value, the value must be a hashtable."), - "RequiredResourceHashtableInputFormatError", + "RequiredResourceHashtableInputFormatError", ErrorCategory.InvalidArgument, this); @@ -483,7 +484,7 @@ private void RequiredResourceHelper(Hashtable reqResourceHash) ThrowTerminatingError(ParameterParsingError); } } - + if (pkgParams.Scope == ScopeType.AllUsers) { _pathsToInstallPkg = Utils.GetAllInstallationPaths(this, pkgParams.Scope); @@ -513,10 +514,10 @@ private void ProcessInstallHelper(string[] pkgNames, string pkgVersion, bool pkg "NameContainsWildcard", ErrorCategory.InvalidArgument, this)); - + return; } - + foreach (string error in errorMsgs) { WriteError(new ErrorRecord(