From d299a906cdbd5de050d1a1b9b8fc66dba87037b8 Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Mon, 6 Oct 2025 18:15:23 +1100 Subject: [PATCH 01/13] whatif for Compute --- .../Compute/Common/ComputeClientBaseCmdlet.cs | 135 ++++++++++++++++++ src/Compute/Compute/Compute.csproj | 1 + 2 files changed, 136 insertions(+) diff --git a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs index db55f9f3a2c2..b839c33df88e 100644 --- a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs +++ b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs @@ -22,6 +22,13 @@ using Microsoft.Azure.Management.Internal.Resources; using Microsoft.Azure.Commands.Common.Authentication; using Microsoft.Azure.Commands.Common.Authentication.Abstractions; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using Newtonsoft.Json; +using Azure.Identity; +using Azure.Core; +using Newtonsoft.Json.Linq; namespace Microsoft.Azure.Commands.Compute { @@ -34,6 +41,12 @@ public abstract class ComputeClientBaseCmdlet : AzureRMCmdlet private ComputeClient computeClient; + // Reusable static HttpClient for DryRun posts + private static readonly HttpClient _dryRunHttpClient = new HttpClient(); + + [Parameter(Mandatory = false, HelpMessage = "Send the invoked PowerShell command (ps_script) and subscription id to a remote endpoint without executing the real operation.")] + public SwitchParameter DryRun { get; set; } + public ComputeClient ComputeClient { get @@ -54,9 +67,131 @@ public ComputeClient ComputeClient public override void ExecuteCmdlet() { StartTime = DateTime.Now; + + // Intercept early if DryRun requested + if (DryRun.IsPresent && TryHandleDryRun()) + { + return; + } base.ExecuteCmdlet(); } + /// + /// Handles DryRun processing: capture command text and subscription id and POST to endpoint. + /// Returns true if DryRun was processed (and normal execution should stop). + /// + protected virtual bool TryHandleDryRun() + { + try + { + string psScript = this.MyInvocation?.Line ?? this.MyInvocation?.InvocationName ?? string.Empty; + string subscriptionId = this.DefaultContext?.Subscription?.Id ?? DefaultProfile.DefaultContext?.Subscription?.Id ?? string.Empty; + + var payload = new + { + ps_script = psScript, + subscription_id = subscriptionId, + timestamp_utc = DateTime.UtcNow.ToString("o"), + source = "Az.Compute.DryRun" + }; + + // Endpoint + token provided via environment variables to avoid changing all cmdlet signatures + string endpoint = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + { + // Default local endpoint (e.g., local Azure Function) if not provided via environment variable + endpoint = "http://localhost:7071/api/what_if_ps_preview"; + } + // Acquire token via Azure Identity (DefaultAzureCredential). Optional scope override via AZURE_POWERSHELL_DRYRUN_SCOPE + string token = GetDryRunAuthToken(); + + // endpoint is always non-empty now (falls back to local default) + + PostDryRun(endpoint, token, payload); + } + catch (Exception ex) + { + WriteWarning($"DryRun error: {ex.Message}"); + } + return true; // Always prevent normal execution when -DryRun is used + } + + private void PostDryRun(string endpoint, string bearerToken, object payload) + { + string json = JsonConvert.SerializeObject(payload); + using (var request = new HttpRequestMessage(HttpMethod.Post, endpoint)) + { + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + if (!string.IsNullOrWhiteSpace(bearerToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + } + WriteVerbose($"DryRun POST -> {endpoint}"); + WriteVerbose($"DryRun Payload: {Truncate(json, 1024)}"); + try + { + var response = _dryRunHttpClient.SendAsync(request).GetAwaiter().GetResult(); + string respBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + if (response.IsSuccessStatusCode) + { + WriteVerbose("DryRun post succeeded."); + WriteVerbose($"DryRun response: {Truncate(respBody, 1024)}"); + } + else + { + WriteWarning($"DryRun post failed: {(int)response.StatusCode} {response.ReasonPhrase}"); + WriteVerbose($"DryRun failure body: {Truncate(respBody, 1024)}"); + } + } + catch (Exception sendEx) + { + WriteWarning($"DryRun post exception: {sendEx.Message}"); + } + } + } + + private static string Truncate(string value, int max) + { + if (string.IsNullOrEmpty(value) || value.Length <= max) + { + return value; + } + return value.Substring(0, max) + "...(truncated)"; + } + + /// + /// Uses Azure Identity's DefaultAzureCredential to acquire a bearer token. Scope can be overridden using + /// AZURE_POWERSHELL_DRYRUN_SCOPE; otherwise defaults to the Resource Manager endpoint + "/.default". + /// Returns null if acquisition fails (request will be sent without Authorization header). + /// + private string GetDryRunAuthToken() + { + try + { + string overrideScope = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_SCOPE"); + string scope; + if (!string.IsNullOrWhiteSpace(overrideScope)) + { + scope = overrideScope.Trim(); + } + else + { + // Default to management endpoint (e.g., https://management.azure.com/.default) + var rmEndpoint = this.DefaultContext?.Environment?.GetEndpoint(AzureEnvironment.Endpoint.ResourceManager) ?? AzureEnvironment.PublicEnvironments["AzureCloud"].GetEndpoint(AzureEnvironment.Endpoint.ResourceManager); + scope = rmEndpoint.TrimEnd('/') + "/.default"; + } + + var credential = new DefaultAzureCredential(); + var token = credential.GetToken(new TokenRequestContext(new[] { scope })); + return token.Token; + } + catch (Exception ex) + { + WriteVerbose($"DryRun token acquisition failed: {ex.Message}"); + return null; + } + } + protected void ExecuteClientAction(Action action) { try diff --git a/src/Compute/Compute/Compute.csproj b/src/Compute/Compute/Compute.csproj index 4b404efdd8dd..ded60afbd25d 100644 --- a/src/Compute/Compute/Compute.csproj +++ b/src/Compute/Compute/Compute.csproj @@ -22,6 +22,7 @@ + From eae8f9221da9a26787b3a193aeae0b5332b20486 Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Wed, 8 Oct 2025 14:52:06 +1100 Subject: [PATCH 02/13] fix execute in new-azvm to only execute whatif --- .../Compute/Common/ComputeClientBaseCmdlet.cs | 175 +++++++++++++++++- .../Operation/NewAzureVMCommand.cs | 5 + 2 files changed, 174 insertions(+), 6 deletions(-) diff --git a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs index b839c33df88e..6c1280eda305 100644 --- a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs +++ b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs @@ -29,6 +29,7 @@ using Azure.Identity; using Azure.Core; using Newtonsoft.Json.Linq; +using System.Threading.Tasks; namespace Microsoft.Azure.Commands.Compute { @@ -107,7 +108,31 @@ protected virtual bool TryHandleDryRun() // endpoint is always non-empty now (falls back to local default) - PostDryRun(endpoint, token, payload); + var dryRunResult = PostDryRun(endpoint, token, payload); + if (dryRunResult != null) + { + // Display the response in a user-friendly format + WriteVerbose("========== DryRun Response =========="); + + // Try to pretty-print the JSON response + try + { + string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); + // Only output to pipeline once, not both WriteObject and WriteInformation + WriteObject(formattedJson); + } + catch + { + // Fallback: just write the object + WriteObject(dryRunResult); + } + + WriteVerbose("====================================="); + } + else + { + WriteWarning("DryRun request completed but no response data was returned."); + } } catch (Exception ex) { @@ -116,36 +141,174 @@ protected virtual bool TryHandleDryRun() return true; // Always prevent normal execution when -DryRun is used } - private void PostDryRun(string endpoint, string bearerToken, object payload) + /// + /// Posts DryRun payload and returns parsed JSON response or raw string. + /// Mirrors Python test_what_if_ps_preview() behavior. + /// + private object PostDryRun(string endpoint, string bearerToken, object payload) { string json = JsonConvert.SerializeObject(payload); using (var request = new HttpRequestMessage(HttpMethod.Post, endpoint)) { request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + // Add Accept header and correlation id like Python script + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + string correlationId = Guid.NewGuid().ToString(); + request.Headers.Add("x-ms-client-request-id", correlationId); + if (!string.IsNullOrWhiteSpace(bearerToken)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); } + WriteVerbose($"DryRun POST -> {endpoint}"); + WriteVerbose($"DryRun correlation-id: {correlationId}"); WriteVerbose($"DryRun Payload: {Truncate(json, 1024)}"); + try { var response = _dryRunHttpClient.SendAsync(request).GetAwaiter().GetResult(); string respBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + WriteVerbose($"DryRun HTTP Status: {(int)response.StatusCode} {response.ReasonPhrase}"); + if (response.IsSuccessStatusCode) { WriteVerbose("DryRun post succeeded."); - WriteVerbose($"DryRun response: {Truncate(respBody, 1024)}"); + WriteVerbose($"DryRun response body: {Truncate(respBody, 2048)}"); + + // Parse JSON and return as object (similar to Python result = response.json()) + try + { + var jToken = !string.IsNullOrWhiteSpace(respBody) ? JToken.Parse(respBody) : null; + if (jToken != null) + { + // Enrich with correlation and status + if (jToken.Type == JTokenType.Object) + { + ((JObject)jToken)["_correlation_id"] = correlationId; + ((JObject)jToken)["_http_status"] = (int)response.StatusCode; + ((JObject)jToken)["_success"] = true; + } + return jToken.ToObject(); + } + } + catch (Exception parseEx) + { + WriteVerbose($"DryRun response parse failed: {parseEx.Message}"); + } + return respBody; } else { - WriteWarning($"DryRun post failed: {(int)response.StatusCode} {response.ReasonPhrase}"); - WriteVerbose($"DryRun failure body: {Truncate(respBody, 1024)}"); + // HTTP error response - display detailed error information + WriteWarning($"DryRun API returned error: {(int)response.StatusCode} {response.ReasonPhrase}"); + + // Create error response object with all details + var errorResponse = new + { + _success = false, + _http_status = (int)response.StatusCode, + _status_description = response.ReasonPhrase, + _correlation_id = correlationId, + _endpoint = endpoint, + error_message = respBody, + timestamp = DateTime.UtcNow.ToString("o") + }; + + // Try to parse error as JSON if possible + try + { + var errorJson = JToken.Parse(respBody); + WriteError(new ErrorRecord( + new Exception($"DryRun API Error: {response.StatusCode} - {respBody}"), + "DryRunApiError", + ErrorCategory.InvalidOperation, + endpoint)); + + // Return enriched error object + if (errorJson.Type == JTokenType.Object) + { + ((JObject)errorJson)["_correlation_id"] = correlationId; + ((JObject)errorJson)["_http_status"] = (int)response.StatusCode; + ((JObject)errorJson)["_success"] = false; + return errorJson.ToObject(); + } + } + catch + { + // Error body is not JSON, return as plain error object + WriteError(new ErrorRecord( + new Exception($"DryRun API Error: {response.StatusCode} - {respBody}"), + "DryRunApiError", + ErrorCategory.InvalidOperation, + endpoint)); + } + + WriteVerbose($"DryRun error response body: {Truncate(respBody, 2048)}"); + return errorResponse; } } + catch (HttpRequestException httpEx) + { + // Network or connection error + WriteError(new ErrorRecord( + new Exception($"DryRun network error: {httpEx.Message}", httpEx), + "DryRunNetworkError", + ErrorCategory.ConnectionError, + endpoint)); + + return new + { + _success = false, + _correlation_id = correlationId, + _endpoint = endpoint, + error_type = "NetworkError", + error_message = httpEx.Message, + stack_trace = httpEx.StackTrace, + timestamp = DateTime.UtcNow.ToString("o") + }; + } + catch (TaskCanceledException timeoutEx) + { + // Timeout error + WriteError(new ErrorRecord( + new Exception($"DryRun request timeout: {timeoutEx.Message}", timeoutEx), + "DryRunTimeout", + ErrorCategory.OperationTimeout, + endpoint)); + + return new + { + _success = false, + _correlation_id = correlationId, + _endpoint = endpoint, + error_type = "Timeout", + error_message = "Request timed out", + timestamp = DateTime.UtcNow.ToString("o") + }; + } catch (Exception sendEx) { - WriteWarning($"DryRun post exception: {sendEx.Message}"); + // Generic error + WriteError(new ErrorRecord( + new Exception($"DryRun request failed: {sendEx.Message}", sendEx), + "DryRunRequestError", + ErrorCategory.NotSpecified, + endpoint)); + + return new + { + _success = false, + _correlation_id = correlationId, + _endpoint = endpoint, + error_type = sendEx.GetType().Name, + error_message = sendEx.Message, + stack_trace = sendEx.StackTrace, + timestamp = DateTime.UtcNow.ToString("o") + }; } } } diff --git a/src/Compute/Compute/VirtualMachine/Operation/NewAzureVMCommand.cs b/src/Compute/Compute/VirtualMachine/Operation/NewAzureVMCommand.cs index 982a59ed8271..b9deb8adfcb3 100644 --- a/src/Compute/Compute/VirtualMachine/Operation/NewAzureVMCommand.cs +++ b/src/Compute/Compute/VirtualMachine/Operation/NewAzureVMCommand.cs @@ -499,6 +499,11 @@ public class NewAzureVMCommand : VirtualMachineBaseCmdlet public override void ExecuteCmdlet() { + // Handle DryRun early (before any real logic) + if (DryRun.IsPresent && TryHandleDryRun()) + { + return; + } switch (ParameterSetName) { From e4987e71e932d9bbb485111e28ff2984e7a8bb5d Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Tue, 21 Oct 2025 05:10:13 +1100 Subject: [PATCH 03/13] add whatif formatter --- .../Compute/Common/ComputeClientBaseCmdlet.cs | 328 ++++++++- src/Compute/Compute/Compute.csproj | 5 + src/shared/WhatIf/CHECKLIST.md | 270 ++++++++ .../WhatIf/Comparers/ChangeTypeComparer.cs | 42 ++ .../WhatIf/Comparers/PSChangeTypeComparer.cs | 43 ++ .../Comparers/PropertyChangeTypeComparer.cs | 41 ++ .../WhatIf/Extensions/ChangeTypeExtensions.cs | 108 +++ .../WhatIf/Extensions/DiagnosticExtensions.cs | 61 ++ .../WhatIf/Extensions/JTokenExtensions.cs | 159 +++++ .../Extensions/PSChangeTypeExtensions.cs | 83 +++ .../PropertyChangeTypeExtensions.cs | 134 ++++ src/shared/WhatIf/Formatters/Color.cs | 76 +++ .../WhatIf/Formatters/ColoredStringBuilder.cs | 120 ++++ src/shared/WhatIf/Formatters/Symbol.cs | 62 ++ .../WhatIf/Formatters/WhatIfJsonFormatter.cs | 242 +++++++ .../WhatIfOperationResultFormatter.cs | 641 ++++++++++++++++++ src/shared/WhatIf/INTEGRATION_GUIDE.md | 412 +++++++++++ src/shared/WhatIf/Models/ChangeType.cs | 57 ++ src/shared/WhatIf/Models/IWhatIfChange.cs | 71 ++ src/shared/WhatIf/Models/IWhatIfDiagnostic.cs | 48 ++ src/shared/WhatIf/Models/IWhatIfError.cs | 38 ++ .../WhatIf/Models/IWhatIfOperationResult.cs | 50 ++ .../WhatIf/Models/IWhatIfPropertyChange.cs | 51 ++ src/shared/WhatIf/Models/PSChangeType.cs | 62 ++ .../WhatIf/Models/PropertyChangeType.cs | 47 ++ src/shared/WhatIf/QUICKSTART.md | 141 ++++ src/shared/WhatIf/README.md | 323 +++++++++ src/shared/WhatIf/USAGE_EXAMPLES.md | 463 +++++++++++++ .../WhatIf/Utilities/ResourceIdUtility.cs | 146 ++++ 29 files changed, 4312 insertions(+), 12 deletions(-) create mode 100644 src/shared/WhatIf/CHECKLIST.md create mode 100644 src/shared/WhatIf/Comparers/ChangeTypeComparer.cs create mode 100644 src/shared/WhatIf/Comparers/PSChangeTypeComparer.cs create mode 100644 src/shared/WhatIf/Comparers/PropertyChangeTypeComparer.cs create mode 100644 src/shared/WhatIf/Extensions/ChangeTypeExtensions.cs create mode 100644 src/shared/WhatIf/Extensions/DiagnosticExtensions.cs create mode 100644 src/shared/WhatIf/Extensions/JTokenExtensions.cs create mode 100644 src/shared/WhatIf/Extensions/PSChangeTypeExtensions.cs create mode 100644 src/shared/WhatIf/Extensions/PropertyChangeTypeExtensions.cs create mode 100644 src/shared/WhatIf/Formatters/Color.cs create mode 100644 src/shared/WhatIf/Formatters/ColoredStringBuilder.cs create mode 100644 src/shared/WhatIf/Formatters/Symbol.cs create mode 100644 src/shared/WhatIf/Formatters/WhatIfJsonFormatter.cs create mode 100644 src/shared/WhatIf/Formatters/WhatIfOperationResultFormatter.cs create mode 100644 src/shared/WhatIf/INTEGRATION_GUIDE.md create mode 100644 src/shared/WhatIf/Models/ChangeType.cs create mode 100644 src/shared/WhatIf/Models/IWhatIfChange.cs create mode 100644 src/shared/WhatIf/Models/IWhatIfDiagnostic.cs create mode 100644 src/shared/WhatIf/Models/IWhatIfError.cs create mode 100644 src/shared/WhatIf/Models/IWhatIfOperationResult.cs create mode 100644 src/shared/WhatIf/Models/IWhatIfPropertyChange.cs create mode 100644 src/shared/WhatIf/Models/PSChangeType.cs create mode 100644 src/shared/WhatIf/Models/PropertyChangeType.cs create mode 100644 src/shared/WhatIf/QUICKSTART.md create mode 100644 src/shared/WhatIf/README.md create mode 100644 src/shared/WhatIf/USAGE_EXAMPLES.md create mode 100644 src/shared/WhatIf/Utilities/ResourceIdUtility.cs diff --git a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs index 6c1280eda305..a21cd988a2c4 100644 --- a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs +++ b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs @@ -30,6 +30,10 @@ using Azure.Core; using Newtonsoft.Json.Linq; using System.Threading.Tasks; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; +using System.Collections.Generic; +using System.Linq; namespace Microsoft.Azure.Commands.Compute { @@ -111,23 +115,46 @@ protected virtual bool TryHandleDryRun() var dryRunResult = PostDryRun(endpoint, token, payload); if (dryRunResult != null) { - // Display the response in a user-friendly format - WriteVerbose("========== DryRun Response =========="); - - // Try to pretty-print the JSON response + // Try to format using the shared WhatIf formatter try { - string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); - // Only output to pipeline once, not both WriteObject and WriteInformation - WriteObject(formattedJson); + // Try to parse as WhatIf result and format it + var whatIfResult = TryAdaptDryRunToWhatIf(dryRunResult); + if (whatIfResult != null) + { + WriteVerbose("========== DryRun Response (Formatted) =========="); + string formattedOutput = WhatIfOperationResultFormatter.Format( + whatIfResult, + noiseNotice: "Note: DryRun preview - actual deployment behavior may differ." + ); + WriteObject(formattedOutput); + WriteVerbose("================================================="); + } + else + { + // Fallback: display as JSON + WriteVerbose("========== DryRun Response =========="); + string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); + WriteObject(formattedJson); + WriteVerbose("====================================="); + } } - catch + catch (Exception formatEx) { - // Fallback: just write the object - WriteObject(dryRunResult); + WriteVerbose($"DryRun formatting failed: {formatEx.Message}"); + // Fallback: just output the raw result + WriteVerbose("========== DryRun Response =========="); + try + { + string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); + WriteObject(formattedJson); + } + catch + { + WriteObject(dryRunResult); + } + WriteVerbose("====================================="); } - - WriteVerbose("====================================="); } else { @@ -141,6 +168,45 @@ protected virtual bool TryHandleDryRun() return true; // Always prevent normal execution when -DryRun is used } + /// + /// Attempts to adapt the DryRun JSON response to IWhatIfOperationResult for formatting. + /// Returns null if the response doesn't match expected structure. + /// + private IWhatIfOperationResult TryAdaptDryRunToWhatIf(object dryRunResult) + { + try + { + // Try to parse as JObject + JObject jObj = null; + if (dryRunResult is JToken jToken) + { + jObj = jToken as JObject; + } + else if (dryRunResult is string strResult) + { + jObj = JObject.Parse(strResult); + } + + if (jObj == null) + { + return null; + } + + // Check if it has a 'changes' or 'resourceChanges' field + var changesToken = jObj["changes"] ?? jObj["resourceChanges"]; + if (changesToken == null) + { + return null; + } + + return new DryRunWhatIfResult(jObj); + } + catch + { + return null; + } + } + /// /// Posts DryRun payload and returns parsed JSON response or raw string. /// Mirrors Python test_what_if_ps_preview() behavior. @@ -442,6 +508,244 @@ public ResourceManagementClient ArmClient this._armClient = value; } } + + #region DryRun WhatIf Adapter Classes + + /// + /// Adapter class to convert DryRun JSON response to IWhatIfOperationResult interface + /// + private class DryRunWhatIfResult : IWhatIfOperationResult + { + private readonly JObject _response; + private readonly Lazy> _changes; + private readonly Lazy> _potentialChanges; + private readonly Lazy> _diagnostics; + private readonly Lazy _error; + + public DryRunWhatIfResult(JObject response) + { + _response = response; + _changes = new Lazy>(() => ParseChanges(_response["changes"] ?? _response["resourceChanges"])); + _potentialChanges = new Lazy>(() => ParseChanges(_response["potentialChanges"])); + _diagnostics = new Lazy>(() => ParseDiagnostics(_response["diagnostics"])); + _error = new Lazy(() => ParseError(_response["error"])); + } + + public string Status => _response["status"]?.Value() ?? "Succeeded"; + public IList Changes => _changes.Value; + public IList PotentialChanges => _potentialChanges.Value; + public IList Diagnostics => _diagnostics.Value; + public IWhatIfError Error => _error.Value; + + private static IList ParseChanges(JToken changesToken) + { + if (changesToken == null || changesToken.Type != JTokenType.Array) + { + return new List(); + } + + return changesToken + .Select(c => new DryRunWhatIfChange(c as JObject)) + .Cast + .ToList(); + } + + private static IList ParseDiagnostics(JToken diagnosticsToken) + { + if (diagnosticsToken == null || diagnosticsToken.Type != JTokenType.Array) + { + return new List(); + } + + return diagnosticsToken + .Select(d => new DryRunWhatIfDiagnostic(d as JObject)) + .Cast() + .ToList(); + } + + private static IWhatIfError ParseError(JToken errorToken) + { + if (errorToken == null) + { + return null; + } + + return new DryRunWhatIfError(errorToken as JObject); + } + } + + /// + /// Adapter for individual resource change + /// + private class DryRunWhatIfChange : IWhatIfChange + { + private readonly JObject _change; + private readonly Lazy> _delta; + + public DryRunWhatIfChange(JObject change) + { + _change = change; + + // Parse resourceId into scope and relative path + string resourceId = _change["resourceId"]?.Value() ?? string.Empty; + var parts = SplitResourceId(resourceId); + Scope = parts.scope; + RelativeResourceId = parts.relativeId; + + _delta = new Lazy>(() => ParsePropertyChanges(_change["delta"] ?? _change["propertyChanges"])); + } + + public string Scope { get; } + public string RelativeResourceId { get; } + public string UnsupportedReason => _change["unsupportedReason"]?.Value(); + public string FullyQualifiedResourceId => _change["resourceId"]?.Value() ?? $"{Scope}/{RelativeResourceId}"; + + public ChangeType ChangeType + { + get + { + string changeTypeStr = _change["changeType"]?.Value() ?? "NoChange"; + return ParseChangeType(changeTypeStr); + } + } + + public string ApiVersion => _change["apiVersion"]?.Value() ?? + Before?["apiVersion"]?.Value() ?? + After?["apiVersion"]?.Value(); + + public JToken Before => _change["before"]; + public JToken After => _change["after"]; + public IList Delta => _delta.Value; + + private static (string scope, string relativeId) SplitResourceId(string resourceId) + { + if (string.IsNullOrEmpty(resourceId)) + { + return (string.Empty, string.Empty); + } + + // Find last occurrence of /providers/ + int providersIndex = resourceId.LastIndexOf("/providers/", StringComparison.OrdinalIgnoreCase); + if (providersIndex > 0) + { + string scope = resourceId.Substring(0, providersIndex); + string relativeId = resourceId.Substring(providersIndex + 1); // Skip the leading '/' + return (scope, relativeId); + } + + // If no providers found, treat entire path as relative + return (string.Empty, resourceId); + } + + private static ChangeType ParseChangeType(string changeTypeStr) + { + if (Enum.TryParse(changeTypeStr, true, out var changeType)) + { + return changeType; + } + return ChangeType.NoChange; + } + + private static IList ParsePropertyChanges(JToken deltaToken) + { + if (deltaToken == null || deltaToken.Type != JTokenType.Array) + { + return new List(); + } + + return deltaToken + .Select(pc => new DryRunWhatIfPropertyChange(pc as JObject)) + .Cast() + .ToList(); + } + } + + /// + /// Adapter for property changes + /// + private class DryRunWhatIfPropertyChange : IWhatIfPropertyChange + { + private readonly JObject _propertyChange; + private readonly Lazy> _children; + + public DryRunWhatIfPropertyChange(JObject propertyChange) + { + _propertyChange = propertyChange; + _children = new Lazy>(() => ParseChildren(_propertyChange["children"])); + } + + public string Path => _propertyChange["path"]?.Value() ?? string.Empty; + + public PropertyChangeType PropertyChangeType + { + get + { + string typeStr = _propertyChange["propertyChangeType"]?.Value() ?? + _propertyChange["changeType"]?.Value() ?? + "NoEffect"; + if (Enum.TryParse(typeStr, true, out var propChangeType)) + { + return propChangeType; + } + return PropertyChangeType.NoEffect; + } + } + + public JToken Before => _propertyChange["before"]; + public JToken After => _propertyChange["after"]; + public IList Children => _children.Value; + + private static IList ParseChildren(JToken childrenToken) + { + if (childrenToken == null || childrenToken.Type != JTokenType.Array) + { + return new List(); + } + + return childrenToken + .Select(c => new DryRunWhatIfPropertyChange(c as JObject)) + .Cast() + .ToList(); + } + } + + /// + /// Adapter for diagnostics + /// + private class DryRunWhatIfDiagnostic : IWhatIfDiagnostic + { + private readonly JObject _diagnostic; + + public DryRunWhatIfDiagnostic(JObject diagnostic) + { + _diagnostic = diagnostic; + } + + public string Code => _diagnostic["code"]?.Value() ?? string.Empty; + public string Message => _diagnostic["message"]?.Value() ?? string.Empty; + public string Level => _diagnostic["level"]?.Value() ?? "Info"; + public string Target => _diagnostic["target"]?.Value() ?? string.Empty; + public string Details => _diagnostic["details"]?.Value() ?? string.Empty; + } + + /// + /// Adapter for errors + /// + private class DryRunWhatIfError : IWhatIfError + { + private readonly JObject _error; + + public DryRunWhatIfError(JObject error) + { + _error = error; + } + + public string Code => _error["code"]?.Value() ?? string.Empty; + public string Message => _error["message"]?.Value() ?? string.Empty; + public string Target => _error["target"]?.Value() ?? string.Empty; + } + + #endregion } } diff --git a/src/Compute/Compute/Compute.csproj b/src/Compute/Compute/Compute.csproj index ded60afbd25d..2e238544ea6e 100644 --- a/src/Compute/Compute/Compute.csproj +++ b/src/Compute/Compute/Compute.csproj @@ -25,6 +25,11 @@ + + + + + diff --git a/src/shared/WhatIf/CHECKLIST.md b/src/shared/WhatIf/CHECKLIST.md new file mode 100644 index 000000000000..e162e41e68cf --- /dev/null +++ b/src/shared/WhatIf/CHECKLIST.md @@ -0,0 +1,270 @@ +# WhatIf 共享库 - 迁移和使用清单 + +## ✅ 已完成的工作 + +### 1. 目录结构 +``` +src/shared/WhatIf/ +├── Formatters/ # 格式化器 +│ ├── Color.cs +│ ├── Symbol.cs +│ ├── ColoredStringBuilder.cs +│ ├── WhatIfJsonFormatter.cs +│ └── WhatIfOperationResultFormatter.cs +├── Extensions/ # 扩展方法 +│ ├── JTokenExtensions.cs +│ ├── DiagnosticExtensions.cs +│ ├── ChangeTypeExtensions.cs +│ ├── PropertyChangeTypeExtensions.cs +│ └── PSChangeTypeExtensions.cs +├── Comparers/ # 排序比较器 +│ ├── ChangeTypeComparer.cs +│ ├── PropertyChangeTypeComparer.cs +│ └── PSChangeTypeComparer.cs +├── Models/ # 数据模型 +│ ├── ChangeType.cs (enum) +│ ├── PropertyChangeType.cs (enum) +│ ├── PSChangeType.cs (enum) +│ ├── IWhatIfOperationResult.cs (interface) +│ ├── IWhatIfChange.cs (interface) +│ ├── IWhatIfPropertyChange.cs (interface) +│ ├── IWhatIfDiagnostic.cs (interface) +│ └── IWhatIfError.cs (interface) +├── Utilities/ # 工具类 +│ └── ResourceIdUtility.cs +├── README.md # 主文档 +├── USAGE_EXAMPLES.md # 使用示例 +├── QUICKSTART.md # 快速开始 +└── INTEGRATION_GUIDE.md # 集成指南 +``` + +### 2. 核心功能 + +#### ✅ Formatters(格式化器) +- **Color.cs**: ANSI 颜色代码定义 + - 7 种颜色:Green, Orange, Purple, Blue, Gray, Red, DarkYellow, Reset + +- **Symbol.cs**: 操作符号定义 + - 7 种符号:+, -, ~, !, =, *, x, 以及方括号和空格 + +- **ColoredStringBuilder.cs**: 带颜色的字符串构建器 + - 支持 ANSI 颜色代码 + - 颜色作用域管理(AnsiColorScope) + - 自动颜色栈管理 + +- **WhatIfJsonFormatter.cs**: JSON 格式化基类 + - 格式化叶子节点 + - 格式化数组和对象 + - 路径对齐 + - 缩进管理 + +- **WhatIfOperationResultFormatter.cs**: 完整的 WhatIf 结果格式化器 + - 支持接口驱动(IWhatIfOperationResult) + - 格式化资源变更 + - 格式化属性变更 + - 格式化诊断信息 + - 图例显示 + - 统计信息 + +#### ✅ Extensions(扩展方法) +- **JTokenExtensions.cs**: Newtonsoft.Json 扩展 + - IsLeaf(), IsNonEmptyArray(), IsNonEmptyObject() + - ToPsObject(), ConvertPropertyValueForPsObject() + +- **DiagnosticExtensions.cs**: 诊断信息扩展 + - ToColor(): 级别 → 颜色映射 + - Level 常量类 + +- **ChangeTypeExtensions.cs**: ChangeType 扩展 + - ToColor(): 变更类型 → 颜色 + - ToSymbol(): 变更类型 → 符号 + - ToPSChangeType(): 类型转换 + +- **PropertyChangeTypeExtensions.cs**: PropertyChangeType 扩展 + - ToColor(), ToSymbol(), ToPSChangeType() + - IsDelete(), IsCreate(), IsModify(), IsArray() 辅助方法 + +- **PSChangeTypeExtensions.cs**: PSChangeType 扩展 + - ToColor(), ToSymbol() + +#### ✅ Comparers(比较器) +- **ChangeTypeComparer.cs**: ChangeType 排序 + - 权重字典:Delete(0) → Create(1) → Deploy(2) → ... → Ignore(6) + +- **PropertyChangeTypeComparer.cs**: PropertyChangeType 排序 + - 权重字典:Delete(0) → Create(1) → Modify/Array(2) → NoEffect(3) + +- **PSChangeTypeComparer.cs**: PSChangeType 排序 + - 8 个权重级别 + +#### ✅ Models(模型) +- **枚举类型**: + - ChangeType: Create, Delete, Deploy, Ignore, Modify, NoChange, Unsupported + - PropertyChangeType: Create, Delete, Modify, Array, NoEffect + - PSChangeType: 合并了上述两者的所有值 + +- **接口**: + - IWhatIfOperationResult: 操作结果顶层接口 + - IWhatIfChange: 资源变更接口 + - IWhatIfPropertyChange: 属性变更接口 + - IWhatIfDiagnostic: 诊断信息接口 + - IWhatIfError: 错误信息接口 + +#### ✅ Utilities(工具类) +- **ResourceIdUtility.cs**: 资源 ID 处理工具 + - SplitResourceId(): 拆分为 scope + relativeResourceId + - GetScope(), GetRelativeResourceId() + - GetResourceGroupName(), GetSubscriptionId() + +#### ✅ 文档 +- **README.md**: 完整库文档 + - 组件概述 + - 使用方法 + - 迁移指南 + - 接口实现示例 + +- **USAGE_EXAMPLES.md**: 7 个详细示例 + - 基础 JSON 格式化 + - ColoredStringBuilder 使用 + - 颜色作用域 + - 自定义格式化器 + - 诊断信息 + - 迁移指南 + - RP 模块示例 + +- **QUICKSTART.md**: 快速参考 + - 颜色/符号映射表 + - 最小代码示例 + - 迁移检查清单 + +- **INTEGRATION_GUIDE.md**: 完整集成指南 + - 完整 Compute 模块示例 + - 接口实现步骤 + - Cmdlet 集成 + - 自定义格式化器 + - 项目引用配置 + - 单元测试示例 + - 最佳实践 + - 常见问题解答 + +## 🎯 设计特点 + +### 1. 完全独立 +- ✅ 不依赖 Resources 模块 +- ✅ 所有类型都在 shared 中定义 +- ✅ 可被任意 RP 模块使用 + +### 2. 接口驱动 +- ✅ 使用接口而非具体类型 +- ✅ 灵活适配不同 SDK 模型 +- ✅ 易于测试和模拟 + +### 3. 可扩展性 +- ✅ 所有格式化方法都是 virtual +- ✅ 可继承并重写行为 +- ✅ 支持自定义格式化器 + +### 4. 类型安全 +- ✅ 强类型枚举 +- ✅ 类型转换扩展方法 +- ✅ 编译时类型检查 + +### 5. 性能优化 +- ✅ Lazy 延迟加载 +- ✅ 最小化字符串操作 +- ✅ 高效的颜色管理 + +## 📋 使用检查清单 + +### 对于 RP 模块开发者 + +#### 1. 项目引用 +```xml + + + +``` + +#### 2. 实现接口 +- [ ] 创建 `PSYourServiceWhatIfChange : IWhatIfChange` +- [ ] 创建 `PSYourServiceWhatIfPropertyChange : IWhatIfPropertyChange` +- [ ] 创建 `PSYourServiceWhatIfOperationResult : IWhatIfOperationResult` +- [ ] 创建 `PSYourServiceWhatIfDiagnostic : IWhatIfDiagnostic`(可选) +- [ ] 创建 `PSYourServiceWhatIfError : IWhatIfError`(可选) + +#### 3. 在 Cmdlet 中使用 +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; + +var psResult = new PSYourServiceWhatIfOperationResult(sdkResult); +string output = WhatIfOperationResultFormatter.Format(psResult); +WriteObject(output); +``` + +#### 4. 测试 +- [ ] 单元测试:格式化输出 +- [ ] 集成测试:端到端 WhatIf 流程 +- [ ] 手动测试:颜色显示正确 + +### 对于 Resources 模块(迁移) + +#### 1. 更新命名空间 +- [ ] `using Microsoft.Azure.Commands.ResourceManager.Cmdlets.Formatters;` + → `using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters;` +- [ ] `using Microsoft.Azure.Commands.ResourceManager.Cmdlets.Extensions;` + → `using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions;` + +#### 2. 更新类型引用 +- [ ] `ChangeType` → 从 shared 引用 +- [ ] `PropertyChangeType` → 从 shared 引用 +- [ ] `PSChangeType` → 从 shared 引用 + +#### 3. 实现接口(可选) +- [ ] `PSWhatIfChange : IWhatIfChange` +- [ ] `PSWhatIfPropertyChange : IWhatIfPropertyChange` +- [ ] `PSWhatIfOperationResult : IWhatIfOperationResult` + +#### 4. 验证 +- [ ] 现有测试通过 +- [ ] WhatIf 输出格式一致 +- [ ] 颜色显示正常 + +## 🚀 后续步骤 + +### 立即可用 +该库现在可以立即在任何 RP 模块中使用。 + +### 推荐集成顺序 +1. **新 RP 模块**: 直接使用接口实现 +2. **现有 RP 模块**: + - 先添加项目引用 + - 实现接口 + - 逐步迁移现有代码 +3. **Resources 模块**: + - 保持现有架构不变 + - 添加接口实现(向后兼容) + - 内部逐步切换到 shared 库 + +### 优化建议 +1. 考虑创建 NuGet 包(如果跨仓库使用) +2. 添加 XML 文档注释(已部分完成) +3. 添加单元测试项目 +4. 性能基准测试 + +## 📞 支持 + +如有问题,请参考: +1. `README.md` - 完整文档 +2. `INTEGRATION_GUIDE.md` - 集成步骤 +3. `USAGE_EXAMPLES.md` - 代码示例 +4. `QUICKSTART.md` - 快速参考 + +## 📝 版本信息 + +- **版本**: 1.0.0 +- **创建日期**: 2025-01 +- **Namespace**: Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf +- **Target Framework**: .NET Standard 2.0 +- **依赖**: + - Newtonsoft.Json (≥ 13.0.1) + - System.Management.Automation (≥ 7.0.0) diff --git a/src/shared/WhatIf/Comparers/ChangeTypeComparer.cs b/src/shared/WhatIf/Comparers/ChangeTypeComparer.cs new file mode 100644 index 000000000000..673b76ed0deb --- /dev/null +++ b/src/shared/WhatIf/Comparers/ChangeTypeComparer.cs @@ -0,0 +1,42 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Comparers +{ + using System.Collections.Generic; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; + + /// + /// Comparer for ChangeType enum to determine display order. + /// + public class ChangeTypeComparer : IComparer + { + private static readonly IReadOnlyDictionary WeightsByChangeType = + new Dictionary + { + [ChangeType.Delete] = 0, + [ChangeType.Create] = 1, + [ChangeType.Deploy] = 2, + [ChangeType.Modify] = 3, + [ChangeType.Unsupported] = 4, + [ChangeType.NoChange] = 5, + [ChangeType.Ignore] = 6, + }; + + public int Compare(ChangeType first, ChangeType second) + { + return WeightsByChangeType[first] - WeightsByChangeType[second]; + } + } +} diff --git a/src/shared/WhatIf/Comparers/PSChangeTypeComparer.cs b/src/shared/WhatIf/Comparers/PSChangeTypeComparer.cs new file mode 100644 index 000000000000..ec107d1f19fb --- /dev/null +++ b/src/shared/WhatIf/Comparers/PSChangeTypeComparer.cs @@ -0,0 +1,43 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Comparers +{ + using System.Collections.Generic; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; + + /// + /// Comparer for PSChangeType enum to determine display order. + /// + public class PSChangeTypeComparer : IComparer + { + private static readonly IReadOnlyDictionary WeightsByPSChangeType = + new Dictionary + { + [PSChangeType.Delete] = 0, + [PSChangeType.Create] = 1, + [PSChangeType.Deploy] = 2, + [PSChangeType.Modify] = 3, + [PSChangeType.Unsupported] = 4, + [PSChangeType.NoEffect] = 5, + [PSChangeType.NoChange] = 6, + [PSChangeType.Ignore] = 7, + }; + + public int Compare(PSChangeType first, PSChangeType second) + { + return WeightsByPSChangeType[first] - WeightsByPSChangeType[second]; + } + } +} diff --git a/src/shared/WhatIf/Comparers/PropertyChangeTypeComparer.cs b/src/shared/WhatIf/Comparers/PropertyChangeTypeComparer.cs new file mode 100644 index 000000000000..c275b23eb67f --- /dev/null +++ b/src/shared/WhatIf/Comparers/PropertyChangeTypeComparer.cs @@ -0,0 +1,41 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Comparers +{ + using System.Collections.Generic; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; + + /// + /// Comparer for PropertyChangeType enum to determine display order. + /// + public class PropertyChangeTypeComparer : IComparer + { + private static readonly IReadOnlyDictionary WeightsByPropertyChangeType = + new Dictionary + { + [PropertyChangeType.Delete] = 0, + [PropertyChangeType.Create] = 1, + // Modify and Array are set to have the same weight by intention. + [PropertyChangeType.Modify] = 2, + [PropertyChangeType.Array] = 2, + [PropertyChangeType.NoEffect] = 3, + }; + + public int Compare(PropertyChangeType first, PropertyChangeType second) + { + return WeightsByPropertyChangeType[first] - WeightsByPropertyChangeType[second]; + } + } +} diff --git a/src/shared/WhatIf/Extensions/ChangeTypeExtensions.cs b/src/shared/WhatIf/Extensions/ChangeTypeExtensions.cs new file mode 100644 index 000000000000..bff6f32af8c8 --- /dev/null +++ b/src/shared/WhatIf/Extensions/ChangeTypeExtensions.cs @@ -0,0 +1,108 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions +{ + using System; + using System.Collections.Generic; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; + + /// + /// Extension methods for ChangeType enum. + /// + public static class ChangeTypeExtensions + { + private static readonly IReadOnlyDictionary ColorsByChangeType = + new Dictionary + { + [ChangeType.NoChange] = Color.Reset, + [ChangeType.Ignore] = Color.Gray, + [ChangeType.Deploy] = Color.Blue, + [ChangeType.Create] = Color.Green, + [ChangeType.Delete] = Color.Orange, + [ChangeType.Modify] = Color.Purple, + [ChangeType.Unsupported] = Color.Gray, + }; + + private static readonly IReadOnlyDictionary SymbolsByChangeType = + new Dictionary + { + [ChangeType.NoChange] = Symbol.Equal, + [ChangeType.Ignore] = Symbol.Asterisk, + [ChangeType.Deploy] = Symbol.ExclamationPoint, + [ChangeType.Create] = Symbol.Plus, + [ChangeType.Delete] = Symbol.Minus, + [ChangeType.Modify] = Symbol.Tilde, + [ChangeType.Unsupported] = Symbol.Cross, + }; + + private static readonly IReadOnlyDictionary PSChangeTypesByChangeType = + new Dictionary + { + [ChangeType.NoChange] = PSChangeType.NoChange, + [ChangeType.Ignore] = PSChangeType.Ignore, + [ChangeType.Deploy] = PSChangeType.Deploy, + [ChangeType.Create] = PSChangeType.Create, + [ChangeType.Delete] = PSChangeType.Delete, + [ChangeType.Modify] = PSChangeType.Modify, + [ChangeType.Unsupported] = PSChangeType.Unsupported, + }; + + /// + /// Converts a ChangeType to its corresponding Color. + /// + public static Color ToColor(this ChangeType changeType) + { + bool success = ColorsByChangeType.TryGetValue(changeType, out Color colorCode); + + if (!success) + { + throw new ArgumentOutOfRangeException(nameof(changeType)); + } + + return colorCode; + } + + /// + /// Converts a ChangeType to its corresponding Symbol. + /// + public static Symbol ToSymbol(this ChangeType changeType) + { + bool success = SymbolsByChangeType.TryGetValue(changeType, out Symbol symbol); + + if (!success) + { + throw new ArgumentOutOfRangeException(nameof(changeType)); + } + + return symbol; + } + + /// + /// Converts a ChangeType to its corresponding PSChangeType. + /// + public static PSChangeType ToPSChangeType(this ChangeType changeType) + { + bool success = PSChangeTypesByChangeType.TryGetValue(changeType, out PSChangeType psChangeType); + + if (!success) + { + throw new ArgumentOutOfRangeException(nameof(changeType)); + } + + return psChangeType; + } + } +} diff --git a/src/shared/WhatIf/Extensions/DiagnosticExtensions.cs b/src/shared/WhatIf/Extensions/DiagnosticExtensions.cs new file mode 100644 index 000000000000..b8f5f537e589 --- /dev/null +++ b/src/shared/WhatIf/Extensions/DiagnosticExtensions.cs @@ -0,0 +1,61 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions +{ + using System; + using System.Collections.Generic; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; + + /// + /// Extension methods for diagnostic levels to map to colors. + /// + public static class DiagnosticExtensions + { + /// + /// Common diagnostic level strings. + /// + public static class Level + { + public const string Error = "Error"; + public const string Warning = "Warning"; + public const string Info = "Info"; + } + + private static readonly IReadOnlyDictionary ColorsByDiagnosticLevel = + new Dictionary + { + [Level.Error] = Color.Red, + [Level.Warning] = Color.DarkYellow, + [Level.Info] = Color.Reset, + }; + + /// + /// Converts a diagnostic level string to a Color. + /// + /// The diagnostic level. + /// The corresponding color, or Gray if not found. + public static Color ToColor(this string level) + { + bool success = ColorsByDiagnosticLevel.TryGetValue(level, out Color colorCode); + + if (!success) + { + return Color.Gray; + } + + return colorCode; + } + } +} diff --git a/src/shared/WhatIf/Extensions/JTokenExtensions.cs b/src/shared/WhatIf/Extensions/JTokenExtensions.cs new file mode 100644 index 000000000000..444264a0a2b8 --- /dev/null +++ b/src/shared/WhatIf/Extensions/JTokenExtensions.cs @@ -0,0 +1,159 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions +{ + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; + using System; + using System.Collections.Generic; + using System.Management.Automation; + + /// + /// A helper class for converting and objects to classes. + /// + public static class JTokenExtensions + { + /// + /// A lookup table that contains the native mappings which are supported. + /// + private static readonly Dictionary PrimitiveTypeMap = new Dictionary() + { + { JTokenType.String, typeof(string) }, + { JTokenType.Integer, typeof(long) }, + { JTokenType.Float, typeof(double) }, + { JTokenType.Boolean, typeof(bool) }, + { JTokenType.Null, typeof(object) }, + { JTokenType.Date, typeof(DateTime) }, + { JTokenType.Bytes, typeof(byte[]) }, + { JTokenType.Guid, typeof(Guid) }, + { JTokenType.Uri, typeof(Uri) }, + { JTokenType.TimeSpan, typeof(TimeSpan) }, + }; + + private static readonly JsonSerializer JsonObjectTypeSerializer = new JsonSerializer(); + + /// + /// Converts a to a + /// + /// The + /// The type of the object. + public static PSObject ToPsObject(this JToken jtoken, string objectType = null) + { + if (jtoken == null) + { + return null; + } + + if (jtoken.Type != JTokenType.Object) + { + return new PSObject(JTokenExtensions.ConvertPropertyValueForPsObject(propertyValue: jtoken)); + } + + var jobject = (JObject)jtoken; + var psObject = new PSObject(); + + if (!string.IsNullOrWhiteSpace(objectType)) + { + psObject.TypeNames.Add(objectType); + } + + foreach (var property in jobject.Properties()) + { + psObject.Properties.Add(new PSNoteProperty( + name: property.Name, + value: JTokenExtensions.ConvertPropertyValueForPsObject(propertyValue: property.Value))); + } + + return psObject; + } + + /// + /// Converts a property value for a into an that can be + /// used as the value of a . + /// + /// The value. + public static object ConvertPropertyValueForPsObject(JToken propertyValue) + { + if (propertyValue.Type == JTokenType.Object) + { + return propertyValue.ToPsObject(); + } + + if (propertyValue.Type == JTokenType.Array) + { + var jArray = (JArray)propertyValue; + + var array = new object[jArray.Count]; + + for (int i = 0; i < array.Length; ++i) + { + array[i] = JTokenExtensions.ConvertPropertyValueForPsObject(jArray[i]); + } + + return array; + } + + Type primitiveType; + if (JTokenExtensions.PrimitiveTypeMap.TryGetValue(propertyValue.Type, out primitiveType)) + { + try + { + return propertyValue.ToObject(primitiveType, JsonObjectTypeSerializer); + } + catch (FormatException) + { + } + catch (ArgumentException) + { + } + catch (JsonException) + { + } + } + + return propertyValue.ToString(); + } + + /// + /// Checks if a is a leaf node. + /// + /// The value to check. + public static bool IsLeaf(this JToken value) + { + return value == null || + value is JValue || + value is JArray arrayValue && arrayValue.Count == 0 || + value is JObject objectValue && objectValue.Count == 0; + } + + /// + /// Checks if a is a non empty . + /// + /// The value to check. + public static bool IsNonEmptyArray(this JToken value) + { + return value is JArray arrayValue && arrayValue.Count > 0; + } + + /// + /// Checks if a is a non empty . + /// + /// The value to check. + public static bool IsNonEmptyObject(this JToken value) + { + return value is JObject objectValue && objectValue.Count > 0; + } + } +} diff --git a/src/shared/WhatIf/Extensions/PSChangeTypeExtensions.cs b/src/shared/WhatIf/Extensions/PSChangeTypeExtensions.cs new file mode 100644 index 000000000000..7debc649315e --- /dev/null +++ b/src/shared/WhatIf/Extensions/PSChangeTypeExtensions.cs @@ -0,0 +1,83 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions +{ + using System; + using System.Collections.Generic; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; + + /// + /// Extension methods for PSChangeType enum. + /// + public static class PSChangeTypeExtensions + { + private static readonly IReadOnlyDictionary ColorsByPSChangeType = + new Dictionary + { + [PSChangeType.NoChange] = Color.Reset, + [PSChangeType.Ignore] = Color.Gray, + [PSChangeType.Deploy] = Color.Blue, + [PSChangeType.Create] = Color.Green, + [PSChangeType.Delete] = Color.Orange, + [PSChangeType.Modify] = Color.Purple, + [PSChangeType.Unsupported] = Color.Gray, + [PSChangeType.NoEffect] = Color.Gray, + }; + + private static readonly IReadOnlyDictionary SymbolsByPSChangeType = + new Dictionary + { + [PSChangeType.NoChange] = Symbol.Equal, + [PSChangeType.Ignore] = Symbol.Asterisk, + [PSChangeType.Deploy] = Symbol.ExclamationPoint, + [PSChangeType.Create] = Symbol.Plus, + [PSChangeType.Delete] = Symbol.Minus, + [PSChangeType.Modify] = Symbol.Tilde, + [PSChangeType.Unsupported] = Symbol.Cross, + [PSChangeType.NoEffect] = Symbol.Cross, + }; + + /// + /// Converts a PSChangeType to its corresponding Color. + /// + public static Color ToColor(this PSChangeType psChangeType) + { + bool success = ColorsByPSChangeType.TryGetValue(psChangeType, out Color colorCode); + + if (!success) + { + throw new ArgumentOutOfRangeException(nameof(psChangeType)); + } + + return colorCode; + } + + /// + /// Converts a PSChangeType to its corresponding Symbol. + /// + public static Symbol ToSymbol(this PSChangeType psChangeType) + { + bool success = SymbolsByPSChangeType.TryGetValue(psChangeType, out Symbol symbol); + + if (!success) + { + throw new ArgumentOutOfRangeException(nameof(psChangeType)); + } + + return symbol; + } + } +} diff --git a/src/shared/WhatIf/Extensions/PropertyChangeTypeExtensions.cs b/src/shared/WhatIf/Extensions/PropertyChangeTypeExtensions.cs new file mode 100644 index 000000000000..01c570681791 --- /dev/null +++ b/src/shared/WhatIf/Extensions/PropertyChangeTypeExtensions.cs @@ -0,0 +1,134 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions +{ + using System; + using System.Collections.Generic; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; + + /// + /// Extension methods for PropertyChangeType enum. + /// + public static class PropertyChangeTypeExtensions + { + private static readonly IReadOnlyDictionary ColorsByPropertyChangeType = + new Dictionary + { + [PropertyChangeType.Create] = Color.Green, + [PropertyChangeType.Delete] = Color.Orange, + [PropertyChangeType.Modify] = Color.Purple, + [PropertyChangeType.Array] = Color.Purple, + [PropertyChangeType.NoEffect] = Color.Gray, + }; + + private static readonly IReadOnlyDictionary SymbolsByPropertyChangeType = + new Dictionary + { + [PropertyChangeType.Create] = Symbol.Plus, + [PropertyChangeType.Delete] = Symbol.Minus, + [PropertyChangeType.Modify] = Symbol.Tilde, + [PropertyChangeType.Array] = Symbol.Tilde, + [PropertyChangeType.NoEffect] = Symbol.Cross, + }; + + private static readonly IReadOnlyDictionary PSChangeTypesByPropertyChangeType = + new Dictionary + { + [PropertyChangeType.Create] = PSChangeType.Create, + [PropertyChangeType.Delete] = PSChangeType.Delete, + [PropertyChangeType.Modify] = PSChangeType.Modify, + [PropertyChangeType.Array] = PSChangeType.Modify, + [PropertyChangeType.NoEffect] = PSChangeType.NoEffect, + }; + + /// + /// Converts a PropertyChangeType to its corresponding Color. + /// + public static Color ToColor(this PropertyChangeType propertyChangeType) + { + bool success = ColorsByPropertyChangeType.TryGetValue(propertyChangeType, out Color colorCode); + + if (!success) + { + throw new ArgumentOutOfRangeException(nameof(propertyChangeType)); + } + + return colorCode; + } + + /// + /// Converts a PropertyChangeType to its corresponding Symbol. + /// + public static Symbol ToSymbol(this PropertyChangeType propertyChangeType) + { + bool success = SymbolsByPropertyChangeType.TryGetValue(propertyChangeType, out Symbol symbol); + + if (!success) + { + throw new ArgumentOutOfRangeException(nameof(propertyChangeType)); + } + + return symbol; + } + + /// + /// Converts a PropertyChangeType to its corresponding PSChangeType. + /// + public static PSChangeType ToPSChangeType(this PropertyChangeType propertyChangeType) + { + bool success = PSChangeTypesByPropertyChangeType.TryGetValue(propertyChangeType, out PSChangeType changeType); + + if (!success) + { + throw new ArgumentOutOfRangeException(nameof(propertyChangeType)); + } + + return changeType; + } + + /// + /// Checks if the property change is a delete operation. + /// + public static bool IsDelete(this PropertyChangeType propertyChangeType) + { + return propertyChangeType == PropertyChangeType.Delete; + } + + /// + /// Checks if the property change is a create operation. + /// + public static bool IsCreate(this PropertyChangeType propertyChangeType) + { + return propertyChangeType == PropertyChangeType.Create; + } + + /// + /// Checks if the property change is a modify operation. + /// + public static bool IsModify(this PropertyChangeType propertyChangeType) + { + return propertyChangeType == PropertyChangeType.Modify; + } + + /// + /// Checks if the property change is an array operation. + /// + public static bool IsArray(this PropertyChangeType propertyChangeType) + { + return propertyChangeType == PropertyChangeType.Array; + } + } +} diff --git a/src/shared/WhatIf/Formatters/Color.cs b/src/shared/WhatIf/Formatters/Color.cs new file mode 100644 index 000000000000..0f46e714b3cb --- /dev/null +++ b/src/shared/WhatIf/Formatters/Color.cs @@ -0,0 +1,76 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters +{ + using System; + + public class Color : IEquatable + { + private const char Esc = (char)27; + + private readonly string colorCode; + + public static Color Orange { get; } = new Color($"{Esc}[38;5;208m"); + + public static Color Green { get; } = new Color($"{Esc}[38;5;77m"); + + public static Color Purple { get; } = new Color($"{Esc}[38;5;141m"); + + public static Color Blue { get; } = new Color($"{Esc}[38;5;39m"); + + public static Color Gray { get; } = new Color($"{Esc}[38;5;246m"); + + public static Color Reset { get; } = new Color($"{Esc}[0m"); + + public static Color Red { get; } = new Color($"{Esc}[38;5;203m"); + + public static Color DarkYellow { get; } = new Color($"{Esc}[38;5;136m"); + + private Color(string colorCode) + { + this.colorCode = colorCode; + } + + public override string ToString() + { + return this.colorCode; + } + + public override int GetHashCode() + { + return colorCode != null ? colorCode.GetHashCode() : 0; + } + + public override bool Equals(object obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + return obj.GetType() == this.GetType() && Equals((Color)obj); + } + + public bool Equals(Color other) + { + return other != null && string.Equals(this.colorCode, other.colorCode); + } + } +} diff --git a/src/shared/WhatIf/Formatters/ColoredStringBuilder.cs b/src/shared/WhatIf/Formatters/ColoredStringBuilder.cs new file mode 100644 index 000000000000..a20383910b1a --- /dev/null +++ b/src/shared/WhatIf/Formatters/ColoredStringBuilder.cs @@ -0,0 +1,120 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters +{ + using System; + using System.Collections.Generic; + using System.Text; + + public class ColoredStringBuilder + { + private readonly StringBuilder stringBuilder = new StringBuilder(); + + private readonly Stack colorStack = new Stack(); + + public override string ToString() + { + return stringBuilder.ToString(); + } + + public ColoredStringBuilder Append(string value) + { + this.stringBuilder.Append(value); + + return this; + } + + public ColoredStringBuilder Append(string value, Color color) + { + this.PushColor(color); + this.Append(value); + this.PopColor(); + + return this; + } + + public ColoredStringBuilder Append(object value) + { + this.stringBuilder.Append(value); + + return this; + } + + public ColoredStringBuilder Append(object value, Color color) + { + this.PushColor(color); + this.Append(value); + this.PopColor(); + + return this; + } + + public ColoredStringBuilder AppendLine() + { + this.stringBuilder.AppendLine(); + + return this; + } + + public ColoredStringBuilder AppendLine(string value) + { + this.stringBuilder.AppendLine(value); + + return this; + } + + public ColoredStringBuilder AppendLine(string value, Color color) + { + this.PushColor(color); + this.AppendLine(value); + this.PopColor(); + + return this; + } + + public AnsiColorScope NewColorScope(Color color) + { + return new AnsiColorScope(this, color); + } + + private void PushColor(Color color) + { + this.colorStack.Push(color); + this.stringBuilder.Append(color); + } + + private void PopColor() + { + this.colorStack.Pop(); + this.stringBuilder.Append(this.colorStack.Count > 0 ? this.colorStack.Peek() : Color.Reset); + } + + public class AnsiColorScope: IDisposable + { + private readonly ColoredStringBuilder builder; + + public AnsiColorScope(ColoredStringBuilder builder, Color color) + { + this.builder = builder; + this.builder.PushColor(color); + } + + public void Dispose() + { + this.builder.PopColor(); + } + } + } +} diff --git a/src/shared/WhatIf/Formatters/Symbol.cs b/src/shared/WhatIf/Formatters/Symbol.cs new file mode 100644 index 000000000000..f341e22d0c24 --- /dev/null +++ b/src/shared/WhatIf/Formatters/Symbol.cs @@ -0,0 +1,62 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters +{ + public class Symbol + { + private readonly char character; + + public static Symbol WhiteSpace { get; } = new Symbol(' '); + + public static Symbol Quote { get; } = new Symbol('"'); + + public static Symbol Colon { get; } = new Symbol(':'); + + public static Symbol LeftSquareBracket { get; } = new Symbol('['); + + public static Symbol RightSquareBracket { get; } = new Symbol(']'); + + public static Symbol Dot { get; } = new Symbol('.'); + + public static Symbol Equal { get; } = new Symbol('='); + + public static Symbol Asterisk { get; } = new Symbol('*'); + + public static Symbol Plus { get; } = new Symbol('+'); + + public static Symbol Minus { get; } = new Symbol('-'); + + public static Symbol Tilde { get; } = new Symbol('~'); + + public static Symbol ExclamationPoint { get; } = new Symbol('!'); + + public static Symbol Cross { get; } = new Symbol('x'); + + private Symbol(char character) + { + this.character = character; + } + + public override string ToString() + { + return this.character.ToString(); + } + + public char ToChar() + { + return this.character; + } + } +} diff --git a/src/shared/WhatIf/Formatters/WhatIfJsonFormatter.cs b/src/shared/WhatIf/Formatters/WhatIfJsonFormatter.cs new file mode 100644 index 000000000000..ac9345862a26 --- /dev/null +++ b/src/shared/WhatIf/Formatters/WhatIfJsonFormatter.cs @@ -0,0 +1,242 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters +{ + using System; + using System.Collections.Generic; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; + using Newtonsoft.Json.Linq; + + public class WhatIfJsonFormatter + { + private const int IndentSize = 2; + + protected ColoredStringBuilder Builder { get; } + + public WhatIfJsonFormatter(ColoredStringBuilder builder) + { + this.Builder = builder; + } + + public static string FormatJson(JToken value) + { + var builder = new ColoredStringBuilder(); + var formatter = new WhatIfJsonFormatter(builder); + + formatter.FormatJson(value, ""); + + return builder.ToString(); + } + + protected void FormatJson(JToken value, string path = "", int maxPathLength = 0, int indentLevel = 0) + { + if (value.IsLeaf()) + { + this.FormatJsonPath(path, maxPathLength - path.Length + 1, indentLevel); + this.FormatLeaf(value); + } + else if (value.IsNonEmptyArray()) + { + this.FormatJsonPath(path, 1, indentLevel); + this.FormatNonEmptyArray(value as JArray, indentLevel); + } + else if (value.IsNonEmptyObject()) + { + this.FormatNonEmptyObject(value as JObject, path, maxPathLength, indentLevel); + } + else + { + throw new ArgumentOutOfRangeException($"Invalid JSON value: {value}"); + } + } + + protected static string Indent(int indentLevel = 1) + { + return new string(Symbol.WhiteSpace.ToChar(), IndentSize * indentLevel); + } + + protected void FormatIndent(int indentLevel) + { + this.Builder.Append(Indent(indentLevel)); + } + + protected void FormatPath(string path, int paddingWidth, int indentLevel, Action formatHead = null, Action formatTail = null) + { + if (string.IsNullOrEmpty(path)) + { + return; + } + + this.FormatIndent(indentLevel); + formatHead?.Invoke(); + this.Builder.Append(path); + formatTail?.Invoke(); + this.Builder.Append(new string(Symbol.WhiteSpace.ToChar(), paddingWidth)); + } + + protected void FormatColon() + { + this.Builder.Append(Symbol.Colon, Color.Reset); + } + + protected void FormatPadding(int paddingWidth) + { + this.Builder.Append(new string(Symbol.WhiteSpace.ToChar(), paddingWidth)); + } + + private static int GetMaxPathLength(JArray arrayValue) + { + var maxLengthIndex = 0; + + for (var i = 0; i < arrayValue.Count; i++) + { + if (arrayValue[i].IsLeaf()) + { + maxLengthIndex = i; + } + } + + return maxLengthIndex.ToString().Length; + } + + private static int GetMaxPathLength(JObject objectValue) + { + var maxPathLength = 0; + + foreach (KeyValuePair property in objectValue) + { + if (property.Value.IsNonEmptyArray()) + { + // Ignore array paths to avoid long padding like this: + // + // short.path: "foo" + // another.short.path: "bar" + // very.very.long.path.to.array: [ + // ... + // ] + // path.after.array: "foobar" + // + // Instead, the following is preferred: + // + // short.path: "foo" + // another.short.path: "bar" + // very.very.long.path.to.array: [ + // ... + // ] + // path.after.array: "foobar" + // + continue; + } + + int currentPathLength = property.Value.IsNonEmptyObject() + // Add one for dot. + ? property.Key.Length + 1 + GetMaxPathLength(property.Value as JObject) + : property.Key.Length; + + maxPathLength = Math.Max(maxPathLength, currentPathLength); + } + + return maxPathLength; + } + + private void FormatLeaf(JToken value) + { + value = value ?? JValue.CreateNull(); + + switch (value.Type) + { + case JTokenType.Null: + this.Builder.Append("null"); + return; + + case JTokenType.Boolean: + this.Builder.Append(value.ToString().ToLowerInvariant()); + return; + + case JTokenType.String: + this.Builder + .Append(Symbol.Quote) + .Append(value) + .Append(Symbol.Quote); + return; + + default: + this.Builder.Append(value); + return; + } + } + + private void FormatNonEmptyArray(JArray value, int indentLevel) + { + // [ + this.Builder + .Append(Symbol.LeftSquareBracket, Color.Reset) + .AppendLine(); + + int maxPathLength = GetMaxPathLength(value); + + for (var index = 0; index < value.Count; index++) + { + JToken childValue = value[index]; + string childPath = index.ToString(); + + if (childValue.IsNonEmptyObject()) + { + this.FormatJsonPath(childPath, 0, indentLevel + 1); + this.FormatNonEmptyObject(childValue as JObject, indentLevel: indentLevel + 1); + } + else + { + this.FormatJson(childValue, childPath, maxPathLength, indentLevel + 1); + } + + this.Builder.AppendLine(); + } + + // ] + this.Builder + .Append(Indent(indentLevel)) + .Append(Symbol.RightSquareBracket, Color.Reset); + } + + private void FormatNonEmptyObject(JObject value, string path = "", int maxPathLength = 0, int indentLevel = 0) + { + bool isRoot = string.IsNullOrEmpty(path); + + if (isRoot) + { + this.Builder.AppendLine().AppendLine(); + + maxPathLength = GetMaxPathLength(value); + indentLevel++; + } + + // Unwrap nested values. + foreach (KeyValuePair property in value) + { + string childPath = isRoot ? property.Key : $"{path}{Symbol.Dot}{property.Key}"; + this.FormatJson(property.Value, childPath, maxPathLength, indentLevel); + + if (!property.Value.IsNonEmptyObject()) + { + this.Builder.AppendLine(); + } + } + } + + private void FormatJsonPath(string path, int paddingWidth, int indentLevel) => + this.FormatPath(path, paddingWidth, indentLevel, formatTail: this.FormatColon); + } +} diff --git a/src/shared/WhatIf/Formatters/WhatIfOperationResultFormatter.cs b/src/shared/WhatIf/Formatters/WhatIfOperationResultFormatter.cs new file mode 100644 index 000000000000..3450df46a741 --- /dev/null +++ b/src/shared/WhatIf/Formatters/WhatIfOperationResultFormatter.cs @@ -0,0 +1,641 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Comparers; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; + using Newtonsoft.Json.Linq; + + /// + /// Formatter for WhatIf operation results. + /// Works with any RP-specific implementation via IWhatIfOperationResult interface. + /// + public class WhatIfOperationResultFormatter : WhatIfJsonFormatter + { + // Diagnostic level constants + protected const string LevelError = "Error"; + protected const string LevelWarning = "Warning"; + protected const string LevelInfo = "Info"; + + public WhatIfOperationResultFormatter(ColoredStringBuilder builder) + : base(builder) + { + } + + /// + /// Formats a WhatIf operation result into a colored string. + /// + public static string Format(IWhatIfOperationResult result, string noiseNotice = null) + { + if (result == null) + { + return null; + } + + var builder = new ColoredStringBuilder(); + var formatter = new WhatIfOperationResultFormatter(builder); + + formatter.FormatNoiseNotice(noiseNotice); + formatter.FormatLegend(result.Changes, result.PotentialChanges); + formatter.FormatResourceChanges(result.Changes, true); + formatter.FormatStats(result.Changes, true); + formatter.FormatResourceChanges(result.PotentialChanges, false); + formatter.FormatStats(result.PotentialChanges, false); + formatter.FormatDiagnostics(result.Diagnostics, result.Changes, result.PotentialChanges); + + return builder.ToString(); + } + + protected virtual void FormatNoiseNotice(string noiseNotice = null) + { + if (string.IsNullOrEmpty(noiseNotice)) + { + noiseNotice = "Note: The result may contain false positive predictions (noise)."; + } + + this.Builder + .AppendLine(noiseNotice) + .AppendLine(); + } + + private static int GetMaxPathLength(IList propertyChanges) + { + if (propertyChanges == null) + { + return 0; + } + + return propertyChanges + .Where(ShouldConsiderPathLength) + .Select(pc => pc.Path.Length) + .DefaultIfEmpty() + .Max(); + } + + private static bool ShouldConsiderPathLength(IWhatIfPropertyChange propertyChange) + { + switch (propertyChange.PropertyChangeType) + { + case PropertyChangeType.Create: + case PropertyChangeType.NoEffect: + return propertyChange.After.IsLeaf(); + + case PropertyChangeType.Delete: + case PropertyChangeType.Modify: + return propertyChange.Before.IsLeaf(); + + default: + return propertyChange.Children == null || propertyChange.Children.Count == 0; + } + } + + protected virtual void FormatStats(IList resourceChanges, bool definiteChanges) + { + if (definiteChanges) + { + this.Builder.AppendLine().Append("Resource changes: "); + } + else if (resourceChanges != null && resourceChanges.Count != 0) + { + this.Builder.AppendLine().Append("Potential changes: "); + } + else + { + return; + } + + if (resourceChanges == null || resourceChanges.Count == 0) + { + this.Builder.Append("no change"); + } + else + { + IEnumerable stats = resourceChanges + .OrderBy(rc => rc.ChangeType, new ChangeTypeComparer()) + .GroupBy(rc => rc.ChangeType) + .Select(g => new { ChangeType = g.Key, Count = g.Count() }) + .Where(x => x.Count != 0) + .Select(x => this.FormatChangeTypeCount(x.ChangeType, x.Count)); + + this.Builder.Append(string.Join(", ", stats)); + } + + this.Builder.Append("."); + } + + protected virtual void FormatDiagnostics( + IList diagnostics, + IList changes, + IList potentialChanges) + { + var diagnosticsList = diagnostics != null ? new List(diagnostics) : new List(); + + // Add unsupported changes as warnings + void AddUnsupportedWarnings(IList changeList) + { + if (changeList != null) + { + var unsupportedChanges = changeList + .Where(c => c.ChangeType == ChangeType.Unsupported) + .ToList(); + + foreach (var change in unsupportedChanges) + { + diagnosticsList.Add(new WhatIfDiagnostic + { + Level = LevelWarning, + Code = "Unsupported", + Message = change.UnsupportedReason, + Target = change.FullyQualifiedResourceId + }); + } + } + } + + AddUnsupportedWarnings(changes); + AddUnsupportedWarnings(potentialChanges); + + if (diagnosticsList.Count == 0) + { + return; + } + + this.Builder.AppendLine().AppendLine(); + this.Builder.Append($"Diagnostics ({diagnosticsList.Count}): ").AppendLine(); + + foreach (var diagnostic in diagnosticsList) + { + using (this.Builder.NewColorScope(DiagnosticLevelToColor(diagnostic.Level))) + { + this.Builder.Append($"({diagnostic.Target})").Append(Symbol.WhiteSpace); + this.Builder.Append(diagnostic.Message).Append(Symbol.WhiteSpace); + this.Builder.Append($"({diagnostic.Code})"); + this.Builder.AppendLine(); + } + } + } + + protected virtual Color DiagnosticLevelToColor(string level) + { + if (string.IsNullOrEmpty(level)) + { + return Color.Reset; + } + + // Use the same logic as DiagnosticExtensions + switch (level.ToLowerInvariant()) + { + case "error": + return Color.Red; + case "warning": + return Color.DarkYellow; + case "info": + return Color.Blue; + default: + return Color.Reset; + } + } + + protected virtual string FormatChangeTypeCount(ChangeType changeType, int count) + { + switch (changeType) + { + case ChangeType.Create: + return $"{count} to create"; + case ChangeType.Delete: + return $"{count} to delete"; + case ChangeType.Deploy: + return $"{count} to deploy"; + case ChangeType.Modify: + return $"{count} to modify"; + case ChangeType.Ignore: + return $"{count} to ignore"; + case ChangeType.NoChange: + return $"{count} no change"; + case ChangeType.Unsupported: + return $"{count} unsupported"; + default: + throw new ArgumentOutOfRangeException(nameof(changeType), changeType, null); + } + } + + protected virtual void FormatLegend(IList changes, IList potentialChanges) + { + var resourceChanges = changes != null ? new List(changes) : new List(); + + if (potentialChanges != null && potentialChanges.Count > 0) + { + resourceChanges = resourceChanges.Concat(potentialChanges).ToList(); + } + + if (resourceChanges.Count == 0) + { + return; + } + + var psChangeTypeSet = new HashSet(); + + void PopulateChangeTypeSet(IList propertyChanges) + { + if (propertyChanges == null) + { + return; + } + + foreach (var propertyChange in propertyChanges) + { + psChangeTypeSet.Add(propertyChange.PropertyChangeType.ToPSChangeType()); + PopulateChangeTypeSet(propertyChange.Children); + } + } + + foreach (var resourceChange in resourceChanges) + { + psChangeTypeSet.Add(resourceChange.ChangeType.ToPSChangeType()); + PopulateChangeTypeSet(resourceChange.Delta); + } + + this.Builder + .Append("Resource and property changes are indicated with ") + .AppendLine(psChangeTypeSet.Count == 1 ? "this symbol:" : "these symbols:"); + + foreach (var changeType in psChangeTypeSet.OrderBy(x => x, new PSChangeTypeComparer())) + { + this.Builder + .Append(Indent()) + .Append(changeType.ToSymbol(), changeType.ToColor()) + .Append(Symbol.WhiteSpace) + .Append(changeType) + .AppendLine(); + } + } + + protected virtual void FormatResourceChanges(IList resourceChanges, bool definiteChanges) + { + if (resourceChanges == null || resourceChanges.Count == 0) + { + return; + } + + int scopeCount = resourceChanges.Select(rc => rc.Scope.ToUpperInvariant()).Distinct().Count(); + + if (definiteChanges) + { + this.Builder + .AppendLine() + .Append("The deployment will update the following ") + .AppendLine(scopeCount == 1 ? "scope:" : "scopes:"); + } + else + { + this.Builder + .AppendLine() + .AppendLine() + .AppendLine() + .Append("The following change MAY OR MAY NOT be deployed to the following ") + .AppendLine(scopeCount == 1 ? "scope:" : "scopes:"); + } + + var groupedByScope = resourceChanges + .OrderBy(rc => rc.Scope.ToUpperInvariant()) + .GroupBy(rc => rc.Scope.ToUpperInvariant()) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var kvp in groupedByScope) + { + FormatResourceChangesInScope(kvp.Value[0].Scope, kvp.Value); + } + } + + protected virtual void FormatResourceChangesInScope(string scope, IList resourceChanges) + { + // Scope. + this.Builder + .AppendLine() + .AppendLine($"Scope: {scope}"); + + // Resource changes. + List sortedResourceChanges = resourceChanges + .OrderBy(rc => rc.ChangeType, new ChangeTypeComparer()) + .ThenBy(rc => rc.RelativeResourceId) + .ToList(); + + var groupedByChangeType = sortedResourceChanges + .GroupBy(rc => rc.ChangeType) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var kvp in groupedByChangeType) + { + using (this.Builder.NewColorScope(kvp.Key.ToColor())) + { + foreach (var rc in kvp.Value) + { + this.FormatResourceChange(rc, rc == sortedResourceChanges.Last()); + } + } + } + } + + protected virtual void FormatResourceChange(IWhatIfChange resourceChange, bool isLast) + { + this.Builder.AppendLine(); + this.FormatResourceChangePath( + resourceChange.ChangeType, + resourceChange.RelativeResourceId, + resourceChange.ApiVersion); + + switch (resourceChange.ChangeType) + { + case ChangeType.Create when resourceChange.After != null: + this.FormatJson(resourceChange.After, indentLevel: 2); + return; + + case ChangeType.Delete when resourceChange.Before != null: + this.FormatJson(resourceChange.Before, indentLevel: 2); + return; + + default: + if (resourceChange.Delta?.Count > 0) + { + using (this.Builder.NewColorScope(Color.Reset)) + { + IList propertyChanges = resourceChange.Delta + .OrderBy(pc => pc.PropertyChangeType, new PropertyChangeTypeComparer()) + .ThenBy(pc => pc.Path) + .ToList(); + + this.Builder.AppendLine(); + this.FormatPropertyChanges(propertyChanges); + } + + return; + } + + if (isLast) + { + this.Builder.AppendLine(); + } + + return; + } + } + + protected virtual void FormatResourceChangePath(ChangeType changeType, string relativeResourceId, string apiVersion) + { + this.FormatPath( + relativeResourceId, + 0, + 1, + () => this.Builder.Append(changeType.ToSymbol()).Append(Symbol.WhiteSpace), + () => this.FormatResourceChangeApiVersion(apiVersion)); + } + + protected virtual void FormatResourceChangeApiVersion(string apiVersion) + { + if (string.IsNullOrEmpty(apiVersion)) + { + return; + } + + using (this.Builder.NewColorScope(Color.Reset)) + { + this.Builder + .Append(Symbol.WhiteSpace) + .Append(Symbol.LeftSquareBracket) + .Append(apiVersion) + .Append(Symbol.RightSquareBracket); + } + } + + protected virtual void FormatPropertyChanges(IList propertyChanges, int indentLevel = 2) + { + int maxPathLength = GetMaxPathLength(propertyChanges); + foreach (var pc in propertyChanges) + { + this.FormatPropertyChange(pc, maxPathLength, indentLevel); + this.Builder.AppendLine(); + } + } + + protected virtual void FormatPropertyChange(IWhatIfPropertyChange propertyChange, int maxPathLength, int indentLevel) + { + PropertyChangeType propertyChangeType = propertyChange.PropertyChangeType; + string path = propertyChange.Path; + JToken before = propertyChange.Before; + JToken after = propertyChange.After; + IList children = propertyChange.Children; + + switch (propertyChange.PropertyChangeType) + { + case PropertyChangeType.Create: + this.FormatPropertyChangePath(propertyChangeType, path, after, children, maxPathLength, indentLevel); + this.FormatPropertyCreate(after, indentLevel + 1); + break; + + case PropertyChangeType.Delete: + this.FormatPropertyChangePath(propertyChangeType, path, before, children, maxPathLength, indentLevel); + this.FormatPropertyDelete(before, indentLevel + 1); + break; + + case PropertyChangeType.Modify: + this.FormatPropertyChangePath(propertyChangeType, path, before, children, maxPathLength, indentLevel); + this.FormatPropertyModify(propertyChange, indentLevel + 1); + break; + + case PropertyChangeType.Array: + this.FormatPropertyChangePath(propertyChangeType, path, null, children, maxPathLength, indentLevel); + this.FormatPropertyArrayChange(propertyChange, propertyChange.Children, indentLevel + 1); + break; + + case PropertyChangeType.NoEffect: + this.FormatPropertyChangePath(propertyChangeType, path, after, children, maxPathLength, indentLevel); + this.FormatPropertyNoEffect(after, indentLevel + 1); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + protected virtual void FormatPropertyChangePath( + PropertyChangeType propertyChangeType, + string path, + JToken valueAfterPath, + IList children, + int maxPathLength, + int indentLevel) + { + if (string.IsNullOrEmpty(path)) + { + return; + } + + int paddingWidth = maxPathLength - path.Length + 1; + bool hasChildren = children != null && children.Count > 0; + + if (valueAfterPath.IsNonEmptyArray() || (propertyChangeType == PropertyChangeType.Array && hasChildren)) + { + paddingWidth = 1; + } + if (valueAfterPath.IsNonEmptyObject()) + { + paddingWidth = 0; + } + if (propertyChangeType == PropertyChangeType.Modify && hasChildren) + { + paddingWidth = 0; + } + + this.FormatPath( + path, + paddingWidth, + indentLevel, + () => this.FormatPropertyChangeType(propertyChangeType), + this.FormatColon); + } + + protected virtual void FormatPropertyChangeType(PropertyChangeType propertyChangeType) + { + this.Builder + .Append(propertyChangeType.ToSymbol(), propertyChangeType.ToColor()) + .Append(Symbol.WhiteSpace); + } + + protected virtual void FormatPropertyCreate(JToken value, int indentLevel) + { + using (this.Builder.NewColorScope(Color.Green)) + { + this.FormatJson(value, indentLevel: indentLevel); + } + } + + protected virtual void FormatPropertyDelete(JToken value, int indentLevel) + { + using (this.Builder.NewColorScope(Color.Orange)) + { + this.FormatJson(value, indentLevel: indentLevel); + } + } + + protected virtual void FormatPropertyModify(IWhatIfPropertyChange propertyChange, int indentLevel) + { + if (propertyChange.Children != null && propertyChange.Children.Count > 0) + { + // Has nested changes. + this.Builder.AppendLine().AppendLine(); + this.FormatPropertyChanges(propertyChange.Children + .OrderBy(pc => pc.PropertyChangeType, new PropertyChangeTypeComparer()) + .ThenBy(pc => pc.Path) + .ToList(), + indentLevel); + } + else + { + JToken before = propertyChange.Before; + JToken after = propertyChange.After; + + // The before value. + this.FormatPropertyDelete(before, indentLevel); + + // Space before => + if (before.IsNonEmptyObject()) + { + this.Builder + .AppendLine() + .Append(Indent(indentLevel)); + } + else + { + this.Builder.Append(Symbol.WhiteSpace); + } + + // => + this.Builder.Append("=>"); + + // Space after => + if (!after.IsNonEmptyObject()) + { + this.Builder.Append(Symbol.WhiteSpace); + } + + // The after value. + this.FormatPropertyCreate(after, indentLevel); + + if (!before.IsLeaf() && after.IsLeaf()) + { + this.Builder.AppendLine(); + } + } + } + + protected virtual void FormatPropertyArrayChange(IWhatIfPropertyChange parentPropertyChange, IList propertyChanges, int indentLevel) + { + if (string.IsNullOrEmpty(parentPropertyChange.Path)) + { + // The parent change doesn't have a path, which means the current + // array change is a nested change. We need to decrease indent_level + // and print indentation before printing "[". + indentLevel--; + FormatIndent(indentLevel); + } + + if (propertyChanges.Count == 0) + { + this.Builder.AppendLine("[]"); + return; + } + + // [ + this.Builder + .Append(Symbol.LeftSquareBracket) + .AppendLine(); + + this.FormatPropertyChanges(propertyChanges + .OrderBy(pc => int.Parse(pc.Path)) + .ThenBy(pc => pc.PropertyChangeType, new PropertyChangeTypeComparer()) + .ToList(), + indentLevel); + + // ] + this.Builder + .Append(Indent(indentLevel)) + .Append(Symbol.RightSquareBracket); + } + + protected virtual void FormatPropertyNoEffect(JToken value, int indentLevel) + { + using (this.Builder.NewColorScope(Color.Gray)) + { + this.FormatJson(value, indentLevel: indentLevel); + } + } + + /// + /// Simple diagnostic implementation for internal use. + /// + private class WhatIfDiagnostic : IWhatIfDiagnostic + { + public string Code { get; set; } + public string Message { get; set; } + public string Level { get; set; } + public string Target { get; set; } + public string Details { get; set; } + } + } +} diff --git a/src/shared/WhatIf/INTEGRATION_GUIDE.md b/src/shared/WhatIf/INTEGRATION_GUIDE.md new file mode 100644 index 000000000000..b522103ff19b --- /dev/null +++ b/src/shared/WhatIf/INTEGRATION_GUIDE.md @@ -0,0 +1,412 @@ +# 完整实现示例 + +本文档展示如何在您的 RP 模块中集成和使用 WhatIf 共享库。 + +## 场景:在 Compute 模块中实现 WhatIf 功能 + +### 步骤 1: 实现接口 + +首先,在您的 RP 模块中创建实现 WhatIf 接口的类: + +```csharp +// File: src/Compute/Compute/Models/PSComputeWhatIfPropertyChange.cs +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; +using Microsoft.Azure.Management.Compute.Models; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.Commands.Compute.Models +{ + /// + /// Compute-specific implementation of IWhatIfPropertyChange + /// + public class PSComputeWhatIfPropertyChange : IWhatIfPropertyChange + { + private readonly WhatIfPropertyChange sdkPropertyChange; + private readonly Lazy before; + private readonly Lazy after; + private readonly Lazy> children; + + public PSComputeWhatIfPropertyChange(WhatIfPropertyChange sdkPropertyChange) + { + this.sdkPropertyChange = sdkPropertyChange; + this.before = new Lazy(() => sdkPropertyChange.Before.ToJToken()); + this.after = new Lazy(() => sdkPropertyChange.After.ToJToken()); + this.children = new Lazy>(() => + sdkPropertyChange.Children?.Select(pc => new PSComputeWhatIfPropertyChange(pc) as IWhatIfPropertyChange).ToList()); + } + + public string Path => sdkPropertyChange.Path; + public PropertyChangeType PropertyChangeType => sdkPropertyChange.PropertyChangeType; + public JToken Before => before.Value; + public JToken After => after.Value; + public IList Children => children.Value; + } +} +``` + +```csharp +// File: src/Compute/Compute/Models/PSComputeWhatIfChange.cs +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Utilities; +using Microsoft.Azure.Management.Compute.Models; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.Commands.Compute.Models +{ + /// + /// Compute-specific implementation of IWhatIfChange + /// + public class PSComputeWhatIfChange : IWhatIfChange + { + private readonly WhatIfChange sdkChange; + private readonly Lazy before; + private readonly Lazy after; + private readonly Lazy> delta; + private readonly Lazy apiVersion; + + public PSComputeWhatIfChange(WhatIfChange sdkChange) + { + this.sdkChange = sdkChange; + + // Split resource ID into scope and relative path + (string scope, string relativeResourceId) = ResourceIdUtility.SplitResourceId(sdkChange.ResourceId); + this.Scope = scope; + this.RelativeResourceId = relativeResourceId; + this.UnsupportedReason = sdkChange.UnsupportedReason; + + this.apiVersion = new Lazy(() => + this.Before?["apiVersion"]?.Value() ?? this.After?["apiVersion"]?.Value()); + this.before = new Lazy(() => sdkChange.Before.ToJToken()); + this.after = new Lazy(() => sdkChange.After.ToJToken()); + this.delta = new Lazy>(() => + sdkChange.Delta?.Select(pc => new PSComputeWhatIfPropertyChange(pc) as IWhatIfPropertyChange).ToList()); + } + + public string Scope { get; } + public string RelativeResourceId { get; } + public string UnsupportedReason { get; } + public string FullyQualifiedResourceId => sdkChange.ResourceId; + public ChangeType ChangeType => sdkChange.ChangeType; + public string ApiVersion => apiVersion.Value; + public JToken Before => before.Value; + public JToken After => after.Value; + public IList Delta => delta.Value; + } +} +``` + +```csharp +// File: src/Compute/Compute/Models/PSComputeWhatIfDiagnostic.cs +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; +using Microsoft.Azure.Management.Compute.Models; + +namespace Microsoft.Azure.Commands.Compute.Models +{ + public class PSComputeWhatIfDiagnostic : IWhatIfDiagnostic + { + public PSComputeWhatIfDiagnostic(DeploymentDiagnosticsDefinition diagnostic) + { + this.Code = diagnostic.Code; + this.Message = diagnostic.Message; + this.Level = diagnostic.Level; + this.Target = diagnostic.Target; + this.Details = diagnostic.Details; + } + + public string Code { get; set; } + public string Message { get; set; } + public string Level { get; set; } + public string Target { get; set; } + public string Details { get; set; } + } +} +``` + +```csharp +// File: src/Compute/Compute/Models/PSComputeWhatIfError.cs +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; + +namespace Microsoft.Azure.Commands.Compute.Models +{ + public class PSComputeWhatIfError : IWhatIfError + { + public string Code { get; set; } + public string Message { get; set; } + public string Target { get; set; } + } +} +``` + +```csharp +// File: src/Compute/Compute/Models/PSComputeWhatIfOperationResult.cs +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; +using Microsoft.Azure.Management.Compute.Models; + +namespace Microsoft.Azure.Commands.Compute.Models +{ + public class PSComputeWhatIfOperationResult : IWhatIfOperationResult + { + private readonly WhatIfOperationResult sdkResult; + private readonly Lazy> changes; + private readonly Lazy> potentialChanges; + private readonly Lazy> diagnostics; + private readonly Lazy error; + + public PSComputeWhatIfOperationResult(WhatIfOperationResult sdkResult) + { + this.sdkResult = sdkResult; + + this.changes = new Lazy>(() => + sdkResult.Changes?.Select(c => new PSComputeWhatIfChange(c) as IWhatIfChange).ToList()); + + this.potentialChanges = new Lazy>(() => + sdkResult.PotentialChanges?.Select(c => new PSComputeWhatIfChange(c) as IWhatIfChange).ToList()); + + this.diagnostics = new Lazy>(() => + sdkResult.Diagnostics?.Select(d => new PSComputeWhatIfDiagnostic(d) as IWhatIfDiagnostic).ToList()); + + this.error = new Lazy(() => + sdkResult.Error != null ? new PSComputeWhatIfError + { + Code = sdkResult.Error.Code, + Message = sdkResult.Error.Message, + Target = sdkResult.Error.Target + } : null); + } + + public string Status => sdkResult.Status; + public IList Changes => changes.Value; + public IList PotentialChanges => potentialChanges.Value; + public IList Diagnostics => diagnostics.Value; + public IWhatIfError Error => error.Value; + } +} +``` + +### 步骤 2: 在 Cmdlet 中使用 + +```csharp +// File: src/Compute/Compute/Cmdlets/VirtualMachine/NewAzureVMWhatIf.cs +using System.Management.Automation; +using Microsoft.Azure.Commands.Compute.Models; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.Management.Compute; + +namespace Microsoft.Azure.Commands.Compute.Cmdlets.VirtualMachine +{ + [Cmdlet(VerbsCommon.New, "AzVM", SupportsShouldProcess = true)] + [OutputType(typeof(PSComputeWhatIfOperationResult))] + public class NewAzureVMWhatIfCommand : ComputeClientBaseCmdlet + { + [Parameter(Mandatory = true)] + public string ResourceGroupName { get; set; } + + [Parameter(Mandatory = true)] + public string Name { get; set; } + + [Parameter(Mandatory = true)] + public string Location { get; set; } + + // ... 其他参数 ... + + public override void ExecuteCmdlet() + { + if (ShouldProcess(this.Name, "Create Virtual Machine")) + { + // 1. 调用 WhatIf API + var whatIfResult = ComputeClient.VirtualMachines.WhatIf( + ResourceGroupName, + Name, + new VirtualMachine + { + Location = Location, + // ... 其他属性 ... + } + ); + + // 2. 包装为 PS 对象(实现接口) + var psResult = new PSComputeWhatIfOperationResult(whatIfResult); + + // 3. 格式化输出 + string formattedOutput = WhatIfOperationResultFormatter.Format( + psResult, + noiseNotice: "Note: This is a preview. The actual deployment may differ." + ); + + // 4. 输出到控制台 + WriteObject(formattedOutput); + + // 5. 也可以返回结构化对象供管道使用 + WriteObject(psResult); + } + } + } +} +``` + +### 步骤 3: 自定义格式化(可选) + +如果需要自定义输出格式: + +```csharp +// File: src/Compute/Compute/Formatters/ComputeWhatIfFormatter.cs +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; + +namespace Microsoft.Azure.Commands.Compute.Formatters +{ + public class ComputeWhatIfFormatter : WhatIfOperationResultFormatter + { + public ComputeWhatIfFormatter(ColoredStringBuilder builder) : base(builder) + { + } + + // 自定义 Compute 特有的提示信息 + protected override void FormatNoiseNotice(string noiseNotice = null) + { + this.Builder + .AppendLine("=== Azure Compute WhatIf Analysis ===") + .AppendLine("Note: Virtual machine configurations may have additional dependencies.") + .AppendLine(); + } + + // 自定义统计信息格式 + protected override string FormatChangeTypeCount(ChangeType changeType, int count) + { + return changeType switch + { + ChangeType.Create => $"{count} VM(s) to create", + ChangeType.Delete => $"{count} VM(s) to delete", + ChangeType.Modify => $"{count} VM(s) to modify", + _ => base.FormatChangeTypeCount(changeType, count) + }; + } + + // 静态便捷方法 + public static string FormatComputeResult(IWhatIfOperationResult result) + { + var builder = new ColoredStringBuilder(); + var formatter = new ComputeWhatIfFormatter(builder); + + formatter.FormatNoiseNotice(); + formatter.FormatLegend(result.Changes, result.PotentialChanges); + formatter.FormatResourceChanges(result.Changes, true); + formatter.FormatStats(result.Changes, true); + formatter.FormatResourceChanges(result.PotentialChanges, false); + formatter.FormatStats(result.PotentialChanges, false); + formatter.FormatDiagnostics(result.Diagnostics, result.Changes, result.PotentialChanges); + + return builder.ToString(); + } + } +} + +// 在 Cmdlet 中使用自定义格式化器 +string formattedOutput = ComputeWhatIfFormatter.FormatComputeResult(psResult); +``` + +## 项目引用 + +在您的 RP 模块的 `.csproj` 文件中添加共享库引用: + +```xml + + + netstandard2.0 + + + + + + + + + + + + + +``` + +或者使用项目引用(如果 shared 是独立项目): + +```xml + + + +``` + +## 单元测试示例 + +```csharp +// File: src/Compute/Compute.Test/WhatIfFormatterTests.cs +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; +using Xunit; + +namespace Microsoft.Azure.Commands.Compute.Test +{ + public class WhatIfFormatterTests + { + [Fact] + public void TestBasicFormatting() + { + // Arrange + var mockResult = new MockWhatIfOperationResult + { + Status = "Succeeded", + Changes = new List + { + new MockWhatIfChange + { + ChangeType = ChangeType.Create, + RelativeResourceId = "Microsoft.Compute/virtualMachines/testVM", + Scope = "/subscriptions/sub-id/resourceGroups/rg1" + } + } + }; + + // Act + string output = WhatIfOperationResultFormatter.Format(mockResult); + + // Assert + Assert.Contains("to create", output); + Assert.Contains("testVM", output); + Assert.Contains("+", output); // 创建符号 + } + } +} +``` + +## 最佳实践 + +1. **性能优化**: 使用 `Lazy` 延迟加载大型数据结构 +2. **错误处理**: 在包装 SDK 对象时捕获并适当处理异常 +3. **可测试性**: 通过接口实现使代码易于模拟和测试 +4. **文档**: 为您的 PS 类添加 XML 文档注释 +5. **向后兼容**: 如果已有 WhatIf 实现,逐步迁移,保持向后兼容 + +## 常见问题 + +**Q: 为什么使用接口而不是继承?** +A: 接口提供了更大的灵活性,允许不同 RP 模块根据各自的 SDK 模型实现,而无需共享基类的限制。 + +**Q: 我需要实现所有接口吗?** +A: 如果只使用基本的 JSON 格式化功能(WhatIfJsonFormatter),则不需要。如果要使用 WhatIfOperationResultFormatter,则需要实现相关接口。 + +**Q: 可以扩展现有的格式化器吗?** +A: 可以!所有格式化方法都是 `virtual` 或 `protected virtual` 的,您可以继承并重写它们来自定义行为。 + +**Q: 如何处理 SDK 类型不匹配?** +A: 使用适配器模式。在您的 PS 类中包装 SDK 对象,并在属性 getter 中进行必要的类型转换。 diff --git a/src/shared/WhatIf/Models/ChangeType.cs b/src/shared/WhatIf/Models/ChangeType.cs new file mode 100644 index 000000000000..35accb5313aa --- /dev/null +++ b/src/shared/WhatIf/Models/ChangeType.cs @@ -0,0 +1,57 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models +{ + /// + /// The type of change that will occur in the deployment. + /// + public enum ChangeType + { + /// + /// The resource will be created. + /// + Create, + + /// + /// The resource will be deleted. + /// + Delete, + + /// + /// The resource will be deployed without any changes. + /// + Deploy, + + /// + /// The resource will be ignored during deployment. + /// + Ignore, + + /// + /// The resource will be modified. + /// + Modify, + + /// + /// The resource will not be changed. + /// + NoChange, + + /// + /// The resource type is not supported for WhatIf. + /// + Unsupported + } +} diff --git a/src/shared/WhatIf/Models/IWhatIfChange.cs b/src/shared/WhatIf/Models/IWhatIfChange.cs new file mode 100644 index 000000000000..85763e4d4423 --- /dev/null +++ b/src/shared/WhatIf/Models/IWhatIfChange.cs @@ -0,0 +1,71 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models +{ + using System.Collections.Generic; + using Newtonsoft.Json.Linq; + + /// + /// Interface representing a WhatIf resource change. + /// Implemented by RP-specific classes to provide change information. + /// + public interface IWhatIfChange + { + /// + /// The scope of the change (e.g., subscription, resource group). + /// + string Scope { get; } + + /// + /// The relative resource ID (without scope prefix). + /// + string RelativeResourceId { get; } + + /// + /// The fully qualified resource ID. + /// + string FullyQualifiedResourceId { get; } + + /// + /// The type of change (Create, Delete, Modify, etc.). + /// + ChangeType ChangeType { get; } + + /// + /// The API version of the resource. + /// + string ApiVersion { get; } + + /// + /// Reason if the resource is unsupported. + /// + string UnsupportedReason { get; } + + /// + /// The resource state before the change. + /// + JToken Before { get; } + + /// + /// The resource state after the change. + /// + JToken After { get; } + + /// + /// The list of property changes. + /// + IList Delta { get; } + } +} diff --git a/src/shared/WhatIf/Models/IWhatIfDiagnostic.cs b/src/shared/WhatIf/Models/IWhatIfDiagnostic.cs new file mode 100644 index 000000000000..1cc0572a7ea3 --- /dev/null +++ b/src/shared/WhatIf/Models/IWhatIfDiagnostic.cs @@ -0,0 +1,48 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models +{ + /// + /// Interface representing a WhatIf diagnostic message. + /// Implemented by RP-specific classes to provide diagnostic information. + /// + public interface IWhatIfDiagnostic + { + /// + /// Diagnostic code. + /// + string Code { get; } + + /// + /// Diagnostic message. + /// + string Message { get; } + + /// + /// Diagnostic level (e.g., "Error", "Warning", "Info"). + /// + string Level { get; } + + /// + /// Target resource or component. + /// + string Target { get; } + + /// + /// Additional details. + /// + string Details { get; } + } +} diff --git a/src/shared/WhatIf/Models/IWhatIfError.cs b/src/shared/WhatIf/Models/IWhatIfError.cs new file mode 100644 index 000000000000..a3c82954cefe --- /dev/null +++ b/src/shared/WhatIf/Models/IWhatIfError.cs @@ -0,0 +1,38 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models +{ + /// + /// Interface representing a WhatIf error. + /// Implemented by RP-specific classes to provide error information. + /// + public interface IWhatIfError + { + /// + /// Error code. + /// + string Code { get; } + + /// + /// Error message. + /// + string Message { get; } + + /// + /// Error target (resource or property). + /// + string Target { get; } + } +} diff --git a/src/shared/WhatIf/Models/IWhatIfOperationResult.cs b/src/shared/WhatIf/Models/IWhatIfOperationResult.cs new file mode 100644 index 000000000000..23dffd4dd634 --- /dev/null +++ b/src/shared/WhatIf/Models/IWhatIfOperationResult.cs @@ -0,0 +1,50 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models +{ + using System.Collections.Generic; + + /// + /// Interface representing a WhatIf operation result. + /// Implemented by RP-specific classes to provide operation result information. + /// + public interface IWhatIfOperationResult + { + /// + /// The operation status. + /// + string Status { get; } + + /// + /// The list of resource changes. + /// + IList Changes { get; } + + /// + /// The list of potential resource changes (may or may not happen). + /// + IList PotentialChanges { get; } + + /// + /// The list of diagnostics. + /// + IList Diagnostics { get; } + + /// + /// Error information if the operation failed. + /// + IWhatIfError Error { get; } + } +} diff --git a/src/shared/WhatIf/Models/IWhatIfPropertyChange.cs b/src/shared/WhatIf/Models/IWhatIfPropertyChange.cs new file mode 100644 index 000000000000..85034bc16a87 --- /dev/null +++ b/src/shared/WhatIf/Models/IWhatIfPropertyChange.cs @@ -0,0 +1,51 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models +{ + using System.Collections.Generic; + using Newtonsoft.Json.Linq; + + /// + /// Interface representing a WhatIf property change. + /// Implemented by RP-specific classes to provide property change information. + /// + public interface IWhatIfPropertyChange + { + /// + /// The JSON path of the property. + /// + string Path { get; } + + /// + /// The type of property change (Create, Delete, Modify, Array, NoEffect). + /// + PropertyChangeType PropertyChangeType { get; } + + /// + /// The property value before the change. + /// + JToken Before { get; } + + /// + /// The property value after the change. + /// + JToken After { get; } + + /// + /// Child property changes (for nested objects/arrays). + /// + IList Children { get; } + } +} diff --git a/src/shared/WhatIf/Models/PSChangeType.cs b/src/shared/WhatIf/Models/PSChangeType.cs new file mode 100644 index 000000000000..8a4ac906cab0 --- /dev/null +++ b/src/shared/WhatIf/Models/PSChangeType.cs @@ -0,0 +1,62 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models +{ + /// + /// PowerShell representation of change types for display purposes. + /// + public enum PSChangeType + { + /// + /// Create change type. + /// + Create, + + /// + /// Delete change type. + /// + Delete, + + /// + /// Deploy change type. + /// + Deploy, + + /// + /// Ignore change type. + /// + Ignore, + + /// + /// Modify change type. + /// + Modify, + + /// + /// No change type. + /// + NoChange, + + /// + /// No effect change type. + /// + NoEffect, + + /// + /// Unsupported change type. + /// + Unsupported + } +} diff --git a/src/shared/WhatIf/Models/PropertyChangeType.cs b/src/shared/WhatIf/Models/PropertyChangeType.cs new file mode 100644 index 000000000000..9335ca185f11 --- /dev/null +++ b/src/shared/WhatIf/Models/PropertyChangeType.cs @@ -0,0 +1,47 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models +{ + /// + /// The type of property change. + /// + public enum PropertyChangeType + { + /// + /// The property will be created. + /// + Create, + + /// + /// The property will be deleted. + /// + Delete, + + /// + /// The property will be modified. + /// + Modify, + + /// + /// The property is an array that will be modified. + /// + Array, + + /// + /// The property change has no effect. + /// + NoEffect + } +} diff --git a/src/shared/WhatIf/QUICKSTART.md b/src/shared/WhatIf/QUICKSTART.md new file mode 100644 index 000000000000..66aa305c54db --- /dev/null +++ b/src/shared/WhatIf/QUICKSTART.md @@ -0,0 +1,141 @@ +# WhatIf 格式化器共享库 - 快速开始 + +## 简介 + +这个库提供了一套可重用的 WhatIf 格式化工具,可以被任何 Azure PowerShell RP 模块使用。 + +## 快速开始 + +### 1. 添加 using 语句 + +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; +``` + +### 2. 格式化 JSON + +```csharp +var jsonData = JObject.Parse(@"{ ""name"": ""myResource"" }"); +string output = WhatIfJsonFormatter.FormatJson(jsonData); +Console.WriteLine(output); +``` + +### 3. 使用颜色 + +```csharp +var builder = new ColoredStringBuilder(); +builder.Append("Creating: ", Color.Reset); +builder.Append("myResource", Color.Green); +builder.AppendLine(); +``` + +## 可用组件 + +| 组件 | 用途 | +|------|------| +| `Color` | ANSI 颜色定义 (Green, Orange, Purple, Blue, Gray, Red, etc.) | +| `Symbol` | 操作符号 (+, -, ~, !, =, *, x) | +| `ColoredStringBuilder` | 带颜色支持的字符串构建器 | +| `WhatIfJsonFormatter` | JSON 格式化基类 | +| `JTokenExtensions` | JSON 扩展方法 (IsLeaf, IsNonEmptyArray, etc.) | +| `DiagnosticExtensions` | 诊断级别到颜色的转换 | + +## 颜色映射 + +| 颜色 | 用途 | +|------|------| +| 🟢 Green | 创建操作 | +| 🟣 Purple | 修改操作 | +| 🟠 Orange | 删除操作 | +| 🔵 Blue | 部署操作 | +| ⚪ Gray | 无影响/忽略操作 | +| 🔴 Red | 错误 | +| 🟡 DarkYellow | 警告 | + +## 符号映射 + +| 符号 | 含义 | +|------|------| +| `+` | 创建 (Create) | +| `-` | 删除 (Delete) | +| `~` | 修改 (Modify) | +| `!` | 部署 (Deploy) | +| `=` | 无变化 (NoChange) | +| `*` | 忽略 (Ignore) | +| `x` | 不支持/无影响 (Unsupported/NoEffect) | + +## 完整示例 + +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; +using Newtonsoft.Json.Linq; + +public class MyWhatIfCommand +{ + public void ExecuteWhatIf() + { + var builder = new ColoredStringBuilder(); + + // 标题 + builder.AppendLine("Resource and property changes are indicated with these symbols:"); + builder.Append(" "); + builder.Append(Symbol.Plus, Color.Green); + builder.AppendLine(" Create"); + builder.Append(" "); + builder.Append(Symbol.Tilde, Color.Purple); + builder.AppendLine(" Modify"); + builder.AppendLine(); + + // 资源变更 + builder.AppendLine("Scope: /subscriptions/xxx/resourceGroups/myRG"); + builder.AppendLine(); + + using (builder.NewColorScope(Color.Green)) + { + builder.Append(" "); + builder.Append(Symbol.Plus); + builder.AppendLine(" Microsoft.Storage/storageAccounts/myAccount"); + + var resourceConfig = new JObject + { + ["location"] = "eastus", + ["sku"] = new JObject { ["name"] = "Standard_LRS" } + }; + + builder.AppendLine(); + var formatter = new WhatIfJsonFormatter(builder); + formatter.FormatJson(resourceConfig, indentLevel: 2); + } + + builder.AppendLine(); + builder.Append("Resource changes: "); + builder.Append("1 to create", Color.Green); + builder.AppendLine("."); + + Console.WriteLine(builder.ToString()); + } +} +``` + +## 更多信息 + +- 详细文档: `/src/shared/WhatIf/README.md` +- 使用示例: `/src/shared/WhatIf/USAGE_EXAMPLES.md` +- 原始实现: `/src/Resources/ResourceManager/Formatters/` + +## 迁移指南 + +如果你正在从 `Microsoft.Azure.Commands.ResourceManager.Cmdlets.Formatters` 迁移: + +**只需要更改 namespace!** API 保持完全兼容。 + +```diff +- using Microsoft.Azure.Commands.ResourceManager.Cmdlets.Formatters; +- using Microsoft.Azure.Commands.ResourceManager.Cmdlets.Extensions; ++ using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; ++ using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; +``` + +其他代码无需修改! diff --git a/src/shared/WhatIf/README.md b/src/shared/WhatIf/README.md new file mode 100644 index 000000000000..347c06f4f12a --- /dev/null +++ b/src/shared/WhatIf/README.md @@ -0,0 +1,323 @@ +# WhatIf Formatter Shared Library + +## 概述 + +这是一个用于格式化 Azure PowerShell WhatIf 操作结果的共享库。它提供了一套可重用的格式化器、扩展方法和比较器,可以被不同的资源提供程序(RP)模块使用。 + +## 目录结构 + +``` +src/shared/WhatIf/ +├── Formatters/ # 格式化器类 +│ ├── Color.cs # ANSI 颜色定义 +│ ├── Symbol.cs # 符号定义(+, -, ~, 等) +│ ├── ColoredStringBuilder.cs # 带颜色的字符串构建器 +│ └── WhatIfJsonFormatter.cs # JSON 格式化器基类 +├── Extensions/ # 扩展方法 +│ ├── JTokenExtensions.cs # JSON Token 扩展 +│ └── DiagnosticExtensions.cs # 诊断信息扩展 +└── README.md # 本文档 +``` + +## 核心组件 + +### 1. Formatters(格式化器) + +#### Color +定义了 ANSI 颜色代码,用于终端输出: +- `Color.Green` - 用于创建操作 +- `Color.Orange` - 用于删除操作 +- `Color.Purple` - 用于修改操作 +- `Color.Blue` - 用于部署操作 +- `Color.Gray` - 用于无影响操作 +- `Color.Red` - 用于错误 +- `Color.DarkYellow` - 用于警告 +- `Color.Reset` - 重置颜色 + +#### Symbol +定义了用于表示不同操作类型的符号: +- `Symbol.Plus` (+) - 创建 +- `Symbol.Minus` (-) - 删除 +- `Symbol.Tilde` (~) - 修改 +- `Symbol.ExclamationPoint` (!) - 部署 +- `Symbol.Equal` (=) - 无变化 +- `Symbol.Asterisk` (*) - 忽略 +- `Symbol.Cross` (x) - 不支持/无影响 + +#### ColoredStringBuilder +一个支持 ANSI 颜色代码的字符串构建器。提供: +- 基本的字符串追加操作 +- 带颜色的文本追加 +- 颜色作用域管理(使用 `using` 语句) + +示例: +```csharp +var builder = new ColoredStringBuilder(); +builder.Append("Creating resource: ", Color.Reset); +builder.Append("resourceName", Color.Green); +builder.AppendLine(); + +// 使用颜色作用域 +using (builder.NewColorScope(Color.Blue)) +{ + builder.Append("Deploying..."); +} +``` + +#### WhatIfJsonFormatter +格式化 JSON 数据为带颜色的、易读的输出。主要功能: +- 自动缩进 +- 路径对齐 +- 支持嵌套对象和数组 +- 叶子节点的类型感知格式化 + +### 2. Extensions(扩展方法) + +#### JTokenExtensions +Newtonsoft.Json JToken 的扩展方法: +- `IsLeaf()` - 检查是否为叶子节点 +- `IsNonEmptyArray()` - 检查是否为非空数组 +- `IsNonEmptyObject()` - 检查是否为非空对象 +- `ToPsObject()` - 转换为 PowerShell 对象 +- `ConvertPropertyValueForPsObject()` - 转换属性值 + +#### DiagnosticExtensions +诊断信息的扩展方法: +- `ToColor(this string level)` - 将诊断级别(Error/Warning/Info)转换为颜色 +- `Level` 静态类 - 提供标准诊断级别常量 + +## 使用方法 + +### 基本 JSON 格式化 + +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Newtonsoft.Json.Linq; + +var jsonData = JObject.Parse(@"{ + ""name"": ""myResource"", + ""location"": ""eastus"", + ""properties"": { + ""enabled"": true + } +}"); + +string formattedOutput = WhatIfJsonFormatter.FormatJson(jsonData); +Console.WriteLine(formattedOutput); +``` + +### 使用 ColoredStringBuilder + +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; + +var builder = new ColoredStringBuilder(); + +builder.Append("Resource changes: "); +builder.Append("3 to create", Color.Green); +builder.Append(", "); +builder.Append("1 to modify", Color.Purple); +builder.AppendLine(); + +string output = builder.ToString(); +``` + +### 在自定义 Formatter 中使用 + +如果您需要创建自定义的 WhatIf formatter: + +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; + +public class MyCustomFormatter : WhatIfJsonFormatter +{ + public MyCustomFormatter(ColoredStringBuilder builder) : base(builder) + { + } + + public void FormatMyData(MyDataType data) + { + using (this.Builder.NewColorScope(Color.Blue)) + { + this.Builder.AppendLine("Custom formatting:"); + // 使用基类的 FormatJson 方法 + this.FormatJson(data.JsonContent); + } + } +} +``` + +## 扩展这个库 + +### 为您的 RP 添加支持 + +如果您想在您的 RP 模块中使用这个库: + +1. **添加项目引用**(如果需要)或确保文件包含在编译中 + +2. **添加 using 语句**: +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; +``` + +3. **实现您的格式化器**: +```csharp +public class MyRPWhatIfFormatter : WhatIfJsonFormatter +{ + // 添加 RP 特定的格式化逻辑 +} +``` + +### 添加新的扩展 + +如果需要添加新的扩展方法: + +1. 在 `Extensions` 目录下创建新文件 +2. 使用命名空间 `Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions` +3. 创建静态扩展类 +4. 更新此 README + +## 依赖项 + +- Newtonsoft.Json - JSON 处理 +- System.Management.Automation - PowerShell 对象支持 + +## 迁移指南 + +### 从 Resources 模块迁移 + +如果您正在从 `Microsoft.Azure.Commands.ResourceManager.Cmdlets.Formatters` 迁移: + +**旧代码**: +```csharp +using Microsoft.Azure.Commands.ResourceManager.Cmdlets.Formatters; +using Microsoft.Azure.Commands.ResourceManager.Cmdlets.Extensions; + +var builder = new ColoredStringBuilder(); +string output = WhatIfJsonFormatter.FormatJson(jsonData); +``` + +**新代码**: +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; + +var builder = new ColoredStringBuilder(); +string output = WhatIfJsonFormatter.FormatJson(jsonData); +``` + +主要变化: +- Namespace 从 `Microsoft.Azure.Commands.ResourceManager.Cmdlets` 改为 `Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf` +- API 保持不变,只需要更新 using 语句 + +## 贡献 + +如果您需要添加新功能或修复 bug: + +1. 确保更改不会破坏现有 API +2. 添加适当的 XML 文档注释 +3. 更新此 README +4. 考虑向后兼容性 + +## 使用接口实现 + +该库提供了一组接口,允许不同的 RP 模块实现自己的 WhatIf 模型,同时使用共享的格式化逻辑。 + +### 接口定义 + +- `IWhatIfOperationResult` - WhatIf 操作结果 +- `IWhatIfChange` - 资源变更 +- `IWhatIfPropertyChange` - 属性变更 +- `IWhatIfDiagnostic` - 诊断信息 +- `IWhatIfError` - 错误信息 + +### 实现示例 + +```csharp +// 1. 实现接口(在您的 RP 模块中) +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; +using Microsoft.Azure.Management.YourService.Models; + +public class PSWhatIfChange : IWhatIfChange +{ + private readonly WhatIfChange sdkChange; + + public PSWhatIfChange(WhatIfChange sdkChange) + { + this.sdkChange = sdkChange; + // 初始化属性... + } + + public string Scope { get; set; } + public string RelativeResourceId { get; set; } + public string FullyQualifiedResourceId => sdkChange.ResourceId; + public ChangeType ChangeType => sdkChange.ChangeType; + public string ApiVersion { get; set; } + public string UnsupportedReason { get; set; } + public JToken Before { get; set; } + public JToken After { get; set; } + public IList Delta { get; set; } +} + +public class PSWhatIfOperationResult : IWhatIfOperationResult +{ + public string Status { get; set; } + public IList Changes { get; set; } + public IList PotentialChanges { get; set; } + public IList Diagnostics { get; set; } + public IWhatIfError Error { get; set; } +} + +// 2. 使用格式化器 +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; + +IWhatIfOperationResult result = GetWhatIfResult(); // 您的实现 +string formattedOutput = WhatIfOperationResultFormatter.Format(result); +Console.WriteLine(formattedOutput); +``` + +### 自定义格式化 + +您也可以继承 `WhatIfOperationResultFormatter` 来自定义格式化行为: + +```csharp +public class CustomWhatIfFormatter : WhatIfOperationResultFormatter +{ + public CustomWhatIfFormatter(ColoredStringBuilder builder) : base(builder) + { + } + + // 重写方法来自定义行为 + protected override void FormatNoiseNotice(string noiseNotice = null) + { + // 自定义提示信息 + this.Builder.AppendLine("自定义提示: 这是预测结果,仅供参考。").AppendLine(); + } + + protected override string FormatChangeTypeCount(ChangeType changeType, int count) + { + // 自定义统计信息格式 + return changeType switch + { + ChangeType.Create => $"{count} 个资源将被创建", + ChangeType.Delete => $"{count} 个资源将被删除", + _ => base.FormatChangeTypeCount(changeType, count) + }; + } +} +``` + +## 版本历史 + +- **1.0.0** (2025-01) - 初始版本,从 Resources 模块提取 + - 核心格式化器(Color, Symbol, ColoredStringBuilder) + - WhatIfJsonFormatter 基类 + - JTokenExtensions 和 DiagnosticExtensions + - WhatIfOperationResultFormatter 完整实现 + - 模型接口(IWhatIfOperationResult, IWhatIfChange, IWhatIfPropertyChange, etc.) + - 枚举类型(ChangeType, PropertyChangeType, PSChangeType) + - 比较器(ChangeTypeComparer, PropertyChangeTypeComparer, PSChangeTypeComparer) + - 类型扩展(ChangeTypeExtensions, PropertyChangeTypeExtensions, PSChangeTypeExtensions) + diff --git a/src/shared/WhatIf/USAGE_EXAMPLES.md b/src/shared/WhatIf/USAGE_EXAMPLES.md new file mode 100644 index 000000000000..41c99745907d --- /dev/null +++ b/src/shared/WhatIf/USAGE_EXAMPLES.md @@ -0,0 +1,463 @@ +# WhatIf 共享库使用示例 + +这个文件包含了如何在不同场景下使用 WhatIf 共享库的示例代码。 + +## 示例 1: 基本 JSON 格式化 + +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; +using Newtonsoft.Json.Linq; + +public class Example1_BasicJsonFormatting +{ + public static void Run() + { + // 创建一些示例 JSON 数据 + var jsonData = new JObject + { + ["name"] = "myStorageAccount", + ["location"] = "eastus", + ["sku"] = new JObject + { + ["name"] = "Standard_LRS" + }, + ["properties"] = new JObject + { + ["supportsHttpsTrafficOnly"] = true, + ["encryption"] = new JObject + { + ["services"] = new JObject + { + ["blob"] = new JObject { ["enabled"] = true }, + ["file"] = new JObject { ["enabled"] = true } + } + } + } + }; + + // 使用静态方法格式化 + string formattedOutput = WhatIfJsonFormatter.FormatJson(jsonData); + + // 输出到控制台(会显示颜色) + Console.WriteLine(formattedOutput); + } +} +``` + +## 示例 2: 使用 ColoredStringBuilder + +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; + +public class Example2_ColoredStringBuilder +{ + public static void FormatResourceChanges(int createCount, int modifyCount, int deleteCount) + { + var builder = new ColoredStringBuilder(); + + // 标题 + builder.AppendLine("Resource changes:"); + builder.AppendLine(); + + // 创建操作 + if (createCount > 0) + { + builder.Append(" "); + builder.Append(Symbol.Plus, Color.Green); + builder.Append(" ", Color.Reset); + builder.Append($"{createCount} to create", Color.Green); + builder.AppendLine(); + } + + // 修改操作 + if (modifyCount > 0) + { + builder.Append(" "); + builder.Append(Symbol.Tilde, Color.Purple); + builder.Append(" ", Color.Reset); + builder.Append($"{modifyCount} to modify", Color.Purple); + builder.AppendLine(); + } + + // 删除操作 + if (deleteCount > 0) + { + builder.Append(" "); + builder.Append(Symbol.Minus, Color.Orange); + builder.Append(" ", Color.Reset); + builder.Append($"{deleteCount} to delete", Color.Orange); + builder.AppendLine(); + } + + Console.WriteLine(builder.ToString()); + } +} +``` + +## 示例 3: 使用颜色作用域 + +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; + +public class Example3_ColorScopes +{ + public static void FormatHierarchicalData() + { + var builder = new ColoredStringBuilder(); + + builder.AppendLine("Deployment scope: /subscriptions/xxx/resourceGroups/myRG"); + builder.AppendLine(); + + // 使用 using 语句自动管理颜色作用域 + using (builder.NewColorScope(Color.Green)) + { + builder.Append(" "); + builder.Append(Symbol.Plus); + builder.Append(" Microsoft.Storage/storageAccounts/myAccount"); + builder.AppendLine(); + + // 嵌套作用域 + using (builder.NewColorScope(Color.Reset)) + { + builder.AppendLine(" location: eastus"); + builder.AppendLine(" sku.name: Standard_LRS"); + } + } + + builder.AppendLine(); + + using (builder.NewColorScope(Color.Purple)) + { + builder.Append(" "); + builder.Append(Symbol.Tilde); + builder.Append(" Microsoft.Network/virtualNetworks/myVnet"); + builder.AppendLine(); + } + + Console.WriteLine(builder.ToString()); + } +} +``` + +## 示例 4: 自定义 Formatter 类 + +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; +using Newtonsoft.Json.Linq; + +public class MyResourceWhatIfFormatter : WhatIfJsonFormatter +{ + public MyResourceWhatIfFormatter(ColoredStringBuilder builder) : base(builder) + { + } + + public string FormatResourceChange( + string changeType, + string resourceId, + JObject beforeState, + JObject afterState) + { + // 根据变更类型选择颜色 + Color changeColor = changeType switch + { + "Create" => Color.Green, + "Modify" => Color.Purple, + "Delete" => Color.Orange, + _ => Color.Reset + }; + + Symbol changeSymbol = changeType switch + { + "Create" => Symbol.Plus, + "Modify" => Symbol.Tilde, + "Delete" => Symbol.Minus, + _ => Symbol.Equal + }; + + using (this.Builder.NewColorScope(changeColor)) + { + // 格式化资源标题 + this.Builder.Append(" "); + this.Builder.Append(changeSymbol); + this.Builder.Append(" "); + this.Builder.Append(resourceId); + this.Builder.AppendLine(); + this.Builder.AppendLine(); + + // 格式化 before/after 状态 + if (changeType == "Modify" && beforeState != null && afterState != null) + { + this.Builder.AppendLine(" Before:"); + this.FormatJson(beforeState, indentLevel: 3); + + this.Builder.AppendLine(); + this.Builder.AppendLine(" After:"); + this.FormatJson(afterState, indentLevel: 3); + } + else if (changeType == "Create" && afterState != null) + { + this.FormatJson(afterState, indentLevel: 2); + } + else if (changeType == "Delete" && beforeState != null) + { + this.FormatJson(beforeState, indentLevel: 2); + } + } + + return this.Builder.ToString(); + } +} + +// 使用示例 +public class Example4_CustomFormatter +{ + public static void Run() + { + var builder = new ColoredStringBuilder(); + var formatter = new MyResourceWhatIfFormatter(builder); + + var afterState = new JObject + { + ["name"] = "myResource", + ["location"] = "eastus", + ["properties"] = new JObject + { + ["enabled"] = true + } + }; + + string output = formatter.FormatResourceChange( + "Create", + "/subscriptions/xxx/resourceGroups/rg/providers/Microsoft.MyService/resources/myResource", + null, + afterState); + + Console.WriteLine(output); + } +} +``` + +## 示例 5: 格式化诊断信息 + +```csharp +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; +using static Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions.DiagnosticExtensions; + +public class Example5_Diagnostics +{ + public class DiagnosticMessage + { + public string Level { get; set; } + public string Code { get; set; } + public string Message { get; set; } + public string Target { get; set; } + } + + public static void FormatDiagnostics(List diagnostics) + { + var builder = new ColoredStringBuilder(); + + builder.AppendLine($"Diagnostics ({diagnostics.Count}):"); + builder.AppendLine(); + + foreach (var diagnostic in diagnostics) + { + // 使用扩展方法将级别转换为颜色 + Color levelColor = diagnostic.Level.ToColor(); + + using (builder.NewColorScope(levelColor)) + { + builder.Append($" [{diagnostic.Level}] "); + builder.Append($"{diagnostic.Code}: "); + builder.Append(diagnostic.Message); + + if (!string.IsNullOrEmpty(diagnostic.Target)) + { + builder.Append($" (Target: {diagnostic.Target})"); + } + + builder.AppendLine(); + } + } + + Console.WriteLine(builder.ToString()); + } + + public static void Run() + { + var diagnostics = new List + { + new DiagnosticMessage + { + Level = Level.Warning, + Code = "W001", + Message = "Resource will be created in a different region than the resource group", + Target = "/subscriptions/xxx/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/sa" + }, + new DiagnosticMessage + { + Level = Level.Error, + Code = "E001", + Message = "Invalid SKU specified for this region", + Target = "/subscriptions/xxx/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm" + }, + new DiagnosticMessage + { + Level = Level.Info, + Code = "I001", + Message = "Using default value for unspecified property", + Target = null + } + }; + + FormatDiagnostics(diagnostics); + } +} +``` + +## 示例 6: 从 Resources 模块迁移 + +### 迁移前的代码 (Resources 模块) + +```csharp +// 旧的 namespace +using Microsoft.Azure.Commands.ResourceManager.Cmdlets.Formatters; +using Microsoft.Azure.Commands.ResourceManager.Cmdlets.Extensions; + +public class OldResourcesCode +{ + public void Format() + { + var builder = new ColoredStringBuilder(); + builder.Append("Creating ", Color.Reset); + builder.Append("resource", Color.Green); + + // 使用 JTokenExtensions + var json = JObject.Parse("..."); + if (json.IsNonEmptyObject()) + { + // ... + } + } +} +``` + +### 迁移后的代码 (使用共享库) + +```csharp +// 新的 namespace +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; + +public class NewSharedLibraryCode +{ + public void Format() + { + // API 完全相同,只需要更改 using 语句 + var builder = new ColoredStringBuilder(); + builder.Append("Creating ", Color.Reset); + builder.Append("resource", Color.Green); + + // 使用 JTokenExtensions + var json = JObject.Parse("..."); + if (json.IsNonEmptyObject()) + { + // ... + } + } +} +``` + +## 示例 7: 在其他 RP 模块中使用 + +```csharp +// 例如在 Compute 模块中 +namespace Microsoft.Azure.Commands.Compute.Whatif +{ + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; + using Newtonsoft.Json.Linq; + + public class ComputeWhatIfFormatter : WhatIfJsonFormatter + { + public ComputeWhatIfFormatter(ColoredStringBuilder builder) : base(builder) + { + } + + public void FormatVMChange(string vmName, string changeType, JObject vmConfig) + { + Color color = changeType switch + { + "Create" => Color.Green, + "Modify" => Color.Purple, + "Delete" => Color.Orange, + _ => Color.Reset + }; + + using (this.Builder.NewColorScope(color)) + { + this.Builder.AppendLine($"Virtual Machine: {vmName}"); + this.Builder.AppendLine($"Change Type: {changeType}"); + this.Builder.AppendLine(); + + if (vmConfig != null) + { + this.FormatJson(vmConfig, indentLevel: 1); + } + } + } + } +} + +// 在 Storage 模块中类似使用 +namespace Microsoft.Azure.Commands.Storage.Whatif +{ + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; + using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; + + public class StorageWhatIfFormatter : WhatIfJsonFormatter + { + public StorageWhatIfFormatter(ColoredStringBuilder builder) : base(builder) + { + } + + // 添加 Storage 特定的格式化逻辑 + } +} +``` + +## 编译和引用 + +确保您的项目文件(.csproj)包含对共享代码的引用。如果文件在同一个解决方案中,它们应该自动包含。 + +如果需要显式引用,可以使用: + +```xml + + + +``` + +或者如果使用项目引用: + +```xml + + + +``` + +## 注意事项 + +1. **颜色支持**: ANSI 颜色代码在大多数现代终端中工作,但在某些环境(如旧版 Windows CMD)中可能不显示 +2. **性能**: ColoredStringBuilder 在内部使用 StringBuilder,对大量文本操作是高效的 +3. **线程安全**: 这些类不是线程安全的,应在单线程上下文中使用 +4. **内存**: 对于非常大的 JSON 对象,考虑分段格式化以节省内存 + +## 更多资源 + +- 查看 `/src/shared/WhatIf/README.md` 了解库的详细文档 +- 查看 Resources 模块中的现有使用示例 +- 参考单元测试了解更多用法场景 diff --git a/src/shared/WhatIf/Utilities/ResourceIdUtility.cs b/src/shared/WhatIf/Utilities/ResourceIdUtility.cs new file mode 100644 index 000000000000..e552ef95cc65 --- /dev/null +++ b/src/shared/WhatIf/Utilities/ResourceIdUtility.cs @@ -0,0 +1,146 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Utilities +{ + using System; + using System.Collections.Generic; + + /// + /// Utility class for splitting Azure resource IDs into scope and relative resource ID. + /// + public static class ResourceIdUtility + { + private static readonly string[] ScopePrefixes = new[] + { + "/subscriptions/", + "/providers/Microsoft.Management/managementGroups/", + "/tenants/" + }; + + /// + /// Splits a fully qualified resource ID into scope and relative resource ID. + /// + /// The fully qualified resource ID. + /// A tuple of (scope, relativeResourceId). + /// + /// Input: "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm" + /// Output: ("/subscriptions/sub-id/resourceGroups/rg", "Microsoft.Compute/virtualMachines/vm") + /// + public static (string scope, string relativeResourceId) SplitResourceId(string resourceId) + { + if (string.IsNullOrWhiteSpace(resourceId)) + { + return (string.Empty, string.Empty); + } + + // Find the scope prefix + string scopePrefix = null; + int scopePrefixIndex = -1; + + foreach (var prefix in ScopePrefixes) + { + int index = resourceId.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); + if (index == 0) + { + scopePrefix = prefix; + scopePrefixIndex = index; + break; + } + } + + if (scopePrefixIndex == -1) + { + // No recognized scope prefix, return the whole ID as relative + return (string.Empty, resourceId); + } + + // Find the "/providers/" segment after the scope + int providersIndex = resourceId.IndexOf("/providers/", scopePrefixIndex + scopePrefix.Length, StringComparison.OrdinalIgnoreCase); + + if (providersIndex == -1) + { + // No providers segment, the whole thing is the scope + return (resourceId, string.Empty); + } + + string scope = resourceId.Substring(0, providersIndex); + string relativeResourceId = resourceId.Substring(providersIndex + "/providers/".Length); + + return (scope, relativeResourceId); + } + + /// + /// Extracts the scope from a resource ID. + /// + public static string GetScope(string resourceId) + { + return SplitResourceId(resourceId).scope; + } + + /// + /// Extracts the relative resource ID from a resource ID. + /// + public static string GetRelativeResourceId(string resourceId) + { + return SplitResourceId(resourceId).relativeResourceId; + } + + /// + /// Gets the resource group name from a resource ID. + /// + public static string GetResourceGroupName(string resourceId) + { + if (string.IsNullOrWhiteSpace(resourceId)) + { + return null; + } + + var parts = resourceId.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + + for (int i = 0; i < parts.Length - 1; i++) + { + if (parts[i].Equals("resourceGroups", StringComparison.OrdinalIgnoreCase)) + { + return parts[i + 1]; + } + } + + return null; + } + + /// + /// Gets the subscription ID from a resource ID. + /// + public static string GetSubscriptionId(string resourceId) + { + if (string.IsNullOrWhiteSpace(resourceId)) + { + return null; + } + + var parts = resourceId.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + + for (int i = 0; i < parts.Length - 1; i++) + { + if (parts[i].Equals("subscriptions", StringComparison.OrdinalIgnoreCase)) + { + return parts[i + 1]; + } + } + + return null; + } + } +} From 14715688343332a3e83342912accacff84eeed2e Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Fri, 24 Oct 2025 12:09:37 +1100 Subject: [PATCH 04/13] fix what if formatter and set new time out limit --- .../Compute/Common/ComputeClientBaseCmdlet.cs | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs index a21cd988a2c4..b98b20cbcf11 100644 --- a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs +++ b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs @@ -47,7 +47,24 @@ public abstract class ComputeClientBaseCmdlet : AzureRMCmdlet private ComputeClient computeClient; // Reusable static HttpClient for DryRun posts - private static readonly HttpClient _dryRunHttpClient = new HttpClient(); + private static readonly HttpClient _dryRunHttpClient = CreateDryRunHttpClient(); + + private static HttpClient CreateDryRunHttpClient() + { + int timeoutSeconds = 300; // Default 5 minutes + + // Allow override via environment variable + string timeoutEnv = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_TIMEOUT_SECONDS"); + if (!string.IsNullOrWhiteSpace(timeoutEnv) && int.TryParse(timeoutEnv, out int customTimeout) && customTimeout > 0) + { + timeoutSeconds = customTimeout; + } + + return new HttpClient() + { + Timeout = TimeSpan.FromSeconds(timeoutSeconds) + }; + } [Parameter(Mandatory = false, HelpMessage = "Send the invoked PowerShell command (ps_script) and subscription id to a remote endpoint without executing the real operation.")] public SwitchParameter DryRun { get; set; } @@ -192,17 +209,30 @@ private IWhatIfOperationResult TryAdaptDryRunToWhatIf(object dryRunResult) return null; } + // Check if the response has a 'what_if_result' wrapper + JObject whatIfData = jObj; + if (jObj["what_if_result"] != null) + { + // Extract the nested what_if_result object + whatIfData = jObj["what_if_result"] as JObject; + if (whatIfData == null) + { + return null; + } + } + // Check if it has a 'changes' or 'resourceChanges' field - var changesToken = jObj["changes"] ?? jObj["resourceChanges"]; + var changesToken = whatIfData["changes"] ?? whatIfData["resourceChanges"]; if (changesToken == null) { return null; } - return new DryRunWhatIfResult(jObj); + return new DryRunWhatIfResult(whatIfData); } - catch + catch (Exception ex) { + WriteVerbose($"Failed to adapt DryRun result to WhatIf format: {ex.Message}"); return null; } } @@ -545,8 +575,7 @@ private static IList ParseChanges(JToken changesToken) } return changesToken - .Select(c => new DryRunWhatIfChange(c as JObject)) - .Cast + .Select(c => (IWhatIfChange)new DryRunWhatIfChange(c as JObject)) .ToList(); } @@ -558,8 +587,7 @@ private static IList ParseDiagnostics(JToken diagnosticsToken } return diagnosticsToken - .Select(d => new DryRunWhatIfDiagnostic(d as JObject)) - .Cast() + .Select(d => (IWhatIfDiagnostic)new DryRunWhatIfDiagnostic(d as JObject)) .ToList(); } @@ -654,8 +682,7 @@ private static IList ParsePropertyChanges(JToken deltaTok } return deltaToken - .Select(pc => new DryRunWhatIfPropertyChange(pc as JObject)) - .Cast() + .Select(pc => (IWhatIfPropertyChange)new DryRunWhatIfPropertyChange(pc as JObject)) .ToList(); } } @@ -703,8 +730,7 @@ private static IList ParseChildren(JToken childrenToken) } return childrenToken - .Select(c => new DryRunWhatIfPropertyChange(c as JObject)) - .Cast() + .Select(c => (IWhatIfPropertyChange)new DryRunWhatIfPropertyChange(c as JObject)) .ToList(); } } From 4bbeb845b46f9024bb2c30a7f2895dcfc8ddf086 Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Wed, 5 Nov 2025 20:53:30 +0800 Subject: [PATCH 05/13] support update-azvm and -exporttotemplatepath --- .../Compute/Common/ComputeClientBaseCmdlet.cs | 114 +++++++++++++++--- .../Operation/UpdateAzureVMCommand.cs | 26 +++- 2 files changed, 122 insertions(+), 18 deletions(-) diff --git a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs index b98b20cbcf11..b5c01b2a477e 100644 --- a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs +++ b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs @@ -69,6 +69,9 @@ private static HttpClient CreateDryRunHttpClient() [Parameter(Mandatory = false, HelpMessage = "Send the invoked PowerShell command (ps_script) and subscription id to a remote endpoint without executing the real operation.")] public SwitchParameter DryRun { get; set; } + [Parameter(Mandatory = false, HelpMessage = "When used together with -DryRun, exports generated Bicep template to the specified file or directory path.")] + public string ExportTemplateToPath { get; set; } + public ComputeClient ComputeClient { get @@ -90,6 +93,16 @@ public override void ExecuteCmdlet() { StartTime = DateTime.Now; + // Validate ExportTemplateToPath usage + if (MyInvocation?.BoundParameters?.ContainsKey("ExportTemplateToPath") == true && !DryRun.IsPresent) + { + ThrowTerminatingError(new ErrorRecord( + new ArgumentException("-ExportTemplateToPath must be used together with -DryRun"), + "ExportTemplateRequiresDryRun", + ErrorCategory.InvalidArgument, + null)); + } + // Intercept early if DryRun requested if (DryRun.IsPresent && TryHandleDryRun()) { @@ -99,57 +112,127 @@ public override void ExecuteCmdlet() } /// - /// Handles DryRun processing: capture command text and subscription id and POST to endpoint. + /// Handles DryRun processing: optionally prepend a custom PowerShell script segment (e.g. flattened object definition) + /// before the original invocation line. Captures subscription id and posts to remote endpoint. + /// If -ExportTemplateToPath is provided, sets export_bicep=True in payload and writes the returned bicep template + /// (bicep_template.main_template) to the target path (file or directory). /// Returns true if DryRun was processed (and normal execution should stop). /// - protected virtual bool TryHandleDryRun() + /// Optional PowerShell script lines to prepend before the actual invocation. + protected virtual bool TryHandleDryRun(string prePsInvocationScript = null) { try { - string psScript = this.MyInvocation?.Line ?? this.MyInvocation?.InvocationName ?? string.Empty; + string invocationLine = this.MyInvocation?.Line ?? this.MyInvocation?.InvocationName ?? string.Empty; + string psScript = string.IsNullOrWhiteSpace(prePsInvocationScript) + ? invocationLine + : prePsInvocationScript + "\n" + invocationLine; string subscriptionId = this.DefaultContext?.Subscription?.Id ?? DefaultProfile.DefaultContext?.Subscription?.Id ?? string.Empty; + bool exportBicep = MyInvocation?.BoundParameters?.ContainsKey("ExportTemplateToPath") == true; + var payload = new { ps_script = psScript, subscription_id = subscriptionId, timestamp_utc = DateTime.UtcNow.ToString("o"), - source = "Az.Compute.DryRun" + source = "Az.Compute.DryRun", + export_bicep = exportBicep ? "True" : "False" }; - // Endpoint + token provided via environment variables to avoid changing all cmdlet signatures string endpoint = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_ENDPOINT"); if (string.IsNullOrWhiteSpace(endpoint)) { - // Default local endpoint (e.g., local Azure Function) if not provided via environment variable - endpoint = "http://localhost:7071/api/what_if_ps_preview"; + endpoint = "https://azcli-script-insight.azurewebsites.net/api/what_if_ps_preview"; } - // Acquire token via Azure Identity (DefaultAzureCredential). Optional scope override via AZURE_POWERSHELL_DRYRUN_SCOPE string token = GetDryRunAuthToken(); - // endpoint is always non-empty now (falls back to local default) - var dryRunResult = PostDryRun(endpoint, token, payload); + JToken resultToken = null; + try + { + if (dryRunResult is JToken jt) + { + resultToken = jt; + } + else if (dryRunResult is string s && !string.IsNullOrWhiteSpace(s)) + { + resultToken = JToken.Parse(s); + } + else + { + resultToken = dryRunResult != null ? JToken.FromObject(dryRunResult) : null; + } + } + catch { /* ignore parse errors */ } + + // If export requested, attempt to extract and persist bicep template + if (exportBicep && resultToken != null) + { + var bicepContent = resultToken.SelectToken("bicep_template.main_template")?.Value(); + if (!string.IsNullOrWhiteSpace(bicepContent)) + { + try + { + string targetPathInput = ExportTemplateToPath; + string resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(targetPathInput); + string filePath; + if (System.IO.Directory.Exists(resolvedPath) || resolvedPath.EndsWith(System.IO.Path.DirectorySeparatorChar.ToString()) || resolvedPath.EndsWith("/")) + { + // Treat as directory + if (!System.IO.Directory.Exists(resolvedPath)) + { + System.IO.Directory.CreateDirectory(resolvedPath); + } + filePath = System.IO.Path.Combine(resolvedPath, "export.bicep"); + } + else + { + // Treat as file path + string dir = System.IO.Path.GetDirectoryName(resolvedPath); + if (!string.IsNullOrWhiteSpace(dir) && !System.IO.Directory.Exists(dir)) + { + System.IO.Directory.CreateDirectory(dir); + } + filePath = resolvedPath; + // Ensure extension + if (System.IO.Path.GetExtension(filePath).Equals(string.Empty, StringComparison.OrdinalIgnoreCase)) + { + filePath += ".bicep"; + } + } + + System.IO.File.WriteAllText(filePath, bicepContent, Encoding.UTF8); + WriteVerbose($"Bicep template exported to: {filePath}"); + WriteInformation($"Bicep template exported to: {filePath}", new string[] { "PSHOST" }); + } + catch (Exception ex) + { + WriteWarning($"Failed to write Bicep template: {ex.Message}"); + } + } + else + { + WriteWarning("bicep_template.main_template not found in DryRun response"); + } + } + if (dryRunResult != null) { - // Try to format using the shared WhatIf formatter try { - // Try to parse as WhatIf result and format it var whatIfResult = TryAdaptDryRunToWhatIf(dryRunResult); if (whatIfResult != null) { WriteVerbose("========== DryRun Response (Formatted) =========="); string formattedOutput = WhatIfOperationResultFormatter.Format( whatIfResult, - noiseNotice: "Note: DryRun preview - actual deployment behavior may differ." - ); + noiseNotice: "Note: DryRun preview - actual deployment behavior may differ."); WriteObject(formattedOutput); WriteVerbose("================================================="); } else { - // Fallback: display as JSON WriteVerbose("========== DryRun Response =========="); string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); WriteObject(formattedJson); @@ -159,7 +242,6 @@ protected virtual bool TryHandleDryRun() catch (Exception formatEx) { WriteVerbose($"DryRun formatting failed: {formatEx.Message}"); - // Fallback: just output the raw result WriteVerbose("========== DryRun Response =========="); try { diff --git a/src/Compute/Compute/VirtualMachine/Operation/UpdateAzureVMCommand.cs b/src/Compute/Compute/VirtualMachine/Operation/UpdateAzureVMCommand.cs index 8731fab6d02c..b5919f8d1210 100644 --- a/src/Compute/Compute/VirtualMachine/Operation/UpdateAzureVMCommand.cs +++ b/src/Compute/Compute/VirtualMachine/Operation/UpdateAzureVMCommand.cs @@ -203,6 +203,29 @@ public class UpdateAzureVMCommand : VirtualMachineBaseCmdlet public override void ExecuteCmdlet() { + // If DryRun requested, build flattened PS script including VM object assignment + if (DryRun.IsPresent) + { + try + { + var vmJson = Newtonsoft.Json.JsonConvert.SerializeObject(this.VM, Newtonsoft.Json.Formatting.Indented); + string escapedJson = vmJson.Replace("`", "``"); + string vmAssign = "$vm = ConvertFrom-Json @'\n" + escapedJson + "\n'@"; + if (TryHandleDryRun(vmAssign)) + { + return; + } + } + catch (Exception ex) + { + WriteVerbose($"DryRun VM flatten failed: {ex.Message}. Falling back to default invocation capture."); + if (TryHandleDryRun()) + { + return; + } + } + } + if (this.IsParameterBound(c => c.UserData)) { if (!ValidateBase64EncodedString.ValidateStringIsBase64Encoded(this.UserData)) @@ -223,7 +246,6 @@ public override void ExecuteCmdlet() { ExecuteClientAction(() => { - var parameters = new VirtualMachine { DiagnosticsProfile = this.VM.DiagnosticsProfile, @@ -346,7 +368,7 @@ public override void ExecuteCmdlet() parameters.SecurityProfile.EncryptionAtHost = this.EncryptionAtHost; } - if (this.IsParameterBound( c => c.SecurityType)) + if (this.IsParameterBound(c => c.SecurityType)) { if (parameters.SecurityProfile == null) { From 4a4a51b0755c5a1b9a329423006b92655775d89a Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Fri, 7 Nov 2025 16:16:59 +0800 Subject: [PATCH 06/13] support -ExportBicep in Az.Compute --- .../Compute/Common/ComputeClientBaseCmdlet.cs | 111 ++++++++++++------ 1 file changed, 77 insertions(+), 34 deletions(-) diff --git a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs index b5c01b2a477e..a886b9816df0 100644 --- a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs +++ b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs @@ -69,8 +69,8 @@ private static HttpClient CreateDryRunHttpClient() [Parameter(Mandatory = false, HelpMessage = "Send the invoked PowerShell command (ps_script) and subscription id to a remote endpoint without executing the real operation.")] public SwitchParameter DryRun { get; set; } - [Parameter(Mandatory = false, HelpMessage = "When used together with -DryRun, exports generated Bicep template to the specified file or directory path.")] - public string ExportTemplateToPath { get; set; } + [Parameter(Mandatory = false, HelpMessage = "When used together with -DryRun, export generated Bicep templates to the user's .azure directory.")] + public SwitchParameter ExportBicep { get; set; } public ComputeClient ComputeClient { @@ -93,12 +93,12 @@ public override void ExecuteCmdlet() { StartTime = DateTime.Now; - // Validate ExportTemplateToPath usage - if (MyInvocation?.BoundParameters?.ContainsKey("ExportTemplateToPath") == true && !DryRun.IsPresent) + // Validate ExportBicep usage + if (ExportBicep.IsPresent && !DryRun.IsPresent) { ThrowTerminatingError(new ErrorRecord( - new ArgumentException("-ExportTemplateToPath must be used together with -DryRun"), - "ExportTemplateRequiresDryRun", + new ArgumentException("-ExportBicep must be used together with -DryRun"), + "ExportBicepRequiresDryRun", ErrorCategory.InvalidArgument, null)); } @@ -129,7 +129,7 @@ protected virtual bool TryHandleDryRun(string prePsInvocationScript = null) : prePsInvocationScript + "\n" + invocationLine; string subscriptionId = this.DefaultContext?.Subscription?.Id ?? DefaultProfile.DefaultContext?.Subscription?.Id ?? string.Empty; - bool exportBicep = MyInvocation?.BoundParameters?.ContainsKey("ExportTemplateToPath") == true; + bool exportBicep = ExportBicep.IsPresent; var payload = new { @@ -166,54 +166,97 @@ protected virtual bool TryHandleDryRun(string prePsInvocationScript = null) } catch { /* ignore parse errors */ } - // If export requested, attempt to extract and persist bicep template + // If export requested, attempt to extract and persist bicep templates if (exportBicep && resultToken != null) { - var bicepContent = resultToken.SelectToken("bicep_template.main_template")?.Value(); - if (!string.IsNullOrWhiteSpace(bicepContent)) + var bicepRoot = resultToken.SelectToken("bicep_template"); + var mainTemplateContent = bicepRoot?.SelectToken("main_template")?.Value(); + var moduleTemplatesToken = bicepRoot?.SelectToken("module_templates"); + + if (!string.IsNullOrWhiteSpace(mainTemplateContent) || moduleTemplatesToken != null) { try { - string targetPathInput = ExportTemplateToPath; - string resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(targetPathInput); - string filePath; - if (System.IO.Directory.Exists(resolvedPath) || resolvedPath.EndsWith(System.IO.Path.DirectorySeparatorChar.ToString()) || resolvedPath.EndsWith("/")) + // Base directory: ~/.azure/bicep + string baseDir = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".azure", "bicep"); + if (!System.IO.Directory.Exists(baseDir)) { - // Treat as directory - if (!System.IO.Directory.Exists(resolvedPath)) - { - System.IO.Directory.CreateDirectory(resolvedPath); - } - filePath = System.IO.Path.Combine(resolvedPath, "export.bicep"); + System.IO.Directory.CreateDirectory(baseDir); } - else + + // Create a subfolder per command for organization (optional) + string commandName = (this.MyInvocation?.InvocationName ?? "az_compute").ToLower().Replace('-', '_'); + string commandDir = System.IO.Path.Combine(baseDir, commandName); + if (!System.IO.Directory.Exists(commandDir)) { - // Treat as file path - string dir = System.IO.Path.GetDirectoryName(resolvedPath); - if (!string.IsNullOrWhiteSpace(dir) && !System.IO.Directory.Exists(dir)) + System.IO.Directory.CreateDirectory(commandDir); + } + + string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var savedPaths = new List(); + + if (!string.IsNullOrWhiteSpace(mainTemplateContent)) + { + string mainFileName = $"{commandName}_main_{timestamp}.bicep"; + string mainPath = System.IO.Path.Combine(commandDir, mainFileName); + System.IO.File.WriteAllText(mainPath, mainTemplateContent, Encoding.UTF8); + savedPaths.Add(mainPath); + } + + if (moduleTemplatesToken != null) + { + // Handle object or array forms + if (moduleTemplatesToken.Type == JTokenType.Object) { - System.IO.Directory.CreateDirectory(dir); + int index = 1; + foreach (var prop in ((JObject)moduleTemplatesToken).Properties()) + { + string content = prop.Value.Value(); + if (string.IsNullOrWhiteSpace(content)) continue; + string moduleFileName = $"{commandName}_module{(index == 1 ? string.Empty : index.ToString())}_{timestamp}.bicep"; + string modulePath = System.IO.Path.Combine(commandDir, moduleFileName); + System.IO.File.WriteAllText(modulePath, content, Encoding.UTF8); + savedPaths.Add(modulePath); + index++; + } } - filePath = resolvedPath; - // Ensure extension - if (System.IO.Path.GetExtension(filePath).Equals(string.Empty, StringComparison.OrdinalIgnoreCase)) + else if (moduleTemplatesToken.Type == JTokenType.Array) { - filePath += ".bicep"; + int index = 1; + foreach (var item in (JArray)moduleTemplatesToken) + { + string content = item?.Value(); + if (string.IsNullOrWhiteSpace(content)) continue; + string moduleFileName = $"{commandName}_module{(index == 1 ? string.Empty : index.ToString())}_{timestamp}.bicep"; + string modulePath = System.IO.Path.Combine(commandDir, moduleFileName); + System.IO.File.WriteAllText(modulePath, content, Encoding.UTF8); + savedPaths.Add(modulePath); + index++; + } } } - System.IO.File.WriteAllText(filePath, bicepContent, Encoding.UTF8); - WriteVerbose($"Bicep template exported to: {filePath}"); - WriteInformation($"Bicep template exported to: {filePath}", new string[] { "PSHOST" }); + if (savedPaths.Count > 0) + { + WriteInformation("Bicep templates saved to:", new[] { "PSHOST" }); + foreach (var p in savedPaths) + { + WriteInformation(p, new[] { "PSHOST" }); + } + } + else + { + WriteWarning("No Bicep templates were exported (empty content)."); + } } catch (Exception ex) { - WriteWarning($"Failed to write Bicep template: {ex.Message}"); + WriteWarning($"Failed to export Bicep templates: {ex.Message}"); } } else { - WriteWarning("bicep_template.main_template not found in DryRun response"); + WriteWarning("No bicep_template content found in DryRun response"); } } From 891ac132a3b0590326c0294d078ff047b78037c2 Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Sun, 9 Nov 2025 21:57:37 +0800 Subject: [PATCH 07/13] support -ExportBicep in Az.Compute --- src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs index a886b9816df0..2971fc081c13 100644 --- a/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs +++ b/src/Compute/Compute/Common/ComputeClientBaseCmdlet.cs @@ -69,7 +69,7 @@ private static HttpClient CreateDryRunHttpClient() [Parameter(Mandatory = false, HelpMessage = "Send the invoked PowerShell command (ps_script) and subscription id to a remote endpoint without executing the real operation.")] public SwitchParameter DryRun { get; set; } - [Parameter(Mandatory = false, HelpMessage = "When used together with -DryRun, export generated Bicep templates to the user's .azure directory.")] + [Parameter(Mandatory = false, HelpMessage = "When used together with -DryRun, export generated Bicep templates to the user's .azure\\whatif directory.")] public SwitchParameter ExportBicep { get; set; } public ComputeClient ComputeClient @@ -177,8 +177,8 @@ protected virtual bool TryHandleDryRun(string prePsInvocationScript = null) { try { - // Base directory: ~/.azure/bicep - string baseDir = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".azure", "bicep"); + // Base directory: ~/.azure/whatif + string baseDir = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".azure", "whatif"); if (!System.IO.Directory.Exists(baseDir)) { System.IO.Directory.CreateDirectory(baseDir); From 2c853c08bdc91993512c9a7676cb5bcd7760354e Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Sun, 9 Nov 2025 22:11:44 +0800 Subject: [PATCH 08/13] support -ExportBicep in Az.Storage --- .../Storage.Management.csproj | 5 + .../StorageAccount/NewAzureStorageAccount.cs | 7 + .../StorageAccountBaseCmdlet.cs | 489 ++++++++++++++++-- .../Storage.common/Storage.common.csproj | 1 + 4 files changed, 470 insertions(+), 32 deletions(-) diff --git a/src/Storage/Storage.Management/Storage.Management.csproj b/src/Storage/Storage.Management/Storage.Management.csproj index 6b3c2db719d5..122a6eaafaa2 100644 --- a/src/Storage/Storage.Management/Storage.Management.csproj +++ b/src/Storage/Storage.Management/Storage.Management.csproj @@ -24,6 +24,11 @@ + + + + + diff --git a/src/Storage/Storage.Management/StorageAccount/NewAzureStorageAccount.cs b/src/Storage/Storage.Management/StorageAccount/NewAzureStorageAccount.cs index 154fb26cada3..97276983c9c5 100644 --- a/src/Storage/Storage.Management/StorageAccount/NewAzureStorageAccount.cs +++ b/src/Storage/Storage.Management/StorageAccount/NewAzureStorageAccount.cs @@ -669,8 +669,15 @@ public int ImmutabilityPeriod public override void ExecuteCmdlet() { + // Call base to perform common validation (including ExportTemplateToPath requirement and standard cmdlet setup) base.ExecuteCmdlet(); + // If DryRun was requested, base already handled output; skip real creation logic + if (this.DryRun.IsPresent) + { + return; + } + CheckNameAvailabilityResult checkNameAvailabilityResult = this.StorageClient.StorageAccounts.CheckNameAvailability(this.Name); if (!checkNameAvailabilityResult.NameAvailable.Value) { diff --git a/src/Storage/Storage.Management/StorageAccount/StorageAccountBaseCmdlet.cs b/src/Storage/Storage.Management/StorageAccount/StorageAccountBaseCmdlet.cs index 8e88a074656d..75c56faae81d 100644 --- a/src/Storage/Storage.Management/StorageAccount/StorageAccountBaseCmdlet.cs +++ b/src/Storage/Storage.Management/StorageAccount/StorageAccountBaseCmdlet.cs @@ -21,6 +21,16 @@ using System; using System.Collections.Generic; using StorageModels = Microsoft.Azure.Management.Storage.Models; +using System.Management.Automation; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Microsoft.Azure.Commands.Common.Authentication; +// Attempt to use shared WhatIf formatter & models (may not be available if project not referenced) +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; namespace Microsoft.Azure.Commands.Management.Storage { @@ -54,6 +64,26 @@ public abstract class StorageAccountBaseCmdlet : AzureRMCmdlet internal const string StandardGZRS = "Standard_GZRS"; internal const string StandardRAGZRS = "Standard_RAGZRS"; + // DryRun support (similar to ComputeClientBaseCmdlet) + [Parameter(Mandatory = false, HelpMessage = "Send the invoked PowerShell command (ps_script) and subscription id to a remote endpoint without executing the real operation.")] + public SwitchParameter DryRun { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "When used together with -DryRun, exports generated Bicep templates to the user's .azure\\bicep directory.")] + public SwitchParameter ExportBicep { get; set; } + + private static readonly HttpClient _dryRunHttpClient = CreateDryRunHttpClient(); + + private static HttpClient CreateDryRunHttpClient() + { + int timeoutSeconds = 300; + string timeoutEnv = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_TIMEOUT_SECONDS"); + if (!string.IsNullOrWhiteSpace(timeoutEnv) && int.TryParse(timeoutEnv, out int customTimeout) && customTimeout > 0) + { + timeoutSeconds = customTimeout; + } + return new HttpClient { Timeout = TimeSpan.FromSeconds(timeoutSeconds) }; + } + protected struct AccountAccessTier { internal const string Hot = "Hot"; @@ -117,13 +147,355 @@ public IStorageManagementClient StorageClient set { storageClientWrapper = new StorageManagementClientWrapper(value); } } - public string SubscriptionId + public string SubscriptionId => DefaultProfile.DefaultContext.Subscription.Id.ToString(); + + public override void ExecuteCmdlet() { - get + // Validate ExportBicep usage + if (ExportBicep.IsPresent && !DryRun.IsPresent) + { + ThrowTerminatingError(new ErrorRecord( + new ArgumentException("-ExportBicep must be used together with -DryRun"), + "ExportBicepRequiresDryRun", + ErrorCategory.InvalidArgument, + null)); + } + + if (DryRun.IsPresent && TryHandleDryRun()) + { + return; // prevent real execution + } + base.ExecuteCmdlet(); + } + + protected virtual bool TryHandleDryRun(string prePsInvocationScript = null) + { + try + { + string invocationLine = this.MyInvocation?.Line ?? this.MyInvocation?.InvocationName ?? string.Empty; + string psScript = string.IsNullOrWhiteSpace(prePsInvocationScript) ? invocationLine : prePsInvocationScript + "\n" + invocationLine; + string subscriptionId = this.DefaultContext?.Subscription?.Id ?? DefaultProfile.DefaultContext?.Subscription?.Id ?? string.Empty; + bool exportBicep = ExportBicep.IsPresent; + + var payload = new + { + ps_script = psScript, + subscription_id = subscriptionId, + timestamp_utc = DateTime.UtcNow.ToString("o"), + source = "Az.Storage.DryRun", + export_bicep = exportBicep ? "True" : "False" + }; + + string endpoint = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + { + endpoint = "https://azcli-script-insight.azurewebsites.net/api/what_if_ps_preview"; // shared endpoint + } + string token = GetDryRunAuthToken(); + + var dryRunResult = PostDryRun(endpoint, token, payload); + JToken resultToken = null; + try + { + if (dryRunResult is JToken jt) resultToken = jt; + else if (dryRunResult is string s && !string.IsNullOrWhiteSpace(s)) resultToken = JToken.Parse(s); + else if (dryRunResult != null) resultToken = JToken.FromObject(dryRunResult); + } + catch { } + + if (exportBicep && resultToken != null) + { + TryExportBicepTemplates(resultToken); + } + + if (dryRunResult != null) + { + // Try to adapt to WhatIf formatting + var whatIfResult = TryAdaptDryRunToWhatIf(dryRunResult); + if (whatIfResult != null) + { + try + { + WriteVerbose("========== DryRun WhatIf (Formatted) =========="); + string formatted = WhatIfOperationResultFormatter.Format(whatIfResult, noiseNotice: "Note: DryRun preview - actual execution skipped."); + WriteObject(formatted); + WriteVerbose("==============================================="); + } + catch (Exception formatEx) + { + WriteVerbose($"WhatIf formatting failed: {formatEx.Message}"); + string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); + WriteObject(formattedJson); + } + } + else + { + WriteVerbose("========== DryRun Response (Raw JSON) =========="); + string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); + WriteObject(formattedJson); + WriteVerbose("==============================================="); + } + } + else + { + WriteWarning("DryRun request completed but no response data was returned."); + } + } + catch (Exception ex) + { + WriteWarning($"DryRun error: {ex.Message}"); + } + return true; + } + + private object PostDryRun(string endpoint, string bearerToken, object payload) + { + string json = JsonConvert.SerializeObject(payload); + using (var request = new HttpRequestMessage(HttpMethod.Post, endpoint)) + { + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + string correlationId = Guid.NewGuid().ToString(); + request.Headers.Add("x-ms-client-request-id", correlationId); + if (!string.IsNullOrWhiteSpace(bearerToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + } + WriteVerbose($"DryRun POST -> {endpoint}"); + WriteVerbose($"DryRun correlation-id: {correlationId}"); + WriteVerbose($"DryRun Payload: {Truncate(json, 1024)}"); + try + { + var response = _dryRunHttpClient.SendAsync(request).GetAwaiter().GetResult(); + string respBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + WriteVerbose($"DryRun HTTP Status: {(int)response.StatusCode} {response.ReasonPhrase}"); + if (response.IsSuccessStatusCode) + { + WriteVerbose("DryRun post succeeded."); + WriteVerbose($"DryRun response body: {Truncate(respBody, 2048)}"); + try + { + var jToken = !string.IsNullOrWhiteSpace(respBody) ? JToken.Parse(respBody) : null; + if (jToken != null && jToken.Type == JTokenType.Object) + { + ((JObject)jToken)["_correlation_id"] = correlationId; + ((JObject)jToken)["_http_status"] = (int)response.StatusCode; + ((JObject)jToken)["_success"] = true; + return jToken.ToObject(); + } + return jToken ?? (object)respBody; + } + catch (Exception parseEx) + { + WriteVerbose($"DryRun response parse failed: {parseEx.Message}"); + return respBody; + } + } + else + { + WriteWarning($"DryRun API returned error: {(int)response.StatusCode} {response.ReasonPhrase}"); + var errorResponse = new + { + _success = false, + _http_status = (int)response.StatusCode, + _status_description = response.ReasonPhrase, + _correlation_id = correlationId, + _endpoint = endpoint, + error_message = respBody, + timestamp = DateTime.UtcNow.ToString("o") + }; + try + { + var errorJson = JToken.Parse(respBody); + WriteError(new ErrorRecord(new Exception($"DryRun API Error: {response.StatusCode} - {respBody}"), "DryRunApiError", ErrorCategory.InvalidOperation, endpoint)); + if (errorJson.Type == JTokenType.Object) + { + ((JObject)errorJson)["_correlation_id"] = correlationId; + ((JObject)errorJson)["_http_status"] = (int)response.StatusCode; + ((JObject)errorJson)["_success"] = false; + return errorJson.ToObject(); + } + } + catch { } + WriteVerbose($"DryRun error response body: {Truncate(respBody, 2048)}"); + return errorResponse; + } + } + catch (HttpRequestException httpEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun network error: {httpEx.Message}", httpEx), "DryRunNetworkError", ErrorCategory.ConnectionError, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = "NetworkError", error_message = httpEx.Message, timestamp = DateTime.UtcNow.ToString("o") }; + } + catch (TimeoutException timeoutEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun request timeout: {timeoutEx.Message}", timeoutEx), "DryRunTimeout", ErrorCategory.OperationTimeout, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = "Timeout", error_message = "Request timed out", timestamp = DateTime.UtcNow.ToString("o") }; + } + catch (Exception sendEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun request failed: {sendEx.Message}", sendEx), "DryRunRequestError", ErrorCategory.NotSpecified, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = sendEx.GetType().Name, error_message = sendEx.Message, timestamp = DateTime.UtcNow.ToString("o") }; + } + } + } + + private static string Truncate(string value, int max) + { + if (string.IsNullOrEmpty(value) || value.Length <= max) return value; + return value.Substring(0, max) + "...(truncated)"; + } + + // Re-implemented without Azure.Identity dependency; leverage existing authentication factory + private string GetDryRunAuthToken() + { + try + { + var context = this.DefaultContext ?? DefaultProfile.DefaultContext; + if (context?.Account == null || context.Environment == null) return null; + string scope = context.Environment.GetEndpoint(AzureEnvironment.Endpoint.ResourceManager); + var accessToken = AzureSession.Instance.AuthenticationFactory.Authenticate( + context.Account, + context.Environment, + context.Tenant?.Id, + null, + ShowDialog.Never, + null, + scope); + return accessToken?.AccessToken; + } + catch (Exception ex) + { + WriteVerbose($"DryRun token acquisition failed: {ex.Message}"); + return null; + } + } + + // Adapter to WhatIf interfaces (same pattern as Compute) + private IWhatIfOperationResult TryAdaptDryRunToWhatIf(object dryRunResult) + { + try + { + JObject root = null; + if (dryRunResult is JToken jt) root = jt as JObject; else if (dryRunResult is string s) root = JObject.Parse(s); else root = JObject.FromObject(dryRunResult); + if (root == null) return null; + var whatIfObj = root["what_if_result"] as JObject ?? root; + if (whatIfObj["changes"] == null && whatIfObj["resourceChanges"] == null) return null; + return new DryRunWhatIfResult(whatIfObj); + } + catch (Exception ex) + { + WriteVerbose($"Adapt WhatIf failed: {ex.Message}"); + return null; + } + } + + private class DryRunWhatIfResult : IWhatIfOperationResult + { + private readonly JObject _response; + private readonly Lazy> _changes; + private readonly Lazy> _potentialChanges; + private readonly Lazy> _diagnostics; + private readonly Lazy _error; + public DryRunWhatIfResult(JObject response) + { + _response = response; + _changes = new Lazy>(() => ParseChanges(_response["changes"] ?? _response["resourceChanges"])); + _potentialChanges = new Lazy>(() => ParseChanges(_response["potentialChanges"])); + _diagnostics = new Lazy>(() => ParseDiagnostics(_response["diagnostics"])); + _error = new Lazy(() => ParseError(_response["error"])); + } + public string Status => _response["status"]?.Value() ?? "Succeeded"; + public IList Changes => _changes.Value; + public IList PotentialChanges => _potentialChanges.Value; + public IList Diagnostics => _diagnostics.Value; + public IWhatIfError Error => _error.Value; + private static IList ParseChanges(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var c in token) if (c is JObject o) list.Add(new DryRunWhatIfChange(o)); + return list; + } + private static IList ParseDiagnostics(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var d in token) if (d is JObject o) list.Add(new DryRunWhatIfDiagnostic(o)); + return list; + } + private static IWhatIfError ParseError(JToken token) + { + if (token == null || token.Type != JTokenType.Object) return null; + return new DryRunWhatIfError((JObject)token); + } + } + private class DryRunWhatIfChange : IWhatIfChange + { + private readonly JObject _change; + private readonly Lazy> _delta; + public DryRunWhatIfChange(JObject change) { - return DefaultProfile.DefaultContext.Subscription.Id.ToString(); + _change = change; + _delta = new Lazy>(() => ParsePropertyChanges(_change["delta"] ?? _change["propertyChanges"])); + } + public string Scope { get { var id = _change["resourceId"]?.Value() ?? string.Empty; int idx = id.LastIndexOf("/providers/"); return idx > 0 ? id.Substring(0, idx) : string.Empty; } } + public string RelativeResourceId { get { var id = _change["resourceId"]?.Value() ?? string.Empty; int idx = id.LastIndexOf("/providers/"); return idx > 0 ? id.Substring(idx + 1) : id; } } + public string UnsupportedReason => _change["unsupportedReason"]?.Value(); + public string FullyQualifiedResourceId => _change["resourceId"]?.Value() ?? string.Empty; + public ChangeType ChangeType { get { var s = _change["changeType"]?.Value() ?? "NoChange"; if (Enum.TryParse(s, true, out var ct)) return ct; return ChangeType.NoChange; } } + public string ApiVersion => _change["apiVersion"]?.Value() ?? _change["after"]?["apiVersion"]?.Value() ?? _change["before"]?["apiVersion"]?.Value(); + public JToken Before => _change["before"]; + public JToken After => _change["after"]; + public IList Delta => _delta.Value; + private static IList ParsePropertyChanges(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var c in token) if (c is JObject o) list.Add(new DryRunWhatIfPropertyChange(o)); + return list; } } + private class DryRunWhatIfPropertyChange : IWhatIfPropertyChange + { + private readonly JObject _prop; + private readonly Lazy> _children; + public DryRunWhatIfPropertyChange(JObject prop) + { + _prop = prop; + _children = new Lazy>(() => ParseChildren(_prop["children"])); + } + public string Path => _prop["path"]?.Value() ?? string.Empty; + public PropertyChangeType PropertyChangeType { get { var s = _prop["propertyChangeType"]?.Value() ?? _prop["changeType"]?.Value() ?? "NoEffect"; if (Enum.TryParse(s, true, out var pct)) return pct; return PropertyChangeType.NoEffect; } } + public JToken Before => _prop["before"]; + public JToken After => _prop["after"]; + public IList Children => _children.Value; + private static IList ParseChildren(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var c in token) if (c is JObject o) list.Add(new DryRunWhatIfPropertyChange(o)); + return list; + } + } + private class DryRunWhatIfDiagnostic : IWhatIfDiagnostic + { + private readonly JObject _diag; + public DryRunWhatIfDiagnostic(JObject d) { _diag = d; } + public string Code => _diag["code"]?.Value() ?? string.Empty; + public string Message => _diag["message"]?.Value() ?? string.Empty; + public string Level => _diag["level"]?.Value() ?? "Info"; + public string Target => _diag["target"]?.Value() ?? string.Empty; + public string Details => _diag["details"]?.Value() ?? string.Empty; + } + private class DryRunWhatIfError : IWhatIfError + { + private readonly JObject _err; + public DryRunWhatIfError(JObject e) { _err = e; } + public string Code => _err["code"]?.Value() ?? string.Empty; + public string Message => _err["message"]?.Value() ?? string.Empty; + public string Target => _err["target"]?.Value() ?? string.Empty; + } protected static AccessTier ParseAccessTier(string accessTier) { @@ -138,11 +510,7 @@ protected static AccessTier ParseAccessTier(string accessTier) protected static Encryption ParseEncryption(bool storageEncryption = false, bool keyVaultEncryption = false, string keyName = null, string keyVersion = null, string keyVaultUri = null) { Encryption accountEncryption = new Encryption(); - - if (storageEncryption) - { - accountEncryption.KeySource = "Microsoft.Storage"; - } + if (storageEncryption) accountEncryption.KeySource = "Microsoft.Storage"; if (keyVaultEncryption) { accountEncryption.KeySource = "Microsoft.Keyvault"; @@ -165,32 +533,14 @@ protected void WriteStorageAccountList(IEnumerable public static string GetIdentityTypeString(string inputIdentityType) { - if (inputIdentityType == null) - { - return null; - } - - // The parameter validate set make sure the value must be systemAssigned or userAssigned or systemAssignedUserAssigned or None - if (inputIdentityType.ToLower() == AccountIdentityType.systemAssigned.ToLower()) - { - return IdentityType.SystemAssigned; - } - if (inputIdentityType.ToLower() == AccountIdentityType.userAssigned.ToLower()) - { - return IdentityType.UserAssigned; - } - if (inputIdentityType.ToLower() == AccountIdentityType.systemAssignedUserAssigned.ToLower()) - { - return IdentityType.SystemAssignedUserAssigned; - } - if (inputIdentityType.ToLower() == AccountIdentityType.none.ToLower()) - { - return IdentityType.None; - } + if (inputIdentityType == null) return null; + if (inputIdentityType.ToLower() == AccountIdentityType.systemAssigned.ToLower()) return IdentityType.SystemAssigned; + if (inputIdentityType.ToLower() == AccountIdentityType.userAssigned.ToLower()) return IdentityType.UserAssigned; + if (inputIdentityType.ToLower() == AccountIdentityType.systemAssignedUserAssigned.ToLower()) return IdentityType.SystemAssignedUserAssigned; + if (inputIdentityType.ToLower() == AccountIdentityType.none.ToLower()) return IdentityType.None; throw new ArgumentException("The value for AssignIdentityType is not valid, the valid value are: \"None\", \"SystemAssigned\", \"UserAssigned\", or \"SystemAssignedUserAssigned\"", "AssignIdentityType"); } - // Make the input string value case is aligned with the test API definition. public static string NormalizeString(string input) { foreach (var field in typeof(T).GetFields()) @@ -203,7 +553,6 @@ public static string NormalizeString(string input) return input; } - // Make the input string[] value case is aligned with the test API definition. public static string[] NormalizeStringArray(string[] input) { if (input != null) @@ -217,5 +566,81 @@ public static string[] NormalizeStringArray(string[] input) } return input; } + + private void TryExportBicepTemplates(JToken resultToken) + { + try + { + var bicepRoot = resultToken.SelectToken("bicep_template"); + if (bicepRoot == null) + { + WriteWarning("bicep_template node not found in DryRun response"); + return; + } + + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string commandName = (this.MyInvocation?.InvocationName ?? "command").Replace(':','_').Replace('/', '_').Replace(' ', '_'); + // Match example paths: C:\Users\user\.azure\whatif\\files + string commandDir = System.IO.Path.Combine(userProfile, ".azure", "whatif", commandName); + if (!System.IO.Directory.Exists(commandDir)) System.IO.Directory.CreateDirectory(commandDir); + + string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var savedFiles = new List(); + + // main template + var mainTemplate = bicepRoot.SelectToken("main_template")?.Value(); + if (!string.IsNullOrWhiteSpace(mainTemplate)) + { + string mainFileName = $"{commandName}_main_{timestamp}.bicep"; + string mainPath = System.IO.Path.Combine(commandDir, mainFileName); + System.IO.File.WriteAllText(mainPath, mainTemplate, Encoding.UTF8); + savedFiles.Add(mainPath); + } + else + { + WriteVerbose("No main_template found under bicep_template"); + } + + // module templates (object of name->content) under bicep_template.module_templates OR bicep_template.modules + var moduleContainer = bicepRoot.SelectToken("module_templates") ?? bicepRoot.SelectToken("modules"); + if (moduleContainer is JObject modulesObj) + { + int index = 0; + foreach (var prop in modulesObj.Properties()) + { + string moduleContent = prop.Value?.Value(); + if (string.IsNullOrWhiteSpace(moduleContent)) continue; + string safeName = prop.Name.Replace(':','_').Replace('/', '_').Replace(' ', '_'); + // If property name generic, append an index to differentiate + string moduleFileName = $"{commandName}_{safeName}_{timestamp}.bicep"; + if (savedFiles.Contains(System.IO.Path.Combine(commandDir, moduleFileName))) + { + moduleFileName = $"{commandName}_{safeName}_{index}_{timestamp}.bicep"; + } + string modulePath = System.IO.Path.Combine(commandDir, moduleFileName); + System.IO.File.WriteAllText(modulePath, moduleContent, Encoding.UTF8); + savedFiles.Add(modulePath); + index++; + } + } + + if (savedFiles.Count > 0) + { + WriteObject("Bicep templates saved to:"); + foreach (var f in savedFiles) + { + WriteObject(f); + } + } + else + { + WriteWarning("No Bicep templates found to export."); + } + } + catch (Exception ex) + { + WriteWarning($"Failed to export Bicep templates: {ex.Message}"); + } + } } } diff --git a/src/Storage/Storage.common/Storage.common.csproj b/src/Storage/Storage.common/Storage.common.csproj index 4add4bf55085..8a86a141cc96 100644 --- a/src/Storage/Storage.common/Storage.common.csproj +++ b/src/Storage/Storage.common/Storage.common.csproj @@ -20,6 +20,7 @@ + From 5dec96588cb4271e09289ea8ea3479bd6e5595e5 Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Mon, 10 Nov 2025 01:49:09 +0800 Subject: [PATCH 09/13] support dry run for new/update vnet --- src/Network/Network/Network.csproj | 5 + .../NewAzureVirtualNetworkCommand.cs | 6 + .../SetAzureVirtualNetworkCommand.cs | 23 + .../VirtualNetworkBaseCmdlet.cs | 463 +++++++++++++++++- 4 files changed, 484 insertions(+), 13 deletions(-) diff --git a/src/Network/Network/Network.csproj b/src/Network/Network/Network.csproj index db72df064083..19a5de976673 100644 --- a/src/Network/Network/Network.csproj +++ b/src/Network/Network/Network.csproj @@ -16,6 +16,11 @@ + + + + + True diff --git a/src/Network/Network/VirtualNetwork/NewAzureVirtualNetworkCommand.cs b/src/Network/Network/VirtualNetwork/NewAzureVirtualNetworkCommand.cs index e16ac7bf5b5f..6f48aa9d0ae0 100644 --- a/src/Network/Network/VirtualNetwork/NewAzureVirtualNetworkCommand.cs +++ b/src/Network/Network/VirtualNetwork/NewAzureVirtualNetworkCommand.cs @@ -147,6 +147,12 @@ public class NewAzureVirtualNetworkCommand : VirtualNetworkBaseCmdlet public override void Execute() { + // Handle DryRun early: perform DryRun payload post and formatting only, skip real create logic + if (DryRun.IsPresent && TryHandleDryRun()) + { + return; // do not proceed to actual creation + } + base.Execute(); var present = IsVirtualNetworkPresent(ResourceGroupName, Name); ConfirmAction( diff --git a/src/Network/Network/VirtualNetwork/SetAzureVirtualNetworkCommand.cs b/src/Network/Network/VirtualNetwork/SetAzureVirtualNetworkCommand.cs index a302743dbec2..f111a7e27018 100644 --- a/src/Network/Network/VirtualNetwork/SetAzureVirtualNetworkCommand.cs +++ b/src/Network/Network/VirtualNetwork/SetAzureVirtualNetworkCommand.cs @@ -37,6 +37,29 @@ public class SetAzureVirtualNetworkCommand : VirtualNetworkBaseCmdlet public override void Execute() { + // DryRun support (modeled after UpdateAzureVMCommand): attempt to flatten PSVirtualNetwork into a PS assignment + if (DryRun.IsPresent) + { + try + { + var vnetJson = Newtonsoft.Json.JsonConvert.SerializeObject(this.VirtualNetwork, Newtonsoft.Json.Formatting.Indented); + string escapedJson = vnetJson.Replace("`", "``"); + string vnetAssign = "$virtualNetwork = ConvertFrom-Json @'\n" + escapedJson + "\n'@"; + if (TryHandleDryRun(vnetAssign)) + { + return; // DryRun handled, do not execute real operation + } + } + catch (Exception ex) + { + WriteVerbose($"DryRun VirtualNetwork flatten failed: {ex.Message}. Falling back to default invocation capture."); + if (TryHandleDryRun()) + { + return; + } + } + } + base.Execute(); if (!this.IsVirtualNetworkPresent(this.VirtualNetwork.ResourceGroupName, this.VirtualNetwork.Name)) diff --git a/src/Network/Network/VirtualNetwork/VirtualNetworkBaseCmdlet.cs b/src/Network/Network/VirtualNetwork/VirtualNetworkBaseCmdlet.cs index 4627e216db56..16f9b932a59c 100644 --- a/src/Network/Network/VirtualNetwork/VirtualNetworkBaseCmdlet.cs +++ b/src/Network/Network/VirtualNetwork/VirtualNetworkBaseCmdlet.cs @@ -1,5 +1,4 @@ - -// ---------------------------------------------------------------------------------- +// ---------------------------------------------------------------------------------- // // Copyright Microsoft Corporation // Licensed under the Apache License, Version 2.0 (the "License"); @@ -19,11 +18,44 @@ using Microsoft.Azure.Management.Network; using Microsoft.Azure.Management.Network.Models; using System.Net; +using System; +using System.Management.Automation; +using System.Text; +using System.Net.Http; +using System.Net.Http.Headers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Azure.Commands.Common.Authentication; +using Microsoft.Azure.Commands.Common.Authentication.Abstractions; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; namespace Microsoft.Azure.Commands.Network { public abstract class VirtualNetworkBaseCmdlet : NetworkBaseCmdlet { + // DryRun parameters (modeled after ComputeClientBaseCmdlet) + [Parameter(Mandatory = false, HelpMessage = "Send the invoked PowerShell command (ps_script) and subscription id to a remote endpoint without executing the real operation.")] + public SwitchParameter DryRun { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "When used together with -DryRun, export generated Bicep templates to the user's .azure\\whatif directory.")] + public SwitchParameter ExportBicep { get; set; } + + private static readonly HttpClient _dryRunHttpClient = CreateDryRunHttpClient(); + + private static HttpClient CreateDryRunHttpClient() + { + int timeoutSeconds = 300; // default 5 minutes + string timeoutEnv = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_TIMEOUT_SECONDS"); + if (!string.IsNullOrWhiteSpace(timeoutEnv) && int.TryParse(timeoutEnv, out int customTimeout) && customTimeout > 0) + { + timeoutSeconds = customTimeout; + } + return new HttpClient { Timeout = TimeSpan.FromSeconds(timeoutSeconds) }; + } + public IVirtualNetworksOperations VirtualNetworkClient { get @@ -32,6 +64,421 @@ public IVirtualNetworksOperations VirtualNetworkClient } } + public override void ExecuteCmdlet() + { + // Validate ExportBicep usage + if (ExportBicep.IsPresent && !DryRun.IsPresent) + { + ThrowTerminatingError(new ErrorRecord( + new ArgumentException("-ExportBicep must be used together with -DryRun"), + "ExportBicepRequiresDryRun", + ErrorCategory.InvalidArgument, + null)); + } + + base.ExecuteCmdlet(); + } + + protected virtual bool TryHandleDryRun(string prePsInvocationScript = null) + { + try + { + string invocationLine = this.MyInvocation?.Line ?? this.MyInvocation?.InvocationName ?? string.Empty; + string psScript = string.IsNullOrWhiteSpace(prePsInvocationScript) ? invocationLine : prePsInvocationScript + "\n" + invocationLine; + string subscriptionId = this.DefaultContext?.Subscription?.Id ?? DefaultProfile.DefaultContext?.Subscription?.Id ?? string.Empty; + bool exportBicep = ExportBicep.IsPresent; + + var payload = new + { + ps_script = psScript, + subscription_id = subscriptionId, + timestamp_utc = DateTime.UtcNow.ToString("o"), + source = "Az.Network.DryRun", + export_bicep = exportBicep ? "True" : "False" + }; + + string endpoint = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + { + endpoint = "https://azcli-script-insight.azurewebsites.net/api/what_if_ps_preview"; + } + string token = GetDryRunAuthToken(); + + var dryRunResult = PostDryRun(endpoint, token, payload); + JToken resultToken = null; + try + { + if (dryRunResult is JToken jt) resultToken = jt; + else if (dryRunResult is string s && !string.IsNullOrWhiteSpace(s)) resultToken = JToken.Parse(s); + else if (dryRunResult != null) resultToken = JToken.FromObject(dryRunResult); + } + catch { } + + if (exportBicep && resultToken != null) + { + ExportBicepTemplates(resultToken); + } + + if (dryRunResult != null) + { + var whatIfResult = TryAdaptDryRunToWhatIf(dryRunResult); + if (whatIfResult != null) + { + try + { + WriteVerbose("========== DryRun WhatIf (Formatted) =========="); + string formatted = WhatIfOperationResultFormatter.Format(whatIfResult, noiseNotice: "Note: DryRun preview - actual execution skipped."); + WriteObject(formatted); + WriteVerbose("==============================================="); + } + catch (Exception formatEx) + { + WriteVerbose($"WhatIf formatting failed: {formatEx.Message}"); + string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); + WriteObject(formattedJson); + } + } + else + { + WriteVerbose("========== DryRun Response (Raw JSON) =========="); + string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); + WriteObject(formattedJson); + WriteVerbose("==============================================="); + } + } + else + { + WriteWarning("DryRun request completed but no response data was returned."); + } + } + catch (Exception ex) + { + WriteWarning($"DryRun error: {ex.Message}"); + } + return true; // Always prevent normal execution when -DryRun is used + } + + private void ExportBicepTemplates(JToken resultToken) + { + var bicepRoot = resultToken.SelectToken("bicep_template"); + var mainTemplateContent = bicepRoot?.SelectToken("main_template")?.Value(); + var moduleTemplatesToken = bicepRoot?.SelectToken("module_templates"); + + if (string.IsNullOrWhiteSpace(mainTemplateContent) && moduleTemplatesToken == null) + { + WriteWarning("No bicep_template content found in DryRun response"); + return; + } + + try + { + string baseDir = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".azure", "whatif"); + if (!System.IO.Directory.Exists(baseDir)) System.IO.Directory.CreateDirectory(baseDir); + + string commandName = (this.MyInvocation?.InvocationName ?? "az_network").ToLower().Replace('-', '_'); + string commandDir = System.IO.Path.Combine(baseDir, commandName); + if (!System.IO.Directory.Exists(commandDir)) System.IO.Directory.CreateDirectory(commandDir); + string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var savedPaths = new List(); + + if (!string.IsNullOrWhiteSpace(mainTemplateContent)) + { + string mainFileName = $"{commandName}_main_{timestamp}.bicep"; + string mainPath = System.IO.Path.Combine(commandDir, mainFileName); + System.IO.File.WriteAllText(mainPath, mainTemplateContent, Encoding.UTF8); + savedPaths.Add(mainPath); + } + + if (moduleTemplatesToken != null) + { + if (moduleTemplatesToken.Type == JTokenType.Object) + { + int index = 1; + foreach (var prop in ((JObject)moduleTemplatesToken).Properties()) + { + string content = prop.Value.Value(); + if (string.IsNullOrWhiteSpace(content)) continue; + string moduleFileName = $"{commandName}_module{(index == 1 ? string.Empty : index.ToString())}_{timestamp}.bicep"; + string modulePath = System.IO.Path.Combine(commandDir, moduleFileName); + System.IO.File.WriteAllText(modulePath, content, Encoding.UTF8); + savedPaths.Add(modulePath); + index++; + } + } + else if (moduleTemplatesToken.Type == JTokenType.Array) + { + int index = 1; + foreach (var item in (JArray)moduleTemplatesToken) + { + string content = item?.Value(); + if (string.IsNullOrWhiteSpace(content)) continue; + string moduleFileName = $"{commandName}_module{(index == 1 ? string.Empty : index.ToString())}_{timestamp}.bicep"; + string modulePath = System.IO.Path.Combine(commandDir, moduleFileName); + System.IO.File.WriteAllText(modulePath, content, Encoding.UTF8); + savedPaths.Add(modulePath); + index++; + } + } + } + + if (savedPaths.Count > 0) + { + WriteInformation("Bicep templates saved to:", new[] { "PSHOST" }); + foreach (var p in savedPaths) WriteInformation(p, new[] { "PSHOST" }); + } + else + { + WriteWarning("No Bicep templates were exported (empty content)."); + } + } + catch (Exception ex) + { + WriteWarning($"Failed to export Bicep templates: {ex.Message}"); + } + } + + private object PostDryRun(string endpoint, string bearerToken, object payload) + { + string json = JsonConvert.SerializeObject(payload); + using (var request = new HttpRequestMessage(System.Net.Http.HttpMethod.Post, endpoint)) + { + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + string correlationId = Guid.NewGuid().ToString(); + request.Headers.Add("x-ms-client-request-id", correlationId); + if (!string.IsNullOrWhiteSpace(bearerToken)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + + WriteVerbose($"DryRun POST -> {endpoint}"); + WriteVerbose($"DryRun correlation-id: {correlationId}"); + WriteVerbose($"DryRun Payload: {Truncate(json, 1024)}"); + + try + { + var response = _dryRunHttpClient.SendAsync(request).GetAwaiter().GetResult(); + string respBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + WriteVerbose($"DryRun HTTP Status: {(int)response.StatusCode} {response.ReasonPhrase}"); + + if (response.IsSuccessStatusCode) + { + WriteVerbose("DryRun post succeeded."); + WriteVerbose($"DryRun response body: {Truncate(respBody, 2048)}"); + try + { + var jToken = !string.IsNullOrWhiteSpace(respBody) ? JToken.Parse(respBody) : null; + if (jToken != null && jToken.Type == JTokenType.Object) + { + ((JObject)jToken)["_correlation_id"] = correlationId; + ((JObject)jToken)["_http_status"] = (int)response.StatusCode; + ((JObject)jToken)["_success"] = true; + return jToken.ToObject(); + } + return jToken ?? (object)respBody; + } + catch (Exception parseEx) + { + WriteVerbose($"DryRun response parse failed: {parseEx.Message}"); + return respBody; + } + } + else + { + WriteWarning($"DryRun API returned error: {(int)response.StatusCode} {response.ReasonPhrase}"); + var errorResponse = new + { + _success = false, + _http_status = (int)response.StatusCode, + _status_description = response.ReasonPhrase, + _correlation_id = correlationId, + _endpoint = endpoint, + error_message = respBody, + timestamp = DateTime.UtcNow.ToString("o") + }; + try + { + var errorJson = JToken.Parse(respBody); + WriteError(new ErrorRecord(new Exception($"DryRun API Error: {response.StatusCode} - {respBody}"), "DryRunApiError", ErrorCategory.InvalidOperation, endpoint)); + if (errorJson.Type == JTokenType.Object) + { + ((JObject)errorJson)["_correlation_id"] = correlationId; + ((JObject)errorJson)["_http_status"] = (int)response.StatusCode; + ((JObject)errorJson)["_success"] = false; + return errorJson.ToObject(); + } + } + catch { } + WriteVerbose($"DryRun error response body: {Truncate(respBody, 2048)}"); + return errorResponse; + } + } + catch (HttpRequestException httpEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun network error: {httpEx.Message}", httpEx), "DryRunNetworkError", ErrorCategory.ConnectionError, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = "NetworkError", error_message = httpEx.Message, timestamp = DateTime.UtcNow.ToString("o") }; + } + catch (TimeoutException timeoutEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun request timeout: {timeoutEx.Message}", timeoutEx), "DryRunTimeout", ErrorCategory.OperationTimeout, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = "Timeout", error_message = "Request timed out", timestamp = DateTime.UtcNow.ToString("o") }; + } + catch (Exception sendEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun request failed: {sendEx.Message}", sendEx), "DryRunRequestError", ErrorCategory.NotSpecified, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = sendEx.GetType().Name, error_message = sendEx.Message, timestamp = DateTime.UtcNow.ToString("o") }; + } + } + } + + private static string Truncate(string value, int max) + { + if (string.IsNullOrEmpty(value) || value.Length <= max) return value; + return value.Substring(0, max) + "...(truncated)"; + } + + // Token acquisition using existing authentication factory (no Azure.Identity dependency) + private string GetDryRunAuthToken() + { + try + { + var context = this.DefaultContext ?? DefaultProfile.DefaultContext; + if (context?.Account == null || context.Environment == null) return null; + string scope = context.Environment.GetEndpoint(AzureEnvironment.Endpoint.ResourceManager); + var accessToken = AzureSession.Instance.AuthenticationFactory.Authenticate( + context.Account, + context.Environment, + context.Tenant?.Id, + null, + ShowDialog.Never, + null, + scope); + return accessToken?.AccessToken; + } + catch (Exception ex) + { + WriteVerbose($"DryRun token acquisition failed: {ex.Message}"); + return null; + } + } + + private IWhatIfOperationResult TryAdaptDryRunToWhatIf(object dryRunResult) + { + try + { + JObject root = null; + if (dryRunResult is JToken jt) root = jt as JObject; else if (dryRunResult is string s) root = JObject.Parse(s); else root = JObject.FromObject(dryRunResult); + if (root == null) return null; + var whatIfObj = root["what_if_result"] as JObject ?? root; + if (whatIfObj["changes"] == null && whatIfObj["resourceChanges"] == null) return null; + return new DryRunWhatIfResult(whatIfObj); + } + catch (Exception ex) + { + WriteVerbose($"Adapt WhatIf failed: {ex.Message}"); + return null; + } + } + + // WhatIf adapter classes + private class DryRunWhatIfResult : IWhatIfOperationResult + { + private readonly JObject _response; + private readonly Lazy> _changes; + private readonly Lazy> _potentialChanges; + private readonly Lazy> _diagnostics; + private readonly Lazy _error; + public DryRunWhatIfResult(JObject response) + { + _response = response; + _changes = new Lazy>(() => ParseChanges(_response["changes"] ?? _response["resourceChanges"])); + _potentialChanges = new Lazy>(() => ParseChanges(_response["potentialChanges"])); + _diagnostics = new Lazy>(() => ParseDiagnostics(_response["diagnostics"])); + _error = new Lazy(() => ParseError(_response["error"])); + } + public string Status => _response["status"]?.Value() ?? "Succeeded"; + public IList Changes => _changes.Value; + public IList PotentialChanges => _potentialChanges.Value; + public IList Diagnostics => _diagnostics.Value; + public IWhatIfError Error => _error.Value; + private static IList ParseChanges(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + return token.Select(c => c is JObject o ? (IWhatIfChange)new DryRunWhatIfChange(o) : null).Where(x => x != null).ToList(); + } + private static IList ParseDiagnostics(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + return token.Select(d => d is JObject o ? (IWhatIfDiagnostic)new DryRunWhatIfDiagnostic(o) : null).Where(x => x != null).ToList(); + } + private static IWhatIfError ParseError(JToken token) + { + if (token == null || token.Type != JTokenType.Object) return null; + return new DryRunWhatIfError((JObject)token); + } + } + private class DryRunWhatIfChange : IWhatIfChange + { + private readonly JObject _change; + private readonly Lazy> _delta; + public DryRunWhatIfChange(JObject change) + { + _change = change; + _delta = new Lazy>(() => ParsePropertyChanges(_change["delta"] ?? _change["propertyChanges"])); + } + public string Scope { get { var id = _change["resourceId"]?.Value() ?? string.Empty; int idx = id.LastIndexOf("/providers/"); return idx > 0 ? id.Substring(0, idx) : string.Empty; } } + public string RelativeResourceId { get { var id = _change["resourceId"]?.Value() ?? string.Empty; int idx = id.LastIndexOf("/providers/"); return idx > 0 ? id.Substring(idx + 1) : id; } } + public string UnsupportedReason => _change["unsupportedReason"]?.Value(); + public string FullyQualifiedResourceId => _change["resourceId"]?.Value() ?? string.Empty; + public ChangeType ChangeType { get { var s = _change["changeType"]?.Value() ?? "NoChange"; if (Enum.TryParse(s, true, out var ct)) return ct; return ChangeType.NoChange; } } + public string ApiVersion => _change["apiVersion"]?.Value() ?? _change["after"]?["apiVersion"]?.Value() ?? _change["before"]?["apiVersion"]?.Value(); + public JToken Before => _change["before"]; + public JToken After => _change["after"]; + public IList Delta => _delta.Value; + private static IList ParsePropertyChanges(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + return token.Select(c => c is JObject o ? (IWhatIfPropertyChange)new DryRunWhatIfPropertyChange(o) : null).Where(x => x != null).ToList(); + } + } + private class DryRunWhatIfPropertyChange : IWhatIfPropertyChange + { + private readonly JObject _prop; + private readonly Lazy> _children; + public DryRunWhatIfPropertyChange(JObject prop) + { + _prop = prop; + _children = new Lazy>(() => ParseChildren(_prop["children"])); + } + public string Path => _prop["path"]?.Value() ?? string.Empty; + public PropertyChangeType PropertyChangeType { get { var s = _prop["propertyChangeType"]?.Value() ?? _prop["changeType"]?.Value() ?? "NoEffect"; if (Enum.TryParse(s, true, out var pct)) return pct; return PropertyChangeType.NoEffect; } } + public JToken Before => _prop["before"]; + public JToken After => _prop["after"]; + public IList Children => _children.Value; + private static IList ParseChildren(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + return token.Select(c => c is JObject o ? (IWhatIfPropertyChange)new DryRunWhatIfPropertyChange(o) : null).Where(x => x != null).ToList(); + } + } + private class DryRunWhatIfDiagnostic : IWhatIfDiagnostic + { + private readonly JObject _diag; + public DryRunWhatIfDiagnostic(JObject d) { _diag = d; } + public string Code => _diag["code"]?.Value() ?? string.Empty; + public string Message => _diag["message"]?.Value() ?? string.Empty; + public string Level => _diag["level"]?.Value() ?? "Info"; + public string Target => _diag["target"]?.Value() ?? string.Empty; + public string Details => _diag["details"]?.Value() ?? string.Empty; + } + private class DryRunWhatIfError : IWhatIfError + { + private readonly JObject _err; + public DryRunWhatIfError(JObject e) { _err = e; } + public string Code => _err["code"]?.Value() ?? string.Empty; + public string Message => _err["message"]?.Value() ?? string.Empty; + public string Target => _err["target"]?.Value() ?? string.Empty; + } + + // Existing functionality retained below public bool IsVirtualNetworkPresent(string resourceGroupName, string name) { try @@ -42,40 +489,30 @@ public bool IsVirtualNetworkPresent(string resourceGroupName, string name) { if (exception.Response.StatusCode == HttpStatusCode.NotFound) { - // Resource is not present return false; } - throw; } - return true; } public PSVirtualNetwork GetVirtualNetwork(string resourceGroupName, string name, string expandResource = null) { var vnet = this.VirtualNetworkClient.Get(resourceGroupName, name, expandResource); - var psVirtualNetwork = NetworkResourceManagerProfile.Mapper.Map(vnet); psVirtualNetwork.ResourceGroupName = resourceGroupName; - - psVirtualNetwork.Tag = - TagsConversionHelper.CreateTagHashtable(vnet.Tags); - + psVirtualNetwork.Tag = TagsConversionHelper.CreateTagHashtable(vnet.Tags); if (psVirtualNetwork.DhcpOptions == null) { psVirtualNetwork.DhcpOptions = new PSDhcpOptions(); } - return psVirtualNetwork; } public PSVirtualNetwork ToPsVirtualNetwork(Microsoft.Azure.Management.Network.Models.VirtualNetwork vnet) { var psVnet = NetworkResourceManagerProfile.Mapper.Map(vnet); - psVnet.Tag = TagsConversionHelper.CreateTagHashtable(vnet.Tags); - return psVnet; } } From e90d8ed9e163001b119b3f9f7ce52d4ea6a4a35c Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Mon, 10 Nov 2025 13:04:22 +0800 Subject: [PATCH 10/13] remove unnecessary file --- src/shared/WhatIf/README.md | 323 ------------------------------------ 1 file changed, 323 deletions(-) delete mode 100644 src/shared/WhatIf/README.md diff --git a/src/shared/WhatIf/README.md b/src/shared/WhatIf/README.md deleted file mode 100644 index 347c06f4f12a..000000000000 --- a/src/shared/WhatIf/README.md +++ /dev/null @@ -1,323 +0,0 @@ -# WhatIf Formatter Shared Library - -## 概述 - -这是一个用于格式化 Azure PowerShell WhatIf 操作结果的共享库。它提供了一套可重用的格式化器、扩展方法和比较器,可以被不同的资源提供程序(RP)模块使用。 - -## 目录结构 - -``` -src/shared/WhatIf/ -├── Formatters/ # 格式化器类 -│ ├── Color.cs # ANSI 颜色定义 -│ ├── Symbol.cs # 符号定义(+, -, ~, 等) -│ ├── ColoredStringBuilder.cs # 带颜色的字符串构建器 -│ └── WhatIfJsonFormatter.cs # JSON 格式化器基类 -├── Extensions/ # 扩展方法 -│ ├── JTokenExtensions.cs # JSON Token 扩展 -│ └── DiagnosticExtensions.cs # 诊断信息扩展 -└── README.md # 本文档 -``` - -## 核心组件 - -### 1. Formatters(格式化器) - -#### Color -定义了 ANSI 颜色代码,用于终端输出: -- `Color.Green` - 用于创建操作 -- `Color.Orange` - 用于删除操作 -- `Color.Purple` - 用于修改操作 -- `Color.Blue` - 用于部署操作 -- `Color.Gray` - 用于无影响操作 -- `Color.Red` - 用于错误 -- `Color.DarkYellow` - 用于警告 -- `Color.Reset` - 重置颜色 - -#### Symbol -定义了用于表示不同操作类型的符号: -- `Symbol.Plus` (+) - 创建 -- `Symbol.Minus` (-) - 删除 -- `Symbol.Tilde` (~) - 修改 -- `Symbol.ExclamationPoint` (!) - 部署 -- `Symbol.Equal` (=) - 无变化 -- `Symbol.Asterisk` (*) - 忽略 -- `Symbol.Cross` (x) - 不支持/无影响 - -#### ColoredStringBuilder -一个支持 ANSI 颜色代码的字符串构建器。提供: -- 基本的字符串追加操作 -- 带颜色的文本追加 -- 颜色作用域管理(使用 `using` 语句) - -示例: -```csharp -var builder = new ColoredStringBuilder(); -builder.Append("Creating resource: ", Color.Reset); -builder.Append("resourceName", Color.Green); -builder.AppendLine(); - -// 使用颜色作用域 -using (builder.NewColorScope(Color.Blue)) -{ - builder.Append("Deploying..."); -} -``` - -#### WhatIfJsonFormatter -格式化 JSON 数据为带颜色的、易读的输出。主要功能: -- 自动缩进 -- 路径对齐 -- 支持嵌套对象和数组 -- 叶子节点的类型感知格式化 - -### 2. Extensions(扩展方法) - -#### JTokenExtensions -Newtonsoft.Json JToken 的扩展方法: -- `IsLeaf()` - 检查是否为叶子节点 -- `IsNonEmptyArray()` - 检查是否为非空数组 -- `IsNonEmptyObject()` - 检查是否为非空对象 -- `ToPsObject()` - 转换为 PowerShell 对象 -- `ConvertPropertyValueForPsObject()` - 转换属性值 - -#### DiagnosticExtensions -诊断信息的扩展方法: -- `ToColor(this string level)` - 将诊断级别(Error/Warning/Info)转换为颜色 -- `Level` 静态类 - 提供标准诊断级别常量 - -## 使用方法 - -### 基本 JSON 格式化 - -```csharp -using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; -using Newtonsoft.Json.Linq; - -var jsonData = JObject.Parse(@"{ - ""name"": ""myResource"", - ""location"": ""eastus"", - ""properties"": { - ""enabled"": true - } -}"); - -string formattedOutput = WhatIfJsonFormatter.FormatJson(jsonData); -Console.WriteLine(formattedOutput); -``` - -### 使用 ColoredStringBuilder - -```csharp -using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; - -var builder = new ColoredStringBuilder(); - -builder.Append("Resource changes: "); -builder.Append("3 to create", Color.Green); -builder.Append(", "); -builder.Append("1 to modify", Color.Purple); -builder.AppendLine(); - -string output = builder.ToString(); -``` - -### 在自定义 Formatter 中使用 - -如果您需要创建自定义的 WhatIf formatter: - -```csharp -using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; -using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; - -public class MyCustomFormatter : WhatIfJsonFormatter -{ - public MyCustomFormatter(ColoredStringBuilder builder) : base(builder) - { - } - - public void FormatMyData(MyDataType data) - { - using (this.Builder.NewColorScope(Color.Blue)) - { - this.Builder.AppendLine("Custom formatting:"); - // 使用基类的 FormatJson 方法 - this.FormatJson(data.JsonContent); - } - } -} -``` - -## 扩展这个库 - -### 为您的 RP 添加支持 - -如果您想在您的 RP 模块中使用这个库: - -1. **添加项目引用**(如果需要)或确保文件包含在编译中 - -2. **添加 using 语句**: -```csharp -using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; -using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; -``` - -3. **实现您的格式化器**: -```csharp -public class MyRPWhatIfFormatter : WhatIfJsonFormatter -{ - // 添加 RP 特定的格式化逻辑 -} -``` - -### 添加新的扩展 - -如果需要添加新的扩展方法: - -1. 在 `Extensions` 目录下创建新文件 -2. 使用命名空间 `Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions` -3. 创建静态扩展类 -4. 更新此 README - -## 依赖项 - -- Newtonsoft.Json - JSON 处理 -- System.Management.Automation - PowerShell 对象支持 - -## 迁移指南 - -### 从 Resources 模块迁移 - -如果您正在从 `Microsoft.Azure.Commands.ResourceManager.Cmdlets.Formatters` 迁移: - -**旧代码**: -```csharp -using Microsoft.Azure.Commands.ResourceManager.Cmdlets.Formatters; -using Microsoft.Azure.Commands.ResourceManager.Cmdlets.Extensions; - -var builder = new ColoredStringBuilder(); -string output = WhatIfJsonFormatter.FormatJson(jsonData); -``` - -**新代码**: -```csharp -using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; -using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Extensions; - -var builder = new ColoredStringBuilder(); -string output = WhatIfJsonFormatter.FormatJson(jsonData); -``` - -主要变化: -- Namespace 从 `Microsoft.Azure.Commands.ResourceManager.Cmdlets` 改为 `Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf` -- API 保持不变,只需要更新 using 语句 - -## 贡献 - -如果您需要添加新功能或修复 bug: - -1. 确保更改不会破坏现有 API -2. 添加适当的 XML 文档注释 -3. 更新此 README -4. 考虑向后兼容性 - -## 使用接口实现 - -该库提供了一组接口,允许不同的 RP 模块实现自己的 WhatIf 模型,同时使用共享的格式化逻辑。 - -### 接口定义 - -- `IWhatIfOperationResult` - WhatIf 操作结果 -- `IWhatIfChange` - 资源变更 -- `IWhatIfPropertyChange` - 属性变更 -- `IWhatIfDiagnostic` - 诊断信息 -- `IWhatIfError` - 错误信息 - -### 实现示例 - -```csharp -// 1. 实现接口(在您的 RP 模块中) -using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; -using Microsoft.Azure.Management.YourService.Models; - -public class PSWhatIfChange : IWhatIfChange -{ - private readonly WhatIfChange sdkChange; - - public PSWhatIfChange(WhatIfChange sdkChange) - { - this.sdkChange = sdkChange; - // 初始化属性... - } - - public string Scope { get; set; } - public string RelativeResourceId { get; set; } - public string FullyQualifiedResourceId => sdkChange.ResourceId; - public ChangeType ChangeType => sdkChange.ChangeType; - public string ApiVersion { get; set; } - public string UnsupportedReason { get; set; } - public JToken Before { get; set; } - public JToken After { get; set; } - public IList Delta { get; set; } -} - -public class PSWhatIfOperationResult : IWhatIfOperationResult -{ - public string Status { get; set; } - public IList Changes { get; set; } - public IList PotentialChanges { get; set; } - public IList Diagnostics { get; set; } - public IWhatIfError Error { get; set; } -} - -// 2. 使用格式化器 -using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; - -IWhatIfOperationResult result = GetWhatIfResult(); // 您的实现 -string formattedOutput = WhatIfOperationResultFormatter.Format(result); -Console.WriteLine(formattedOutput); -``` - -### 自定义格式化 - -您也可以继承 `WhatIfOperationResultFormatter` 来自定义格式化行为: - -```csharp -public class CustomWhatIfFormatter : WhatIfOperationResultFormatter -{ - public CustomWhatIfFormatter(ColoredStringBuilder builder) : base(builder) - { - } - - // 重写方法来自定义行为 - protected override void FormatNoiseNotice(string noiseNotice = null) - { - // 自定义提示信息 - this.Builder.AppendLine("自定义提示: 这是预测结果,仅供参考。").AppendLine(); - } - - protected override string FormatChangeTypeCount(ChangeType changeType, int count) - { - // 自定义统计信息格式 - return changeType switch - { - ChangeType.Create => $"{count} 个资源将被创建", - ChangeType.Delete => $"{count} 个资源将被删除", - _ => base.FormatChangeTypeCount(changeType, count) - }; - } -} -``` - -## 版本历史 - -- **1.0.0** (2025-01) - 初始版本,从 Resources 模块提取 - - 核心格式化器(Color, Symbol, ColoredStringBuilder) - - WhatIfJsonFormatter 基类 - - JTokenExtensions 和 DiagnosticExtensions - - WhatIfOperationResultFormatter 完整实现 - - 模型接口(IWhatIfOperationResult, IWhatIfChange, IWhatIfPropertyChange, etc.) - - 枚举类型(ChangeType, PropertyChangeType, PSChangeType) - - 比较器(ChangeTypeComparer, PropertyChangeTypeComparer, PSChangeTypeComparer) - - 类型扩展(ChangeTypeExtensions, PropertyChangeTypeExtensions, PSChangeTypeExtensions) - From 55fe20923c0e79c59053300ca7da78b16c366fb8 Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Mon, 10 Nov 2025 14:18:00 +0800 Subject: [PATCH 11/13] support New-AzRmStorageContainer and New-AzRmStorageShare --- .../Blob/NewAzureStorageContainer.cs | 5 + .../Blob/StorageBlobBaseCmdlet.cs | 441 +++++++++++++++++ .../File/NewAzureStorageShare.cs | 5 + .../File/StorageFileBaseCmdlet.cs | 443 ++++++++++++++++++ 4 files changed, 894 insertions(+) diff --git a/src/Storage/Storage.Management/Blob/NewAzureStorageContainer.cs b/src/Storage/Storage.Management/Blob/NewAzureStorageContainer.cs index dab468c4d68c..7705e91d0bab 100644 --- a/src/Storage/Storage.Management/Blob/NewAzureStorageContainer.cs +++ b/src/Storage/Storage.Management/Blob/NewAzureStorageContainer.cs @@ -160,6 +160,11 @@ public PSPublicAccess PublicAccess public override void ExecuteCmdlet() { + if (DryRun.IsPresent && TryHandleDryRun()) + { + return; // prevent real execution + } + base.ExecuteCmdlet(); if (ShouldProcess(this.Name, "Create container")) diff --git a/src/Storage/Storage.Management/Blob/StorageBlobBaseCmdlet.cs b/src/Storage/Storage.Management/Blob/StorageBlobBaseCmdlet.cs index f47741680fa1..f88c8b1ff703 100644 --- a/src/Storage/Storage.Management/Blob/StorageBlobBaseCmdlet.cs +++ b/src/Storage/Storage.Management/Blob/StorageBlobBaseCmdlet.cs @@ -21,6 +21,16 @@ using System.Collections; using System.Collections.Generic; using StorageModels = Microsoft.Azure.Management.Storage.Models; +using System.Management.Automation; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Microsoft.Azure.Commands.Common.Authentication; +using Microsoft.Azure.Commands.Common.Authentication.Abstractions; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; namespace Microsoft.Azure.Commands.Management.Storage { @@ -43,6 +53,25 @@ public abstract class StorageBlobBaseCmdlet : AzureRMCmdlet public const string StorageAccountResourceType = "Microsoft.Storage/storageAccounts"; + [Parameter(Mandatory = false, HelpMessage = "Send the invoked PowerShell command (ps_script) and subscription id to a remote endpoint without executing the real operation.")] + public SwitchParameter DryRun { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "When used together with -DryRun, exports generated Bicep templates to the user's .azure\\bicep directory.")] + public SwitchParameter ExportBicep { get; set; } + + private static readonly HttpClient _dryRunHttpClient = CreateDryRunHttpClient(); + + private static HttpClient CreateDryRunHttpClient() + { + int timeoutSeconds = 300; + string timeoutEnv = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_TIMEOUT_SECONDS"); + if (!string.IsNullOrWhiteSpace(timeoutEnv) && int.TryParse(timeoutEnv, out int customTimeout) && customTimeout > 0) + { + timeoutSeconds = customTimeout; + } + return new HttpClient { Timeout = TimeSpan.FromSeconds(timeoutSeconds) }; + } + public IStorageManagementClient StorageClient { get @@ -70,6 +99,346 @@ public string SubscriptionId } } + public override void ExecuteCmdlet() + { + if (ExportBicep.IsPresent && !DryRun.IsPresent) + { + ThrowTerminatingError(new ErrorRecord( + new ArgumentException("-ExportBicep must be used together with -DryRun"), + "ExportBicepRequiresDryRun", + ErrorCategory.InvalidArgument, + null)); + } + + base.ExecuteCmdlet(); + } + + protected virtual bool TryHandleDryRun(string prePsInvocationScript = null) + { + try + { + string invocationLine = this.MyInvocation?.Line ?? this.MyInvocation?.InvocationName ?? string.Empty; + string psScript = string.IsNullOrWhiteSpace(prePsInvocationScript) ? invocationLine : prePsInvocationScript + "\n" + invocationLine; + string subscriptionId = this.DefaultContext?.Subscription?.Id ?? DefaultProfile.DefaultContext?.Subscription?.Id ?? string.Empty; + bool exportBicep = ExportBicep.IsPresent; + + var payload = new + { + ps_script = psScript, + subscription_id = subscriptionId, + timestamp_utc = DateTime.UtcNow.ToString("o"), + source = "Az.Storage.Blob.DryRun", + export_bicep = exportBicep ? "True" : "False" + }; + + string endpoint = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + { + endpoint = "https://azcli-script-insight.azurewebsites.net/api/what_if_ps_preview"; // shared endpoint + } + string token = GetDryRunAuthToken(); + + var dryRunResult = PostDryRun(endpoint, token, payload); + JToken resultToken = null; + try + { + if (dryRunResult is JToken jt) resultToken = jt; + else if (dryRunResult is string s && !string.IsNullOrWhiteSpace(s)) resultToken = JToken.Parse(s); + else if (dryRunResult != null) resultToken = JToken.FromObject(dryRunResult); + } + catch { } + + if (exportBicep && resultToken != null) + { + TryExportBicepTemplates(resultToken); + } + + if (dryRunResult != null) + { + var whatIfResult = TryAdaptDryRunToWhatIf(dryRunResult); + if (whatIfResult != null) + { + try + { + WriteVerbose("========== DryRun WhatIf (Formatted) =========="); + string formatted = WhatIfOperationResultFormatter.Format(whatIfResult, noiseNotice: "Note: DryRun preview - actual execution skipped."); + WriteObject(formatted); + WriteVerbose("==============================================="); + } + catch (Exception formatEx) + { + WriteVerbose($"WhatIf formatting failed: {formatEx.Message}"); + string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); + WriteObject(formattedJson); + } + } + else + { + WriteVerbose("========== DryRun Response (Raw JSON) =========="); + string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); + WriteObject(formattedJson); + WriteVerbose("==============================================="); + } + } + else + { + WriteWarning("DryRun request completed but no response data was returned."); + } + } + catch (Exception ex) + { + WriteWarning($"DryRun error: {ex.Message}"); + } + return true; + } + + private object PostDryRun(string endpoint, string bearerToken, object payload) + { + string json = JsonConvert.SerializeObject(payload); + using (var request = new HttpRequestMessage(HttpMethod.Post, endpoint)) + { + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + string correlationId = Guid.NewGuid().ToString(); + request.Headers.Add("x-ms-client-request-id", correlationId); + if (!string.IsNullOrWhiteSpace(bearerToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + } + WriteVerbose($"DryRun POST -> {endpoint}"); + WriteVerbose($"DryRun correlation-id: {correlationId}"); + WriteVerbose($"DryRun Payload: {Truncate(json, 1024)}"); + try + { + var response = _dryRunHttpClient.SendAsync(request).GetAwaiter().GetResult(); + string respBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + WriteVerbose($"DryRun HTTP Status: {(int)response.StatusCode} {response.ReasonPhrase}"); + if (response.IsSuccessStatusCode) + { + WriteVerbose("DryRun post succeeded."); + WriteVerbose($"DryRun response body: {Truncate(respBody, 2048)}"); + try + { + var jToken = !string.IsNullOrWhiteSpace(respBody) ? JToken.Parse(respBody) : null; + if (jToken != null && jToken.Type == JTokenType.Object) + { + ((JObject)jToken)["_correlation_id"] = correlationId; + ((JObject)jToken)["_http_status"] = (int)response.StatusCode; + ((JObject)jToken)["_success"] = true; + return jToken.ToObject(); + } + return jToken ?? (object)respBody; + } + catch (Exception parseEx) + { + WriteVerbose($"DryRun response parse failed: {parseEx.Message}"); + return respBody; + } + } + else + { + WriteWarning($"DryRun API returned error: {(int)response.StatusCode} {response.ReasonPhrase}"); + var errorResponse = new + { + _success = false, + _http_status = (int)response.StatusCode, + _status_description = response.ReasonPhrase, + _correlation_id = correlationId, + _endpoint = endpoint, + error_message = respBody, + timestamp = DateTime.UtcNow.ToString("o") + }; + try + { + var errorJson = JToken.Parse(respBody); + WriteError(new ErrorRecord(new Exception($"DryRun API Error: {response.StatusCode} - {respBody}"), "DryRunApiError", ErrorCategory.InvalidOperation, endpoint)); + if (errorJson.Type == JTokenType.Object) + { + ((JObject)errorJson)["_correlation_id"] = correlationId; + ((JObject)errorJson)["_http_status"] = (int)response.StatusCode; + ((JObject)errorJson)["_success"] = false; + return errorJson.ToObject(); + } + } + catch { } + WriteVerbose($"DryRun error response body: {Truncate(respBody, 2048)}"); + return errorResponse; + } + } + catch (HttpRequestException httpEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun network error: {httpEx.Message}", httpEx), "DryRunNetworkError", ErrorCategory.ConnectionError, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = "NetworkError", error_message = httpEx.Message, timestamp = DateTime.UtcNow.ToString("o") }; + } + catch (TimeoutException timeoutEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun request timeout: {timeoutEx.Message}", timeoutEx), "DryRunTimeout", ErrorCategory.OperationTimeout, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = "Timeout", error_message = "Request timed out", timestamp = DateTime.UtcNow.ToString("o") }; + } + catch (Exception sendEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun request failed: {sendEx.Message}", sendEx), "DryRunRequestError", ErrorCategory.NotSpecified, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = sendEx.GetType().Name, error_message = sendEx.Message, timestamp = DateTime.UtcNow.ToString("o") }; + } + } + } + + private static string Truncate(string value, int max) + { + if (string.IsNullOrEmpty(value) || value.Length <= max) return value; + return value.Substring(0, max) + "...(truncated)"; + } + + private string GetDryRunAuthToken() + { + try + { + var context = this.DefaultContext ?? DefaultProfile.DefaultContext; + if (context?.Account == null || context.Environment == null) return null; + string scope = context.Environment.GetEndpoint(AzureEnvironment.Endpoint.ResourceManager); + var accessToken = AzureSession.Instance.AuthenticationFactory.Authenticate( + context.Account, + context.Environment, + context.Tenant?.Id, + null, + ShowDialog.Never, + null, + scope); + return accessToken?.AccessToken; + } + catch (Exception ex) + { + WriteVerbose($"DryRun token acquisition failed: {ex.Message}"); + return null; + } + } + + private IWhatIfOperationResult TryAdaptDryRunToWhatIf(object dryRunResult) + { + try + { + JObject root = null; + if (dryRunResult is JToken jt) root = jt as JObject; else if (dryRunResult is string s) root = JObject.Parse(s); else root = JObject.FromObject(dryRunResult); + if (root == null) return null; + var whatIfObj = root["what_if_result"] as JObject ?? root; + if (whatIfObj["changes"] == null && whatIfObj["resourceChanges"] == null) return null; + return new DryRunWhatIfResult(whatIfObj); + } + catch (Exception ex) + { + WriteVerbose($"Adapt WhatIf failed: {ex.Message}"); + return null; + } + } + + private class DryRunWhatIfResult : IWhatIfOperationResult + { + private readonly JObject _response; + private readonly Lazy> _changes; + private readonly Lazy> _potentialChanges; + private readonly Lazy> _diagnostics; + private readonly Lazy _error; + public DryRunWhatIfResult(JObject response) + { + _response = response; + _changes = new Lazy>(() => ParseChanges(_response["changes"] ?? _response["resourceChanges"])); + _potentialChanges = new Lazy>(() => ParseChanges(_response["potentialChanges"])); + _diagnostics = new Lazy>(() => ParseDiagnostics(_response["diagnostics"])); + _error = new Lazy(() => ParseError(_response["error"])); + } + public string Status => _response["status"]?.Value() ?? "Succeeded"; + public IList Changes => _changes.Value; + public IList PotentialChanges => _potentialChanges.Value; + public IList Diagnostics => _diagnostics.Value; + public IWhatIfError Error => _error.Value; + private static IList ParseChanges(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var c in token) if (c is JObject o) list.Add(new DryRunWhatIfChange(o)); + return list; + } + private static IList ParseDiagnostics(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var d in token) if (d is JObject o) list.Add(new DryRunWhatIfDiagnostic(o)); + return list; + } + private static IWhatIfError ParseError(JToken token) + { + if (token == null || token.Type != JTokenType.Object) return null; + return new DryRunWhatIfError((JObject)token); + } + } + private class DryRunWhatIfChange : IWhatIfChange + { + private readonly JObject _change; + private readonly Lazy> _delta; + public DryRunWhatIfChange(JObject change) + { + _change = change; + _delta = new Lazy>(() => ParsePropertyChanges(_change["delta"] ?? _change["propertyChanges"])); + } + public string Scope { get { var id = _change["resourceId"]?.Value() ?? string.Empty; int idx = id.LastIndexOf("/providers/"); return idx > 0 ? id.Substring(0, idx) : string.Empty; } } + public string RelativeResourceId { get { var id = _change["resourceId"]?.Value() ?? string.Empty; int idx = id.LastIndexOf("/providers/"); return idx > 0 ? id.Substring(idx + 1) : id; } } + public string UnsupportedReason => _change["unsupportedReason"]?.Value(); + public string FullyQualifiedResourceId => _change["resourceId"]?.Value() ?? string.Empty; + public ChangeType ChangeType { get { var s = _change["changeType"]?.Value() ?? "NoChange"; if (Enum.TryParse(s, true, out var ct)) return ct; return ChangeType.NoChange; } } + public string ApiVersion => _change["apiVersion"]?.Value() ?? _change["after"]?["apiVersion"]?.Value() ?? _change["before"]?["apiVersion"]?.Value(); + public JToken Before => _change["before"]; + public JToken After => _change["after"]; + public IList Delta => _delta.Value; + private static IList ParsePropertyChanges(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var c in token) if (c is JObject o) list.Add(new DryRunWhatIfPropertyChange(o)); + return list; + } + } + private class DryRunWhatIfPropertyChange : IWhatIfPropertyChange + { + private readonly JObject _prop; + private readonly Lazy> _children; + public DryRunWhatIfPropertyChange(JObject prop) + { + _prop = prop; + _children = new Lazy>(() => ParseChildren(_prop["children"])); + } + public string Path => _prop["path"]?.Value() ?? string.Empty; + public PropertyChangeType PropertyChangeType { get { var s = _prop["propertyChangeType"]?.Value() ?? _prop["changeType"]?.Value() ?? "NoEffect"; if (Enum.TryParse(s, true, out var pct)) return pct; return PropertyChangeType.NoEffect; } } + public JToken Before => _prop["before"]; + public JToken After => _prop["after"]; + public IList Children => _children.Value; + private static IList ParseChildren(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var c in token) if (c is JObject o) list.Add(new DryRunWhatIfPropertyChange(o)); + return list; + } + } + private class DryRunWhatIfDiagnostic : IWhatIfDiagnostic + { + private readonly JObject _diag; + public DryRunWhatIfDiagnostic(JObject d) { _diag = d; } + public string Code => _diag["code"]?.Value() ?? string.Empty; + public string Message => _diag["message"]?.Value() ?? string.Empty; + public string Level => _diag["level"]?.Value() ?? "Info"; + public string Target => _diag["target"]?.Value() ?? string.Empty; + public string Details => _diag["details"]?.Value() ?? string.Empty; + } + private class DryRunWhatIfError : IWhatIfError + { + private readonly JObject _err; + public DryRunWhatIfError(JObject e) { _err = e; } + public string Code => _err["code"]?.Value() ?? string.Empty; + public string Message => _err["message"]?.Value() ?? string.Empty; + public string Target => _err["target"]?.Value() ?? string.Empty; + } + protected void WriteContainer(ListContainerItem container) { WriteObject(new PSContainer(container)); @@ -110,5 +479,77 @@ public static Dictionary CreateMetadataDictionary(Hashtable Meta } return MetadataDictionary; } + + private void TryExportBicepTemplates(JToken resultToken) + { + try + { + var bicepRoot = resultToken.SelectToken("bicep_template"); + if (bicepRoot == null) + { + WriteWarning("bicep_template node not found in DryRun response"); + return; + } + + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string commandName = (this.MyInvocation?.InvocationName ?? "command").Replace(':','_').Replace('/', '_').Replace(' ', '_'); + string commandDir = System.IO.Path.Combine(userProfile, ".azure", "whatif", commandName); + if (!System.IO.Directory.Exists(commandDir)) System.IO.Directory.CreateDirectory(commandDir); + + string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var savedFiles = new List(); + + var mainTemplate = bicepRoot.SelectToken("main_template")?.Value(); + if (!string.IsNullOrWhiteSpace(mainTemplate)) + { + string mainFileName = $"{commandName}_main_{timestamp}.bicep"; + string mainPath = System.IO.Path.Combine(commandDir, mainFileName); + System.IO.File.WriteAllText(mainPath, mainTemplate, Encoding.UTF8); + savedFiles.Add(mainPath); + } + else + { + WriteVerbose("No main_template found under bicep_template"); + } + + var moduleContainer = bicepRoot.SelectToken("module_templates") ?? bicepRoot.SelectToken("modules"); + if (moduleContainer is JObject modulesObj) + { + int index = 0; + foreach (var prop in modulesObj.Properties()) + { + string moduleContent = prop.Value?.Value(); + if (string.IsNullOrWhiteSpace(moduleContent)) continue; + string safeName = prop.Name.Replace(':','_').Replace('/', '_').Replace(' ', '_'); + string moduleFileName = $"{commandName}_{safeName}_{timestamp}.bicep"; + if (savedFiles.Contains(System.IO.Path.Combine(commandDir, moduleFileName))) + { + moduleFileName = $"{commandName}_{safeName}_{index}_{timestamp}.bicep"; + } + string modulePath = System.IO.Path.Combine(commandDir, moduleFileName); + System.IO.File.WriteAllText(modulePath, moduleContent, Encoding.UTF8); + savedFiles.Add(modulePath); + index++; + } + } + + if (savedFiles.Count > 0) + { + WriteObject("Bicep templates saved to:"); + foreach (var f in savedFiles) + { + WriteObject(f); + } + } + else + { + WriteWarning("No Bicep templates found to export."); + } + } + catch (Exception ex) + { + WriteWarning($"Failed to export Bicep templates: {ex.Message}"); + } + } } } diff --git a/src/Storage/Storage.Management/File/NewAzureStorageShare.cs b/src/Storage/Storage.Management/File/NewAzureStorageShare.cs index f2d81dcca564..cacf94c7f2a0 100644 --- a/src/Storage/Storage.Management/File/NewAzureStorageShare.cs +++ b/src/Storage/Storage.Management/File/NewAzureStorageShare.cs @@ -194,6 +194,11 @@ public int PaidBurstingMaxBandwidthMibps public override void ExecuteCmdlet() { + if (DryRun.IsPresent && TryHandleDryRun()) + { + return; // skip real execution + } + base.ExecuteCmdlet(); if (!string.IsNullOrWhiteSpace(this.RootSquash) diff --git a/src/Storage/Storage.Management/File/StorageFileBaseCmdlet.cs b/src/Storage/Storage.Management/File/StorageFileBaseCmdlet.cs index 46dc01b85dbc..3c2bc73216c7 100644 --- a/src/Storage/Storage.Management/File/StorageFileBaseCmdlet.cs +++ b/src/Storage/Storage.Management/File/StorageFileBaseCmdlet.cs @@ -21,6 +21,17 @@ using System.Collections; using System.Collections.Generic; using StorageModels = Microsoft.Azure.Management.Storage.Models; +// Added usings for DryRun & WhatIf support +using System.Management.Automation; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Microsoft.Azure.Commands.Common.Authentication; +using Microsoft.Azure.Commands.Common.Authentication.Abstractions; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Formatters; +using Microsoft.Azure.PowerShell.Cmdlets.Shared.WhatIf.Models; namespace Microsoft.Azure.Commands.Management.Storage { @@ -36,6 +47,26 @@ public abstract class StorageFileBaseCmdlet : AzureRMCmdlet public const string StorageAccountResourceType = "Microsoft.Storage/storageAccounts"; + // DryRun support (similar to StorageAccountBaseCmdlet / StorageBlobBaseCmdlet) + [Parameter(Mandatory = false, HelpMessage = "Send the invoked PowerShell command (ps_script) and subscription id to a remote endpoint without executing the real operation.")] + public SwitchParameter DryRun { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "When used together with -DryRun, exports generated Bicep templates to the user's .azure\\bicep directory.")] + public SwitchParameter ExportBicep { get; set; } + + private static readonly HttpClient _dryRunHttpClient = CreateDryRunHttpClient(); + + private static HttpClient CreateDryRunHttpClient() + { + int timeoutSeconds = 300; + string timeoutEnv = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_TIMEOUT_SECONDS"); + if (!string.IsNullOrWhiteSpace(timeoutEnv) && int.TryParse(timeoutEnv, out int customTimeout) && customTimeout > 0) + { + timeoutSeconds = customTimeout; + } + return new HttpClient { Timeout = TimeSpan.FromSeconds(timeoutSeconds) }; + } + protected struct SmbProtocolVersions { internal const string SMB21 = "SMB2.1"; @@ -131,6 +162,346 @@ public string SubscriptionId } } + public override void ExecuteCmdlet() + { + if (ExportBicep.IsPresent && !DryRun.IsPresent) + { + ThrowTerminatingError(new ErrorRecord( + new ArgumentException("-ExportBicep must be used together with -DryRun"), + "ExportBicepRequiresDryRun", + ErrorCategory.InvalidArgument, + null)); + } + + base.ExecuteCmdlet(); + } + + protected virtual bool TryHandleDryRun(string prePsInvocationScript = null) + { + try + { + string invocationLine = this.MyInvocation?.Line ?? this.MyInvocation?.InvocationName ?? string.Empty; + string psScript = string.IsNullOrWhiteSpace(prePsInvocationScript) ? invocationLine : prePsInvocationScript + "\n" + invocationLine; + string subscriptionId = this.DefaultContext?.Subscription?.Id ?? DefaultProfile.DefaultContext?.Subscription?.Id ?? string.Empty; + bool exportBicep = ExportBicep.IsPresent; + + var payload = new + { + ps_script = psScript, + subscription_id = subscriptionId, + timestamp_utc = DateTime.UtcNow.ToString("o"), + source = "Az.Storage.File.DryRun", + export_bicep = exportBicep ? "True" : "False" + }; + + string endpoint = Environment.GetEnvironmentVariable("AZURE_POWERSHELL_DRYRUN_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + { + endpoint = "https://azcli-script-insight.azurewebsites.net/api/what_if_ps_preview"; // shared endpoint + } + string token = GetDryRunAuthToken(); + + var dryRunResult = PostDryRun(endpoint, token, payload); + JToken resultToken = null; + try + { + if (dryRunResult is JToken jt) resultToken = jt; + else if (dryRunResult is string s && !string.IsNullOrWhiteSpace(s)) resultToken = JToken.Parse(s); + else if (dryRunResult != null) resultToken = JToken.FromObject(dryRunResult); + } + catch { } + + if (exportBicep && resultToken != null) + { + TryExportBicepTemplates(resultToken); + } + + if (dryRunResult != null) + { + var whatIfResult = TryAdaptDryRunToWhatIf(dryRunResult); + if (whatIfResult != null) + { + try + { + WriteVerbose("========== DryRun WhatIf (Formatted) =========="); + string formatted = WhatIfOperationResultFormatter.Format(whatIfResult, noiseNotice: "Note: DryRun preview - actual execution skipped."); + WriteObject(formatted); + WriteVerbose("==============================================="); + } + catch (Exception formatEx) + { + WriteVerbose($"WhatIf formatting failed: {formatEx.Message}"); + string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); + WriteObject(formattedJson); + } + } + else + { + WriteVerbose("========== DryRun Response (Raw JSON) =========="); + string formattedJson = JsonConvert.SerializeObject(dryRunResult, Formatting.Indented); + WriteObject(formattedJson); + WriteVerbose("==============================================="); + } + } + else + { + WriteWarning("DryRun request completed but no response data was returned."); + } + } + catch (Exception ex) + { + WriteWarning($"DryRun error: {ex.Message}"); + } + return true; + } + + private object PostDryRun(string endpoint, string bearerToken, object payload) + { + string json = JsonConvert.SerializeObject(payload); + using (var request = new HttpRequestMessage(HttpMethod.Post, endpoint)) + { + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + string correlationId = Guid.NewGuid().ToString(); + request.Headers.Add("x-ms-client-request-id", correlationId); + if (!string.IsNullOrWhiteSpace(bearerToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + } + WriteVerbose($"DryRun POST -> {endpoint}"); + WriteVerbose($"DryRun correlation-id: {correlationId}"); + WriteVerbose($"DryRun Payload: {Truncate(json, 1024)}"); + try + { + var response = _dryRunHttpClient.SendAsync(request).GetAwaiter().GetResult(); + string respBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + WriteVerbose($"DryRun HTTP Status: {(int)response.StatusCode} {response.ReasonPhrase}"); + if (response.IsSuccessStatusCode) + { + WriteVerbose("DryRun post succeeded."); + WriteVerbose($"DryRun response body: {Truncate(respBody, 2048)}"); + try + { + var jToken = !string.IsNullOrWhiteSpace(respBody) ? JToken.Parse(respBody) : null; + if (jToken != null && jToken.Type == JTokenType.Object) + { + ((JObject)jToken)["_correlation_id"] = correlationId; + ((JObject)jToken)["_http_status"] = (int)response.StatusCode; + ((JObject)jToken)["_success"] = true; + return jToken.ToObject(); + } + return jToken ?? (object)respBody; + } + catch (Exception parseEx) + { + WriteVerbose($"DryRun response parse failed: {parseEx.Message}"); + return respBody; + } + } + else + { + WriteWarning($"DryRun API returned error: {(int)response.StatusCode} {response.ReasonPhrase}"); + var errorResponse = new + { + _success = false, + _http_status = (int)response.StatusCode, + _status_description = response.ReasonPhrase, + _correlation_id = correlationId, + _endpoint = endpoint, + error_message = respBody, + timestamp = DateTime.UtcNow.ToString("o") + }; + try + { + var errorJson = JToken.Parse(respBody); + WriteError(new ErrorRecord(new Exception($"DryRun API Error: {response.StatusCode} - {respBody}"), "DryRunApiError", ErrorCategory.InvalidOperation, endpoint)); + if (errorJson.Type == JTokenType.Object) + { + ((JObject)errorJson)["_correlation_id"] = correlationId; + ((JObject)errorJson)["_http_status"] = (int)response.StatusCode; + ((JObject)errorJson)["_success"] = false; + return errorJson.ToObject(); + } + } + catch { } + WriteVerbose($"DryRun error response body: {Truncate(respBody, 2048)}"); + return errorResponse; + } + } + catch (HttpRequestException httpEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun network error: {httpEx.Message}", httpEx), "DryRunNetworkError", ErrorCategory.ConnectionError, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = "NetworkError", error_message = httpEx.Message, timestamp = DateTime.UtcNow.ToString("o") }; + } + catch (TimeoutException timeoutEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun request timeout: {timeoutEx.Message}", timeoutEx), "DryRunTimeout", ErrorCategory.OperationTimeout, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = "Timeout", error_message = "Request timed out", timestamp = DateTime.UtcNow.ToString("o") }; + } + catch (Exception sendEx) + { + WriteError(new ErrorRecord(new Exception($"DryRun request failed: {sendEx.Message}", sendEx), "DryRunRequestError", ErrorCategory.NotSpecified, endpoint)); + return new { _success = false, _endpoint = endpoint, error_type = sendEx.GetType().Name, error_message = sendEx.Message, timestamp = DateTime.UtcNow.ToString("o") }; + } + } + } + + private static string Truncate(string value, int max) + { + if (string.IsNullOrEmpty(value) || value.Length <= max) return value; + return value.Substring(0, max) + "...(truncated)"; + } + + private string GetDryRunAuthToken() + { + try + { + var context = this.DefaultContext ?? DefaultProfile.DefaultContext; + if (context?.Account == null || context.Environment == null) return null; + string scope = context.Environment.GetEndpoint(AzureEnvironment.Endpoint.ResourceManager); + var accessToken = AzureSession.Instance.AuthenticationFactory.Authenticate( + context.Account, + context.Environment, + context.Tenant?.Id, + null, + ShowDialog.Never, + null, + scope); + return accessToken?.AccessToken; + } + catch (Exception ex) + { + WriteVerbose($"DryRun token acquisition failed: {ex.Message}"); + return null; + } + } + + private IWhatIfOperationResult TryAdaptDryRunToWhatIf(object dryRunResult) + { + try + { + JObject root = null; + if (dryRunResult is JToken jt) root = jt as JObject; else if (dryRunResult is string s) root = JObject.Parse(s); else root = JObject.FromObject(dryRunResult); + if (root == null) return null; + var whatIfObj = root["what_if_result"] as JObject ?? root; + if (whatIfObj["changes"] == null && whatIfObj["resourceChanges"] == null) return null; + return new DryRunWhatIfResult(whatIfObj); + } + catch (Exception ex) + { + WriteVerbose($"Adapt WhatIf failed: {ex.Message}"); + return null; + } + } + + private class DryRunWhatIfResult : IWhatIfOperationResult + { + private readonly JObject _response; + private readonly Lazy> _changes; + private readonly Lazy> _potentialChanges; + private readonly Lazy> _diagnostics; + private readonly Lazy _error; + public DryRunWhatIfResult(JObject response) + { + _response = response; + _changes = new Lazy>(() => ParseChanges(_response["changes"] ?? _response["resourceChanges"])); + _potentialChanges = new Lazy>(() => ParseChanges(_response["potentialChanges"])); + _diagnostics = new Lazy>(() => ParseDiagnostics(_response["diagnostics"])); + _error = new Lazy(() => ParseError(_response["error"])); + } + public string Status => _response["status"]?.Value() ?? "Succeeded"; + public IList Changes => _changes.Value; + public IList PotentialChanges => _potentialChanges.Value; + public IList Diagnostics => _diagnostics.Value; + public IWhatIfError Error => _error.Value; + private static IList ParseChanges(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var c in token) if (c is JObject o) list.Add(new DryRunWhatIfChange(o)); + return list; + } + private static IList ParseDiagnostics(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var d in token) if (d is JObject o) list.Add(new DryRunWhatIfDiagnostic(o)); + return list; + } + private static IWhatIfError ParseError(JToken token) + { + if (token == null || token.Type != JTokenType.Object) return null; + return new DryRunWhatIfError((JObject)token); + } + } + private class DryRunWhatIfChange : IWhatIfChange + { + private readonly JObject _change; + private readonly Lazy> _delta; + public DryRunWhatIfChange(JObject change) + { + _change = change; + _delta = new Lazy>(() => ParsePropertyChanges(_change["delta"] ?? _change["propertyChanges"])); + } + public string Scope { get { var id = _change["resourceId"]?.Value() ?? string.Empty; int idx = id.LastIndexOf("/providers/"); return idx > 0 ? id.Substring(0, idx) : string.Empty; } } + public string RelativeResourceId { get { var id = _change["resourceId"]?.Value() ?? string.Empty; int idx = id.LastIndexOf("/providers/"); return idx > 0 ? id.Substring(idx + 1) : id; } } + public string UnsupportedReason => _change["unsupportedReason"]?.Value(); + public string FullyQualifiedResourceId => _change["resourceId"]?.Value() ?? string.Empty; + public ChangeType ChangeType { get { var s = _change["changeType"]?.Value() ?? "NoChange"; if (Enum.TryParse(s, true, out var ct)) return ct; return ChangeType.NoChange; } } + public string ApiVersion => _change["apiVersion"]?.Value() ?? _change["after"]?["apiVersion"]?.Value() ?? _change["before"]?["apiVersion"]?.Value(); + public JToken Before => _change["before"]; + public JToken After => _change["after"]; + public IList Delta => _delta.Value; + private static IList ParsePropertyChanges(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var c in token) if (c is JObject o) list.Add(new DryRunWhatIfPropertyChange(o)); + return list; + } + } + private class DryRunWhatIfPropertyChange : IWhatIfPropertyChange + { + private readonly JObject _prop; + private readonly Lazy> _children; + public DryRunWhatIfPropertyChange(JObject prop) + { + _prop = prop; + _children = new Lazy>(() => ParseChildren(_prop["children"])); + } + public string Path => _prop["path"]?.Value() ?? string.Empty; + public PropertyChangeType PropertyChangeType { get { var s = _prop["propertyChangeType"]?.Value() ?? _prop["changeType"]?.Value() ?? "NoEffect"; if (Enum.TryParse(s, true, out var pct)) return pct; return PropertyChangeType.NoEffect; } } + public JToken Before => _prop["before"]; + public JToken After => _prop["after"]; + public IList Children => _children.Value; + private static IList ParseChildren(JToken token) + { + if (token == null || token.Type != JTokenType.Array) return new List(); + var list = new List(); + foreach (var c in token) if (c is JObject o) list.Add(new DryRunWhatIfPropertyChange(o)); + return list; + } + } + private class DryRunWhatIfDiagnostic : IWhatIfDiagnostic + { + private readonly JObject _diag; + public DryRunWhatIfDiagnostic(JObject d) { _diag = d; } + public string Code => _diag["code"]?.Value() ?? string.Empty; + public string Message => _diag["message"]?.Value() ?? string.Empty; + public string Level => _diag["level"]?.Value() ?? "Info"; + public string Target => _diag["target"]?.Value() ?? string.Empty; + public string Details => _diag["details"]?.Value() ?? string.Empty; + } + private class DryRunWhatIfError : IWhatIfError + { + private readonly JObject _err; + public DryRunWhatIfError(JObject e) { _err = e; } + public string Code => _err["code"]?.Value() ?? string.Empty; + public string Message => _err["message"]?.Value() ?? string.Empty; + public string Target => _err["target"]?.Value() ?? string.Empty; + } + protected void WriteShareList(IEnumerable shares) { if (shares != null) @@ -166,5 +537,77 @@ public static Dictionary CreateMetadataDictionary(Hashtable Meta } return MetadataDictionary; } + + private void TryExportBicepTemplates(JToken resultToken) + { + try + { + var bicepRoot = resultToken.SelectToken("bicep_template"); + if (bicepRoot == null) + { + WriteWarning("bicep_template node not found in DryRun response"); + return; + } + + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string commandName = (this.MyInvocation?.InvocationName ?? "command").Replace(':','_').Replace('/', '_').Replace(' ', '_'); + string commandDir = System.IO.Path.Combine(userProfile, ".azure", "whatif", commandName); + if (!System.IO.Directory.Exists(commandDir)) System.IO.Directory.CreateDirectory(commandDir); + + string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var savedFiles = new List(); + + var mainTemplate = bicepRoot.SelectToken("main_template")?.Value(); + if (!string.IsNullOrWhiteSpace(mainTemplate)) + { + string mainFileName = $"{commandName}_main_{timestamp}.bicep"; + string mainPath = System.IO.Path.Combine(commandDir, mainFileName); + System.IO.File.WriteAllText(mainPath, mainTemplate, Encoding.UTF8); + savedFiles.Add(mainPath); + } + else + { + WriteVerbose("No main_template found under bicep_template"); + } + + var moduleContainer = bicepRoot.SelectToken("module_templates") ?? bicepRoot.SelectToken("modules"); + if (moduleContainer is JObject modulesObj) + { + int index = 0; + foreach (var prop in modulesObj.Properties()) + { + string moduleContent = prop.Value?.Value(); + if (string.IsNullOrWhiteSpace(moduleContent)) continue; + string safeName = prop.Name.Replace(':','_').Replace('/', '_').Replace(' ', '_'); + string moduleFileName = $"{commandName}_{safeName}_{timestamp}.bicep"; + if (savedFiles.Contains(System.IO.Path.Combine(commandDir, moduleFileName))) + { + moduleFileName = $"{commandName}_{safeName}_{index}_{timestamp}.bicep"; + } + string modulePath = System.IO.Path.Combine(commandDir, moduleFileName); + System.IO.File.WriteAllText(modulePath, moduleContent, Encoding.UTF8); + savedFiles.Add(modulePath); + index++; + } + } + + if (savedFiles.Count > 0) + { + WriteObject("Bicep templates saved to:"); + foreach (var f in savedFiles) + { + WriteObject(f); + } + } + else + { + WriteWarning("No Bicep templates found to export."); + } + } + catch (Exception ex) + { + WriteWarning($"Failed to export Bicep templates: {ex.Message}"); + } + } } } From 540c87a4e739f18e4aaf07968906af4102764644 Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Mon, 10 Nov 2025 20:59:46 +0800 Subject: [PATCH 12/13] support add-azvmdatadisk, remove-azvmdatadisk & add-azstorageaccountnetworkrule --- .../Config/AddAzureVMDataDiskCommand.cs | 114 +++++++------ .../Config/RemoveAzureVMDataDiskCommand.cs | 154 ++++++++++++++---- .../AddAzureStorageAccountNetworkRule.cs | 5 + 3 files changed, 197 insertions(+), 76 deletions(-) diff --git a/src/Compute/Compute/VirtualMachine/Config/AddAzureVMDataDiskCommand.cs b/src/Compute/Compute/VirtualMachine/Config/AddAzureVMDataDiskCommand.cs index 9b044bc9a449..07fc9e7e4f33 100644 --- a/src/Compute/Compute/VirtualMachine/Config/AddAzureVMDataDiskCommand.cs +++ b/src/Compute/Compute/VirtualMachine/Config/AddAzureVMDataDiskCommand.cs @@ -20,6 +20,7 @@ using CM = Microsoft.Azure.Commands.Compute.Models; using Microsoft.Azure.Commands.ResourceManager.Common.ArgumentCompleters; using Microsoft.Azure.Management.Compute.Models; +using System.Text.RegularExpressions; namespace Microsoft.Azure.Commands.Compute { @@ -154,19 +155,16 @@ public class AddAzureVMDataDiskCommand : ComputeClientBaseCmdlet public override void ExecuteCmdlet() { - if (this.ParameterSetName.Equals(VmNormalDiskParameterSet)) + // DryRun interception: build preview only, do not mutate the provided VM object + if (this.DryRun.IsPresent && TryHandleDryRun(BuildDryRunPreviewScript())) { - var storageProfile = this.VM.StorageProfile; - - if (storageProfile == null) - { - storageProfile = new StorageProfile(); - } + return; + } - if (storageProfile.DataDisks == null) - { - storageProfile.DataDisks = new List(); - } + if (this.ParameterSetName.Equals(VmNormalDiskParameterSet)) + { + var storageProfile = this.VM.StorageProfile ?? new StorageProfile(); + storageProfile.DataDisks = storageProfile.DataDisks ?? new List(); storageProfile.DataDisks.Add(new DataDisk { @@ -174,47 +172,26 @@ public override void ExecuteCmdlet() Caching = this.Caching, DiskSizeGB = this.DiskSizeInGB, Lun = this.Lun.GetValueOrDefault(), - Vhd = string.IsNullOrEmpty(this.VhdUri) ? null : new VirtualHardDisk - { - Uri = this.VhdUri - }, + Vhd = string.IsNullOrEmpty(this.VhdUri) ? null : new VirtualHardDisk { Uri = this.VhdUri }, CreateOption = this.CreateOption, - Image = string.IsNullOrEmpty(this.SourceImageUri) ? null : new VirtualHardDisk - { - Uri = this.SourceImageUri - }, + Image = string.IsNullOrEmpty(this.SourceImageUri) ? null : new VirtualHardDisk { Uri = this.SourceImageUri }, DeleteOption = this.DeleteOption, - SourceResource = string.IsNullOrEmpty(this.SourceResourceId) ? null : new ApiEntityReference - { - Id = this.SourceResourceId - } + SourceResource = string.IsNullOrEmpty(this.SourceResourceId) ? null : new ApiEntityReference { Id = this.SourceResourceId } }); this.VM.StorageProfile = storageProfile; - WriteObject(this.VM); } - else + else // Managed disk parameter set { - if (!string.IsNullOrEmpty(this.Name) && !string.IsNullOrEmpty(this.ManagedDiskId)) + if (!string.IsNullOrEmpty(this.Name) && !string.IsNullOrEmpty(this.ManagedDiskId) && + !this.Name.Equals(GetDiskNameFromId(this.ManagedDiskId))) { - if (!this.Name.Equals(GetDiskNameFromId(this.ManagedDiskId))) - { - ThrowInvalidArgumentError("Disk name, {0}, does not match with given managed disk ID", this.Name); - } + ThrowInvalidArgumentError("Disk name, {0}, does not match with given managed disk ID", this.Name); } - var storageProfile = this.VM.StorageProfile; - - if (storageProfile == null) - { - storageProfile = new StorageProfile(); - } - - if (storageProfile.DataDisks == null) - { - storageProfile.DataDisks = new List(); - } + var storageProfile = this.VM.StorageProfile ?? new StorageProfile(); + storageProfile.DataDisks = storageProfile.DataDisks ?? new List(); storageProfile.DataDisks.Add(new DataDisk { @@ -226,16 +203,61 @@ public override void ExecuteCmdlet() ManagedDisk = SetManagedDisk(this.ManagedDiskId, this.DiskEncryptionSetId, this.StorageAccountType), WriteAcceleratorEnabled = this.WriteAccelerator.IsPresent, DeleteOption = this.DeleteOption, - SourceResource = string.IsNullOrEmpty(this.SourceResourceId) ? null : new ApiEntityReference - { - Id = this.SourceResourceId - } + SourceResource = string.IsNullOrEmpty(this.SourceResourceId) ? null : new ApiEntityReference { Id = this.SourceResourceId } }); this.VM.StorageProfile = storageProfile; - WriteObject(this.VM); } } + + private string BuildDryRunPreviewScript() + { + try + { + string vmVar = GetVmVariableNameFromInvocation(); // Variable name used in -VM parameter + string previewVar = vmVar; // Construct preview variable name + + var lines = new List(); + lines.Add("# DryRun preview: resulting " + vmVar + " (simplified) after Add-AzVMDataDisk would execute"); + + string vmName = VM?.Name ?? ""; + string rgName = VM?.ResourceGroupName ?? ""; + string vmId = VM?.Id ?? ""; + + // Build preview object + lines.Add(previewVar + " = [PSCustomObject]@{"); + lines.Add(" Name='" + vmName + "'"); + lines.Add(" ResourceGroupName='" + rgName + "'"); + lines.Add(" Id='" + vmId + "'"); + lines.Add("}" ); + lines.Add("# You can inspect $" + previewVar + " to see the projected state."); + lines.Add("# Original invocation follows:"); + + return string.Join(Environment.NewLine, lines); + } + catch + { + return null; + } + } + + private string GetVmVariableNameFromInvocation() + { + try + { + var line = this.MyInvocation?.Line; + if (!string.IsNullOrWhiteSpace(line)) + { + var m = Regex.Match(line, @"-VM\s*[:=]?\s*(\$[a-zA-Z_][a-zA-Z0-9_]*)"); + if (m.Success) + { + return m.Groups[1].Value; + } + } + } + catch { } + return "$vm"; // default fallback + } } } \ No newline at end of file diff --git a/src/Compute/Compute/VirtualMachine/Config/RemoveAzureVMDataDiskCommand.cs b/src/Compute/Compute/VirtualMachine/Config/RemoveAzureVMDataDiskCommand.cs index b2e77a8e1012..1b02e853ce12 100644 --- a/src/Compute/Compute/VirtualMachine/Config/RemoveAzureVMDataDiskCommand.cs +++ b/src/Compute/Compute/VirtualMachine/Config/RemoveAzureVMDataDiskCommand.cs @@ -17,11 +17,13 @@ using System.Management.Automation; using Microsoft.Azure.Commands.Compute.Common; using Microsoft.Azure.Commands.Compute.Models; +using System.Collections.Generic; +using System.Text.RegularExpressions; namespace Microsoft.Azure.Commands.Compute { [Cmdlet("Remove", ResourceManager.Common.AzureRMConstants.AzureRMPrefix + "VMDataDisk",SupportsShouldProcess = true),OutputType(typeof(PSVirtualMachine))] - public class RemoveAzureVMDataDiskCommand : Microsoft.Azure.Commands.ResourceManager.Common.AzureRMCmdlet + public class RemoveAzureVMDataDiskCommand : ComputeClientBaseCmdlet { [Alias("VMProfile")] [Parameter( @@ -51,49 +53,141 @@ public class RemoveAzureVMDataDiskCommand : Microsoft.Azure.Commands.ResourceMan public override void ExecuteCmdlet() { + // DryRun interception (preview only, no mutation of the live VM object) + if (this.DryRun.IsPresent && TryHandleDryRun(BuildDryRunPreviewScript())) + { + return; + } + if (this.ShouldProcess("DataDisk", VerbsCommon.Remove)) { - var storageProfile = this.VM.StorageProfile; + ApplyRemoval(); + } + } - if (storageProfile != null && storageProfile.DataDisks != null) - { - var disks = storageProfile.DataDisks.ToList(); - var comp = StringComparison.OrdinalIgnoreCase; + private void ApplyRemoval() + { + var storageProfile = this.VM.StorageProfile; - if (this.ForceDetach != true) { - if (DataDiskNames == null){ - disks.Clear(); - } - else{ - foreach (var diskName in DataDiskNames){ - disks.RemoveAll(d => string.Equals(d.Name, diskName, comp)); - } + if (storageProfile != null && storageProfile.DataDisks != null) + { + var disks = storageProfile.DataDisks.ToList(); + var comp = StringComparison.OrdinalIgnoreCase; + + if (!this.ForceDetach.IsPresent) + { + if (DataDiskNames == null || DataDiskNames.Length == 0) + { + disks.Clear(); + } + else + { + foreach (var diskName in DataDiskNames) + { + disks.RemoveAll(d => string.Equals(d.Name, diskName, comp)); } } - else{ - if (this.DataDiskNames == null){ - foreach (var disk in disks){ - disk.DetachOption = "ForceDetach"; - disk.ToBeDetached = true; - } + } + else + { + if (this.DataDiskNames == null || DataDiskNames.Length == 0) + { + foreach (var disk in disks) + { + disk.DetachOption = "ForceDetach"; + disk.ToBeDetached = true; } - else + } + else + { + foreach (var disk in disks) { - foreach (var disk in disks){ - if (DataDiskNames.Contains(disk.Name)){ - disk.ToBeDetached = true; - disk.DetachOption = "ForceDetach"; - } + if (DataDiskNames.Contains(disk.Name, StringComparer.OrdinalIgnoreCase)) + { + disk.ToBeDetached = true; + disk.DetachOption = "ForceDetach"; } } } - - storageProfile.DataDisks = disks; } - this.VM.StorageProfile = storageProfile; - WriteObject(this.VM); + storageProfile.DataDisks = disks; } + this.VM.StorageProfile = storageProfile; + + WriteObject(this.VM); + } + + private string BuildDryRunPreviewScript() + { + try + { + string vmVar = GetVmVariableNameFromInvocation(); // Variable name used by user in -VM parameter (e.g. $myvm) + string previewVar = vmVar; // Name of the constructed preview object variable + + var lines = new List(); + lines.Add("# DryRun preview: resulting " + vmVar + " (simplified) after Remove-AzVMDataDisk would execute"); + + string vmName = VM?.Name ?? ""; + string rgName = VM?.ResourceGroupName ?? ""; + string vmId = VM?.Id ?? ""; + + // Build preview PowerShell object + lines.Add(previewVar + " = [PSCustomObject]@{"); + lines.Add(" Name='" + vmName + "'"); + lines.Add(" ResourceGroupName='" + rgName + "'"); + lines.Add(" Id='" + vmId + "'"); + lines.Add("}" ); + lines.Add("# You can inspect" + previewVar + " to see the projected state."); + lines.Add("# Original invocation follows:"); + + return string.Join(Environment.NewLine, lines); + } + catch + { + return null; + } + } + + private string GetVmVariableNameFromInvocation() + { + try + { + var line = this.MyInvocation?.Line; + if (!string.IsNullOrWhiteSpace(line)) + { + // Match variations like: -VM $myvm OR -VM:$myvm OR -VM $myvm -OtherParam + var m = Regex.Match(line, @"-VM\s*[:=]?\s*(\$[a-zA-Z_][a-zA-Z0-9_]*)"); + if (m.Success) + { + return m.Groups[1].Value; + } + } + } + catch { } + return "$vm"; // Fallback default if parsing fails + } + + private static Microsoft.Azure.Management.Compute.Models.DataDisk CloneDisk(Microsoft.Azure.Management.Compute.Models.DataDisk d) + { + if (d == null) return null; + // Only copy fields needed for the preview + return new Microsoft.Azure.Management.Compute.Models.DataDisk + { + Name = d.Name, + Lun = d.Lun, + CreateOption = d.CreateOption, + DiskSizeGB = d.DiskSizeGB, + Caching = d.Caching, + Vhd = d.Vhd, + Image = d.Image, + ManagedDisk = d.ManagedDisk, + WriteAcceleratorEnabled = d.WriteAcceleratorEnabled, + ToBeDetached = d.ToBeDetached, + DetachOption = d.DetachOption, + DeleteOption = d.DeleteOption, + SourceResource = d.SourceResource + }; } } } diff --git a/src/Storage/Storage.Management/StorageAccount/AddAzureStorageAccountNetworkRule.cs b/src/Storage/Storage.Management/StorageAccount/AddAzureStorageAccountNetworkRule.cs index d2420feb073a..cd79829a392a 100644 --- a/src/Storage/Storage.Management/StorageAccount/AddAzureStorageAccountNetworkRule.cs +++ b/src/Storage/Storage.Management/StorageAccount/AddAzureStorageAccountNetworkRule.cs @@ -128,6 +128,11 @@ public class AddAzureStorageAccountNetworkRuleCommand : StorageAccountBaseCmdlet public override void ExecuteCmdlet() { + if (DryRun.IsPresent && TryHandleDryRun()) + { + return; // skip real execution + } + base.ExecuteCmdlet(); if (ShouldProcess(this.Name, "Add Storage Account NetworkRules")) From 3a78c24a401c16b887d957924da2d23125f3d3d1 Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Mon, 10 Nov 2025 21:04:44 +0800 Subject: [PATCH 13/13] set pre-release, changelog --- src/Compute/Compute/Az.Compute.psd1 | 2 +- src/Compute/Compute/ChangeLog.md | 1 + src/Network/Network/Az.Network.psd1 | 2 +- src/Network/Network/ChangeLog.md | 1 + src/Storage/Storage.Management/Az.Storage.psd1 | 2 +- src/Storage/Storage.Management/ChangeLog.md | 1 + 6 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Compute/Compute/Az.Compute.psd1 b/src/Compute/Compute/Az.Compute.psd1 index f4a6440f0aad..f5f08c3dd3b5 100644 --- a/src/Compute/Compute/Az.Compute.psd1 +++ b/src/Compute/Compute/Az.Compute.psd1 @@ -263,7 +263,7 @@ PrivateData = @{ * Added ''-AddProxyAgentExtension'' parameter (Bool) to ''Set-AzVMProxyAgentSetting'' and ''Set-AzVmssProxyAgentSetting''' # Prerelease string of this module - # Prerelease = '' + Prerelease = 'preview' # Flag to indicate whether the module requires explicit user acceptance for install/update/save # RequireLicenseAcceptance = $false diff --git a/src/Compute/Compute/ChangeLog.md b/src/Compute/Compute/ChangeLog.md index 3b20e3949f01..a2cba74a3acd 100644 --- a/src/Compute/Compute/ChangeLog.md +++ b/src/Compute/Compute/ChangeLog.md @@ -20,6 +20,7 @@ --> ## Upcoming Release +* Supported `-DryRun` and `-ExportBicep` * Improved user experience and consistency. This may introduce breaking changes. Please refer to [here](https://go.microsoft.com/fwlink/?linkid=2340249). * Updated Azure.Core from 1.45.0 to 1.47.3 * Added `-EnableAutomaticUpgrade` and `-TreatFailureAsDeploymentFailure` parameters (Bool) to `New-AzVmGalleryApplication` and `New-AzVmssGalleryApplication` cmdlets. diff --git a/src/Network/Network/Az.Network.psd1 b/src/Network/Network/Az.Network.psd1 index fbe960fb9953..58ad99298b34 100644 --- a/src/Network/Network/Az.Network.psd1 +++ b/src/Network/Network/Az.Network.psd1 @@ -814,7 +814,7 @@ PrivateData = @{ * Bug fix for ''AzureFirewallPolicy'' to ensure ''BasePolicy'' is properly set via ''Set-AzFirewallPolicy'' cmdlet either via pipe or direct value.' # Prerelease string of this module - # Prerelease = '' + Prerelease = 'preview' # Flag to indicate whether the module requires explicit user acceptance for install/update/save # RequireLicenseAcceptance = $false diff --git a/src/Network/Network/ChangeLog.md b/src/Network/Network/ChangeLog.md index acaaa2cff76e..6673ff1b058a 100644 --- a/src/Network/Network/ChangeLog.md +++ b/src/Network/Network/ChangeLog.md @@ -19,6 +19,7 @@ ---> ## Upcoming Release +* Supported `-DryRun` and `-ExportBicep` * Onboarded `Microsoft.Security/privateLinks` to Private Link Common Cmdlets ## Version 7.23.0 diff --git a/src/Storage/Storage.Management/Az.Storage.psd1 b/src/Storage/Storage.Management/Az.Storage.psd1 index 04b9746ade92..6c1087668487 100644 --- a/src/Storage/Storage.Management/Az.Storage.psd1 +++ b/src/Storage/Storage.Management/Az.Storage.psd1 @@ -260,7 +260,7 @@ PrivateData = @{ - ''Invoke-AzStorageReconcileNetworkSecurityPerimeterConfiguration''' # Prerelease string of this module - # Prerelease = '' + Prerelease = 'preview' # Flag to indicate whether the module requires explicit user acceptance for install/update/save # RequireLicenseAcceptance = $false diff --git a/src/Storage/Storage.Management/ChangeLog.md b/src/Storage/Storage.Management/ChangeLog.md index 44aae779db62..492376c1e1e3 100644 --- a/src/Storage/Storage.Management/ChangeLog.md +++ b/src/Storage/Storage.Management/ChangeLog.md @@ -18,6 +18,7 @@ - Additional information about change #1 --> ## Upcoming Release +* Supported `-DryRun` and `-ExportBicep` * Updated Azure.Core from 1.45.0 to 1.47.3 * Supported Storage account planned failover: `Invoke-AzStorageAccountFailover`, `Get-AzStorageAccount`