diff --git a/documentation/Import-PnPFlow.md b/documentation/Import-PnPFlow.md new file mode 100644 index 000000000..c645ece58 --- /dev/null +++ b/documentation/Import-PnPFlow.md @@ -0,0 +1,128 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Import-PnPFlow.html +external help file: PnP.PowerShell.dll-Help.xml +title: Import-PnPFlow +--- + +# Import-PnPFlow + +## SYNOPSIS + +**Required Permissions** + +* Azure: management.azure.com + +Imports a Microsoft Power Automate Flow. + +## SYNTAX + +### With Zip Package +```powershell +Import-PnPFlow [-Environment ] [-PackagePath ] [-Name ] [-Connection ] + +``` + +## DESCRIPTION +This cmdlet imports a Microsoft Power Automate Flow from a ZIP package. At present, only flows originating from the same tenant are supported. + +Many times Importing a Microsoft Power Automate Flow will not be possible due to various reasons such as connections having gone stale, SharePoint sites referenced no longer existing or other configuration errors in the Flow. To display these errors when trying to Import a Flow, provide the -Verbose flag with your Import request. If not provided, these errors will silently be ignored. + +## EXAMPLES + +### Example 1 +```powershell +Import-PnPFlow -Environment (Get-PnPPowerPlatformEnvironment -Identity "myenvironment") -PackagePath C:\Temp\Export-ReEnableFlow_20250414140636.zip -Name NewFlowName +``` + +This will Import the specified Microsoft Power Automate Flow from the specified Power Platform environment as an output to the current output of PowerShell + +### Example 2 +```powershell +Import-PnPFlow -Environment (Get-PnPPowerPlatformEnvironment -IsDefault) -PackagePath C:\Temp\Export-ReEnableFlow_20250414140636.zip -Name NewFlowName +``` + +This will Import the specified Microsoft Power Automate Flow from the default Power Platform environment as an output to the current output of PowerShell + +### Example 3 +```powershell +Import-PnPFlow -PackagePath C:\Temp\Export-ReEnableFlow_20250414140636.zip -Name NewFlowName +``` + +This will Import a flow to the default environment. The flow will be imported as a zip package. The name of the flow will be set to NewFlowName. + +### Example 4 +```powershell +Import-PnPFlow -PackagePath C:\Temp\Export-ReEnableFlow_20250414140636.zip -Name NewFlowName -Verbose +``` + +This will Import a flow to the default environment. The flow will be imported as a zip package. The name of the flow will be set to NewFlowName. With the -Verbose flag, any errors that occur during the import process will be displayed in the console. + +## PARAMETERS + +### -Connection +Optional connection to be used by the cmdlet. +Retrieve the value for this parameter by either specifying -ReturnConnection on Connect-PnPOnline or by executing Get-PnPConnection. + +```yaml +Type: PnPConnection +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Environment +The name of the Power Platform environment or an Environment instance. If omitted, the default environment will be used. + +```yaml +Type: PowerPlatformEnvironmentPipeBind +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: The default environment +Accept pipeline input: True +Accept wildcard characters: False +``` + +### -PackagePath +Local path of the .zip package to import. The path must be a valid path on the local file system. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: true +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Name +The new name of the flow. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: true +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) \ No newline at end of file diff --git a/src/Commands/Model/PowerPlatform/PowerAutomate/ImportFlowResult.cs b/src/Commands/Model/PowerPlatform/PowerAutomate/ImportFlowResult.cs new file mode 100644 index 000000000..1805584d9 --- /dev/null +++ b/src/Commands/Model/PowerPlatform/PowerAutomate/ImportFlowResult.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PnP.PowerShell.Commands.Model.PowerPlatform.PowerAutomate +{ + public class ImportFlowResult + { + public string Name { get; set; } + public string Status { get; set; } + public ImportFlowDetails Details { get; set; } + } + + public class ImportFlowDetails + { + public string DisplayName { get; set; } + public string Description { get; set; } + public DateTime CreatedTime { get; set; } + } +} diff --git a/src/Commands/PowerPlatform/PowerAutomate/ImportFlow.cs b/src/Commands/PowerPlatform/PowerAutomate/ImportFlow.cs new file mode 100644 index 000000000..a8486a12e --- /dev/null +++ b/src/Commands/PowerPlatform/PowerAutomate/ImportFlow.cs @@ -0,0 +1,44 @@ +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Base.PipeBinds; +using PnP.PowerShell.Commands.Utilities; +using System.Management.Automation; + + +namespace PnP.PowerShell.Commands.PowerPlatform.PowerAutomate +{ + [Cmdlet(VerbsData.Import, "PnPFlow")] + [ApiNotAvailableUnderApplicationPermissions] + [RequiredApiDelegatedPermissions("azure/user_impersonation")] + public class ImportFlow : PnPAzureManagementApiCmdlet + { + private const string ParameterSet_BYIDENTITY = "By Identity"; + private const string ParameterSet_ALL = "All"; + + [Parameter(Mandatory = false, ValueFromPipeline = true, ParameterSetName = ParameterSet_BYIDENTITY)] + [Parameter(Mandatory = false, ValueFromPipeline = true, ParameterSetName = ParameterSet_ALL)] + [Parameter(Mandatory = false)] + public PowerPlatformEnvironmentPipeBind Environment; + + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_ALL)] + public string PackagePath; + + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_ALL)] + public string Name; + + protected override void ExecuteCmdlet() + { + var environmentName = GetEnvironmentName(); + string baseUrl = PowerPlatformUtility.GetBapEndpoint(Connection.AzureEnvironment); + var importStatus = ImportFlowUtility.ExecuteImportFlow(Connection.HttpClient,AccessToken,baseUrl,environmentName,PackagePath,Name); + WriteObject(importStatus); + } + + private string GetEnvironmentName() + { + return ParameterSpecified(nameof(Environment)) + ? Environment.GetName() + : PowerPlatformUtility.GetDefaultEnvironment(ArmRequestHelper, Connection.AzureEnvironment)?.Name; + } + } +} diff --git a/src/Commands/Utilities/ImportFlowUtility.cs b/src/Commands/Utilities/ImportFlowUtility.cs new file mode 100644 index 000000000..7769cd316 --- /dev/null +++ b/src/Commands/Utilities/ImportFlowUtility.cs @@ -0,0 +1,307 @@ +using PnP.PowerShell.Commands.Utilities.REST; +using PnP.PowerShell.Commands.Base; +using System; +using System.IO; +using System.Text.Json; +using PnP.Framework.Diagnostics; +using System.Net.Http; +using System.Text.Json.Nodes; +using PnP.PowerShell.Commands.Model.PowerPlatform.PowerAutomate; +using System.Threading; + +namespace PnP.PowerShell.Commands.Utilities +{ + internal static class ImportFlowUtility + { + public static ImportFlowResult ExecuteImportFlow(HttpClient httpClient, string accessToken, string baseUrl, string environmentName, string packagePath, string name) + { + var sasUrl = GenerateSasUrl(httpClient, accessToken, baseUrl, environmentName); + var blobUri = BuildBlobUri(sasUrl, packagePath); + UploadPackageToBlob(blobUri, packagePath); + var importParametersResponse = GetImportParameters(httpClient, accessToken, baseUrl, environmentName, blobUri); + var importOperationsData = GetImportOperations(httpClient, accessToken, importParametersResponse.Location.ToString()); + var propertiesElement = GetPropertiesElement(importOperationsData); + ValidateProperties(propertiesElement); + var resourcesObject = ParseResources(propertiesElement); + var resource = TransformResources(resourcesObject, name); + var validatePackagePayload = CreateImportObject(propertiesElement, resourcesObject); + var validateResponseData = ValidateImportPackage(httpClient, accessToken, baseUrl, environmentName, validatePackagePayload); + var importPackagePayload = CreateImportObject(validateResponseData); + var importResult = ImportPackage(httpClient, accessToken, baseUrl, environmentName, importPackagePayload); + return WaitForImportCompletion(httpClient, accessToken, importResult.Location.ToString()); + } + public static string GenerateSasUrl(HttpClient httpClient, string accessToken, string baseUrl, string environmentName) + { + var response = RestHelper.Post(httpClient, $"{baseUrl}/providers/Microsoft.BusinessAppPlatform/environments/{environmentName}/generateResourceStorage?api-version=2016-11-01", accessToken); + var data = JsonSerializer.Deserialize(response); + return data.GetProperty("sharedAccessSignature").GetString(); + } + + public static UriBuilder BuildBlobUri(string sasUrl, string packagePath) + { + var fileName = Path.GetFileName(packagePath); + var blobUri = new UriBuilder(sasUrl); + blobUri.Path += $"/{fileName}"; + return blobUri; + } + + public static void UploadPackageToBlob(UriBuilder blobUri, string PackagePath) + { + using (var blobClient = new HttpClient()) + using (var packageFileStream = new FileStream(PackagePath, FileMode.Open, FileAccess.Read)) + { + var packageContent = new StreamContent(packageFileStream); + packageContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Put, blobUri.Uri) + { + Content = packageContent + }; + + request.Headers.Add("x-ms-blob-type", "BlockBlob"); + + var uploadResponse = blobClient.SendAsync(request).GetAwaiter().GetResult(); + + if (!uploadResponse.IsSuccessStatusCode) + { + var errorContent = uploadResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + throw new Exception($"Upload failed: {uploadResponse.StatusCode} - {errorContent}"); + } + } + } + + public static System.Net.Http.Headers.HttpResponseHeaders GetImportParameters(HttpClient httpClient, string accessToken, string baseUrl, string environmentName, UriBuilder blobUri) + { + var importPayload = new + { + packageLink = new + { + value = blobUri.Uri.ToString() + } + }; + var response = RestHelper.PostGetResponseHeader( + httpClient, + $"{baseUrl}/providers/Microsoft.BusinessAppPlatform/environments/{environmentName}/listImportParameters?api-version=2016-11-01", + accessToken, + payload: importPayload, + accept: "application/json" + ); + Log.Debug("ImportFlowUtility", "Import parameters retrieved"); + System.Threading.Thread.Sleep(2500); + return response; + } + + public static JsonElement GetImportOperations(HttpClient httpClient, string accessToken, string importOperationsUrl) + { + var listImportOperations = RestHelper.Get( + httpClient, + importOperationsUrl, + accessToken, + accept: "application/json" + ); + Log.Debug("ImportFlowUtility", "Import operations retrieved"); + return JsonSerializer.Deserialize(listImportOperations); + } + + public static JsonElement GetPropertiesElement(JsonElement importOperationsData) + { + if (!importOperationsData.TryGetProperty("properties", out JsonElement propertiesElement)) + { + throw new Exception("Import failed: 'properties' section missing."); + } + return propertiesElement; + } + + public static void ValidateProperties(JsonElement propertiesElement) + { + bool hasStatus = propertiesElement.TryGetProperty("status", out _); + bool hasPackageLink = propertiesElement.TryGetProperty("packageLink", out _); + bool hasDetails = propertiesElement.TryGetProperty("details", out _); + bool hasResources = propertiesElement.TryGetProperty("resources", out _); + + if (!(hasStatus && hasPackageLink && hasDetails && hasResources)) + { + throw new Exception("Import failed: One or more required fields are missing in 'properties'."); + } + if (!propertiesElement.TryGetProperty("resources", out JsonElement resourcesElement)) + { + throw new Exception("Import failed: 'resources' section missing in 'properties'."); + } + } + + public static JsonObject ParseResources(JsonElement propertiesElement) + { + if (!propertiesElement.TryGetProperty("resources", out JsonElement resourcesElement)) + { + throw new Exception("Import failed: 'resources' section missing in 'properties'."); + } + return JsonNode.Parse(resourcesElement.GetRawText()) as JsonObject; + } + + public static JsonElement ValidateImportPackage(HttpClient httpClient, string accessToken, string baseUrl, string environmentName, JsonObject validatePackagePayload) + { + var validateResponse = RestHelper.Post(httpClient, $"{baseUrl}/providers/Microsoft.BusinessAppPlatform/environments/{environmentName}/validateImportPackage?api-version=2016-11-01", accessToken, payload: validatePackagePayload); + return JsonSerializer.Deserialize(validateResponse); + } + + public static JsonObject TransformResources(JsonObject resourcesObject, string Name) + { + foreach (var property in resourcesObject) + { + string resourceKey = property.Key; + var resource = property.Value as JsonObject; + + if (resource != null && resource.TryGetPropertyValue("type", out JsonNode typeNode)) + { + string resourceType = typeNode?.ToString(); + + if (resourceType == "Microsoft.Flow/flows") + { + resource["selectedCreationType"] = "New"; + if (Name != null) + { + if (resource.TryGetPropertyValue("details", out JsonNode detailsNode) && detailsNode is JsonObject detailsObject) + { + detailsObject["displayName"] = Name; + } + } + } + else if (resourceType == "Microsoft.PowerApps/apis/connections") + { + resource["selectedCreationType"] = "Existing"; + + // Only set the id if suggestedId exists + if (resource.TryGetPropertyValue("suggestedId", out JsonNode suggestedIdNode) && suggestedIdNode != null) + { + resource["id"] = JsonValue.Create(suggestedIdNode.ToString()); + } + } + } + } + return resourcesObject; + } + + public static JsonObject CreateImportObject(JsonElement importData, JsonObject resourceObject = null) + { + JsonObject resourcesObject = new JsonObject + { + ["details"] = JsonNode.Parse(importData.GetProperty("details").GetRawText()), + ["packageLink"] = JsonNode.Parse(importData.GetProperty("packageLink").GetRawText()), + ["status"] = JsonNode.Parse(importData.GetProperty("status").GetRawText()), + ["resources"] = resourceObject ?? JsonNode.Parse(importData.GetProperty("resources").GetRawText()) + }; + return resourcesObject; + } + + public static System.Net.Http.Headers.HttpResponseHeaders ImportPackage(HttpClient httpClient, string accessToken, string baseUrl, string environmentName, JsonObject importPackagePayload) + { + var importResult = RestHelper.PostGetResponseHeader( + httpClient, + $"{baseUrl}/providers/Microsoft.BusinessAppPlatform/environments/{environmentName}/importPackage?api-version=2016-11-01", + accessToken, + payload: importPackagePayload, + accept: "application/json" + ); + Log.Debug("ImportFlowUtility", "Import package initiated"); + return importResult; + } + + public static ImportFlowResult WaitForImportCompletion(HttpClient httpClient,string accessToken,string importPackageResponseUrl) + { + string status = null; + int retryCount = 0; + JsonElement importResultDataElement = default; + + do + { + Thread.Sleep(2500); + var importResultData = RestHelper.Get(httpClient, importPackageResponseUrl, accessToken, accept: "application/json"); + importResultDataElement = JsonSerializer.Deserialize(importResultData); + + if (importResultDataElement.TryGetProperty("properties", out JsonElement propertiesElement) && + propertiesElement.TryGetProperty("status", out JsonElement statusElement)) + { + status = statusElement.GetString(); + } + else + { + Log.Warning("ImportFlowUtility", "Failed to retrieve the status from the response."); + throw new Exception("Import status could not be determined."); + } + + if (status == "Running") + { + Log.Debug("ImportFlowUtility", "Import is still running. Waiting for completion..."); + retryCount++; + } + else if (status == "Failed") + { + ThrowImportError(importResultDataElement); + } + + } while (status == "Running" && retryCount < 5); + + if (status == "Running") + { + throw new Exception("Import failed to complete after 5 attempts."); + } + return MapToImportFlowResult(importResultDataElement); + } + + + public static void ThrowImportError(JsonElement importErrorResultData) + { + if (importErrorResultData.TryGetProperty("properties", out JsonElement propertiesElement) && + propertiesElement.TryGetProperty("resources", out JsonElement resourcesElement)) + { + foreach (var resource in resourcesElement.EnumerateObject()) + { + if (resource.Value.TryGetProperty("error", out JsonElement errorElement)) + { + string errorMessage = errorElement.TryGetProperty("message", out JsonElement messageElement) + ? messageElement.GetString() + : errorElement.TryGetProperty("code", out JsonElement codeElement) + ? codeElement.GetString() + : "Unknown error"; + + throw new Exception($"Import failed: {errorMessage}"); + } + } + throw new Exception("Import failed: No error details found in resources."); + } + + throw new Exception("Import failed: Unknown error."); + } + + + private static ImportFlowResult MapToImportFlowResult(JsonElement importResultDataElement) + { + var result = new ImportFlowResult(); + + if (importResultDataElement.TryGetProperty("name", out var nameElement)) + { + result.Name = nameElement.GetString(); + } + + if (importResultDataElement.TryGetProperty("properties", out var propertiesElement)) + { + if (propertiesElement.TryGetProperty("status", out var statusElement)) + { + result.Status = statusElement.GetString(); + } + + var details = new ImportFlowDetails(); + if (propertiesElement.TryGetProperty("details", out var detailsElement)) + { + details.DisplayName = detailsElement.GetProperty("displayName").GetString(); + details.Description = detailsElement.GetProperty("description").GetString(); + details.CreatedTime = detailsElement.GetProperty("createdTime").GetDateTime(); + } + result.Details = details; + } + + return result; + } + + } +}