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