From d299a906cdbd5de050d1a1b9b8fc66dba87037b8 Mon Sep 17 00:00:00 2001 From: Nori Zhang Date: Mon, 6 Oct 2025 18:15:23 +1100 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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; + } + } +}