diff --git a/src/code/ACRHelper.cs b/src/code/ACRHelper.cs new file mode 100644 index 000000000..3950a8ee7 --- /dev/null +++ b/src/code/ACRHelper.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.PowerShell.PowerShellGet.UtilClasses; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; + +using Dbg = System.Diagnostics.Debug; + +namespace Microsoft.PowerShell.PowerShellGet.Cmdlets +{ + internal static class ACRHelper + { + 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 = AcrHttpHelper.GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; + callingCmdlet.WriteVerbose("Getting acr access token"); + var acrAccessToken = AcrHttpHelper.GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; + callingCmdlet.WriteVerbose($"Getting manifest for {moduleName} - {moduleVersion}"); + var manifest = AcrHttpHelper.GetAcrRepositoryManifestAsync(registry, moduleName, moduleVersion, acrAccessToken).Result; + var digest = manifest["layers"].FirstOrDefault()["digest"].ToString(); + callingCmdlet.WriteVerbose($"Downloading blob for {moduleName} - {moduleVersion}"); + var responseContent = AcrHttpHelper.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, System.IO.SeekOrigin.Begin); + content.CopyTo(fs); + fs.Close(); + + var pkgInfo = new PSResourceInfo(moduleName, moduleVersion, repo.Name); + + // 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; + } + } +} diff --git a/src/code/AcrHttpHelper.cs b/src/code/AcrHttpHelper.cs new file mode 100644 index 000000000..167ca8b5c --- /dev/null +++ b/src/code/AcrHttpHelper.cs @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Collections.ObjectModel; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.PowerShell.PowerShellGet.Cmdlets +{ + internal static class AcrHttpHelper + { + 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(); + + 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") + }; + } + } +} \ No newline at end of file diff --git a/src/code/FindACRModule.cs b/src/code/FindACRModule.cs new file mode 100644 index 000000000..bdaf35683 --- /dev/null +++ b/src/code/FindACRModule.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.PowerShell.PowerShellGet.UtilClasses; +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Net.Http; +using System.Net.Http.Headers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.PowerShell.PowerShellGet.Cmdlets +{ + internal static class FindACRModule + { + 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 = AcrHttpHelper.GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; + callingCmdlet.WriteVerbose("Getting acr access token"); + var acrAccessToken = AcrHttpHelper.GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; + + callingCmdlet.WriteVerbose("Getting tags"); + var foundTags = AcrHttpHelper.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; + } + } +} 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..606ce8f9f 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -480,12 +480,23 @@ 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; + 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 @@ -1160,6 +1171,193 @@ private void InjectCredentialsToSettings(ISettings settings, IPackageSourceProvi isPasswordClearText: true, String.Empty)); } + + // 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. + 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; + } + + 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(); + } + + private bool PushNupkgACR(string outputNupkgDir, 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, + this); + + WriteVerbose("Access token retrieved."); + + tenantID = Utils.GetSecretInfoFromSecretManagement( + repository.Name, + repositoryCredentialInfo, + this); + } + + // Call asynchronous network methods in a try/catch block to handle exceptions. + string registry = repository.Uri.Host; + + WriteVerbose("Getting acr refresh token"); + var acrRefreshToken = AcrHttpHelper.GetAcrRefreshTokenAsync(registry, tenantID, accessToken).Result; + WriteVerbose("Getting acr access token"); + var acrAccessToken = AcrHttpHelper.GetAcrAccessTokenAsync(registry, acrRefreshToken).Result; + + WriteVerbose("Start uploading blob"); + var moduleLocation = AcrHttpHelper.GetStartUploadBlobLocation(registry, _pkgName, acrAccessToken).Result; + + WriteVerbose("Computing digest for .nupkg file"); + bool digestCreated = CreateDigest(fullNupkgFile, out string digest, out ErrorRecord digestError); + if (!digestCreated) + { + ThrowTerminatingError(digestError); + } + + WriteVerbose("Finish uploading blob"); + bool moduleUploadSuccess = AcrHttpHelper.EndUploadBlob(registry, moduleLocation, fullNupkgFile, digest, false, acrAccessToken).Result; + + 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(); + + WriteVerbose("Start uploading an empty file"); + var emptyLocation = AcrHttpHelper.GetStartUploadBlobLocation(registry, _pkgName, acrAccessToken).Result; + + WriteVerbose("Computing digest for empty file"); + bool emptyDigestCreated = CreateDigest(emptyFilePath, out string emptyDigest, out ErrorRecord emptyDigestError); + if (!emptyDigestCreated) + { + ThrowTerminatingError(emptyDigestError); + } + + WriteVerbose("Finish uploading empty file"); + bool emptyFileUploadSuccess = AcrHttpHelper.EndUploadBlob(registry, emptyLocation, emptyFilePath, emptyDigest, false, acrAccessToken).Result; + + 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); + + WriteVerbose("Create the manifest layer"); + bool manifestCreated = AcrHttpHelper.CreateManifest(registry, _pkgName, _pkgVersion.OriginalVersion, configFilePath, true, acrAccessToken).Result; + + if (manifestCreated) + { + return true; + } + return false; + } #endregion } diff --git a/src/code/RepositorySettings.cs b/src/code/RepositorySettings.cs index 495483d61..36679673c 100644 --- a/src/code/RepositorySettings.cs +++ b/src/code/RepositorySettings.cs @@ -435,6 +435,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 +520,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 +652,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 +756,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), diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 9b578d4d3..fdc29342d 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -41,6 +41,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 +633,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 +1637,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)