From ee78f8ea978e37b305e0db7a342c87d734dd3d59 Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Wed, 19 Nov 2025 17:06:23 -0800 Subject: [PATCH 01/13] Add non-Azure hosting support to a365 setup --- .../Commands/SetupCommand.cs | 128 ++++++++++---- .../Models/Agent365Config.cs | 65 ++++++- .../Services/A365SetupRunner.cs | 158 +++++++++++------- 3 files changed, 254 insertions(+), 97 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 5170e6b1..b5b9551a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -64,6 +64,9 @@ public static Command CreateCommand( { // Validate configuration even in dry-run mode var dryRunConfig = await configService.LoadAsync(config.FullName); + + // Validate non-Azure messaging endpoint + ValidateMessagingEndpointForNonAzure(dryRunConfig, logger); logger.LogInformation("DRY RUN: Agent 365 Setup - Blueprint + Messaging Endpoint Registration"); logger.LogInformation("This would execute the following operations:"); @@ -85,6 +88,9 @@ public static Command CreateCommand( { // Load configuration - ConfigService automatically finds generated config in same directory var setupConfig = await configService.LoadAsync(config.FullName); + + // Early fail for non-Azure if no MessagingEndpoint + ValidateMessagingEndpointForNonAzure(setupConfig, logger); // Validate Azure CLI authentication, subscription, and environment if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) @@ -408,24 +414,53 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( throw new InvalidOperationException("Agent Blueprint ID is required for messaging endpoint registration"); } - if (string.IsNullOrEmpty(setupConfig.WebAppName)) + string messagingEndpoint; + string endpointName; + if (string.Equals(setupConfig.HostingMode, "AzureAppService", StringComparison.OrdinalIgnoreCase)) { - logger.LogError("Web App Name not configured in a365.config.json"); - throw new InvalidOperationException("Web App Name is required for messaging endpoint registration"); + if (string.IsNullOrEmpty(setupConfig.WebAppName)) + { + logger.LogError("Web App Name not configured in a365.config.json"); + throw new InvalidOperationException("Web App Name is required for messaging endpoint registration"); + } + + // Generate endpoint name with Azure Bot Service constraints (4-42 chars) + var baseEndpointName = $"{setupConfig.WebAppName}-endpoint"; + endpointName = EndpointHelper.GetEndpointName(baseEndpointName); + + // Construct messaging endpoint URL from web app name + messagingEndpoint = $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages"; + } + else // External hosting (non-Azure) + { + if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint)) + { + logger.LogError("MessagingEndpoint must be provided in a365.config.json for External hosting mode"); + throw new InvalidOperationException("MessagingEndpoint is required for messaging endpoint registration"); + } + + if (!Uri.TryCreate(setupConfig.MessagingEndpoint, UriKind.Absolute, out var uri) || + uri.Scheme != Uri.UriSchemeHttps) + { + logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}", + setupConfig.MessagingEndpoint); + throw new InvalidOperationException("MessagingEndpoint must be a valid HTTPS URL."); + } + + messagingEndpoint = setupConfig.MessagingEndpoint; + + // Derive endpoint name from host when there's no WebAppName + var hostPart = uri.Host.Replace('.', '-'); + var baseEndpointName = $"{hostPart}-endpoint"; + endpointName = EndpointHelper.GetEndpointName(baseEndpointName ); } - // Generate endpoint name with Azure Bot Service constraints (4-42 chars) - var baseEndpointName = $"{setupConfig.WebAppName}-endpoint"; - var endpointName = EndpointHelper.GetEndpointName(baseEndpointName); if (endpointName.Length < 4) { logger.LogError("Bot endpoint name '{EndpointName}' is too short (must be at least 4 characters)", endpointName); throw new InvalidOperationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)"); } - // Register messaging endpoint using agent blueprint identity and deployed web app URL - var messagingEndpoint = $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages"; - logger.LogInformation(" - Registering blueprint messaging endpoint"); logger.LogInformation(" * Endpoint Name: {EndpointName}", endpointName); logger.LogInformation(" * Messaging Endpoint: {Endpoint}", messagingEndpoint); @@ -445,74 +480,111 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( } } + /// + /// Ensure OAuth2 permission grants are set from blueprint to MCP server + /// private static async Task EnsureMcpOauth2PermissionGrantsAsync( GraphApiService graph, - Agent365Config cfg, + Agent365Config config, string[] scopes, ILogger logger, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(cfg.AgentBlueprintId)) + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) throw new InvalidOperationException("AgentBlueprintId (appId) is required."); - var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(cfg.TenantId, cfg.AgentBlueprintId, ct); + var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct); if (string.IsNullOrWhiteSpace(blueprintSpObjectId)) { - throw new InvalidOperationException($"Blueprint Service Principal not found for appId {cfg.AgentBlueprintId}. " + + throw new InvalidOperationException($"Blueprint Service Principal not found for appId {config.AgentBlueprintId}. " + "The service principal may not have propagated yet. Wait a few minutes and retry."); } - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(cfg.Environment); - var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(cfg.TenantId, resourceAppId, ct); + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct); if (string.IsNullOrWhiteSpace(Agent365ToolsSpObjectId)) { throw new InvalidOperationException($"Agent 365 Tools Service Principal not found for appId {resourceAppId}. " + - $"Ensure the Agent 365 Tools application is available in your tenant for environment: {cfg.Environment}"); + $"Ensure the Agent 365 Tools application is available in your tenant for environment: {config.Environment}"); } logger.LogInformation(" - OAuth2 grant: client {ClientId} to resource {ResourceId} scopes [{Scopes}]", blueprintSpObjectId, Agent365ToolsSpObjectId, string.Join(' ', scopes)); var response = await graph.CreateOrUpdateOauth2PermissionGrantAsync( - cfg.TenantId, blueprintSpObjectId, Agent365ToolsSpObjectId, scopes, ct); + config.TenantId, blueprintSpObjectId, Agent365ToolsSpObjectId, scopes, ct); if (!response) { throw new InvalidOperationException( - $"Failed to create/update OAuth2 permission grant from blueprint {cfg.AgentBlueprintId} to Agent 365 Tools {resourceAppId}. " + + $"Failed to create/update OAuth2 permission grant from blueprint {config.AgentBlueprintId} to Agent 365 Tools {resourceAppId}. " + "This may be due to insufficient permissions. Ensure you have DelegatedPermissionGrant.ReadWrite.All or Application.ReadWrite.All permissions."); } } + /// + /// Ensure inheritable permissions are set from blueprint to MCP server + /// private static async Task EnsureMcpInheritablePermissionsAsync( GraphApiService graph, - Agent365Config cfg, + Agent365Config config, string[] scopes, ILogger logger, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(cfg.AgentBlueprintId)) + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) throw new InvalidOperationException("AgentBlueprintId (appId) is required."); - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(cfg.Environment); + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); logger.LogInformation(" - Inheritable permissions: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", - cfg.AgentBlueprintId, resourceAppId, string.Join(' ', scopes)); + config.AgentBlueprintId, resourceAppId, string.Join(' ', scopes)); var (ok, alreadyExists, err) = await graph.SetInheritablePermissionsAsync( - cfg.TenantId, cfg.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct); + config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct); if (!ok && !alreadyExists) { - cfg.InheritanceConfigured = false; - cfg.InheritanceConfigError = err; + config.InheritanceConfigured = false; + config.InheritanceConfigError = err; throw new InvalidOperationException($"Failed to set inheritable permissions: {err}. " + "Ensure you have Application.ReadWrite.All permissions and the blueprint supports inheritable permissions."); } - cfg.InheritanceConfigured = true; - cfg.InheritablePermissionsAlreadyExist = alreadyExists; - cfg.InheritanceConfigError = null; + config.InheritanceConfigured = true; + config.InheritablePermissionsAlreadyExist = alreadyExists; + config.InheritanceConfigError = null; + } + + /// + /// Validate messaging endpoint configuration for non-Azure deployments + /// + private static void ValidateMessagingEndpointForNonAzure(Agent365Config config, ILogger logger) + { + if (!string.Equals(config.HostingMode, "External", StringComparison.OrdinalIgnoreCase)) + return; + + if (string.IsNullOrWhiteSpace(config.MessagingEndpoint)) + { + logger.LogError( + "For non-Azure deployments (hostingMode=External), 'messagingEndpoint' is required in a365.config.json. " + + "This is the HTTPS URL that Bot Framework will call (e.g. https://your-host.com/api/messages)."); + + throw new InvalidOperationException("Missing MessagingEndpoint for non-Azure deployment."); + } + + if (!Uri.TryCreate(config.MessagingEndpoint, UriKind.Absolute, out var uri) || + uri.Scheme != Uri.UriSchemeHttps) + { + logger.LogError( + "MessagingEndpoint must be a valid HTTPS URL for non-Azure deployments. Current value: {Endpoint}", + config.MessagingEndpoint); + + throw new InvalidOperationException("Invalid MessagingEndpoint URL for non-Azure deployment."); + } + + logger.LogInformation("Non-Azure hosting detected. Using external messaging endpoint: {Endpoint}", + config.MessagingEndpoint); } /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index ea4e4680..f9ab56fe 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -24,14 +24,27 @@ public class Agent365Config public List Validate() { var errors = new List(); + if (string.IsNullOrWhiteSpace(TenantId)) errors.Add("tenantId is required."); - if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); - if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); - if (string.IsNullOrWhiteSpace(Location)) errors.Add("location is required."); - if (string.IsNullOrWhiteSpace(AppServicePlanName)) errors.Add("appServicePlanName is required."); - if (string.IsNullOrWhiteSpace(WebAppName)) errors.Add("webAppName is required."); + + if (HostingMode.Equals("AzureAppService", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); + if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); + if (string.IsNullOrWhiteSpace(Location)) errors.Add("location is required."); + if (string.IsNullOrWhiteSpace(AppServicePlanName)) errors.Add("appServicePlanName is required."); + if (string.IsNullOrWhiteSpace(WebAppName)) errors.Add("webAppName is required."); + } + else + { + // External / non-Azure hosting + if (string.IsNullOrWhiteSpace(MessagingEndpoint)) + errors.Add("messagingEndpoint is required when hostingMode is 'External'."); + } + if (string.IsNullOrWhiteSpace(AgentIdentityDisplayName)) errors.Add("agentIdentityDisplayName is required."); if (string.IsNullOrWhiteSpace(DeploymentProjectPath)) errors.Add("deploymentProjectPath is required."); + // agentIdentityScopes and agentApplicationScopes are now hardcoded defaults // botName and botDisplayName are now derived, not required in config // Add more validation as needed (e.g., GUID format, allowed values, etc.) @@ -76,6 +89,21 @@ public List Validate() [JsonPropertyName("environment")] public string Environment { get; init; } = "preprod"; + /// + /// Hosting mode for the agent runtime. + /// - "AzureAppService" (default): CLI provisions App Service + MSI, uses webAppName. + /// - "External": non-Azure hosting (K8s, other cloud, on-prem). CLI skips Azure infra. + /// + [JsonPropertyName("hostingMode")] + public string HostingMode { get; init; } = "AzureAppService"; + + /// + /// For External hosting, this is the HTTPS messaging endpoint that Bot Framework will call, + /// For AzureAppService, this is optional; the CLI derives the endpoint from webAppName. + /// + [JsonPropertyName("messagingEndpoint")] + public string? MessagingEndpoint { get; init; } + #endregion #region App Service Configuration @@ -163,13 +191,32 @@ public List Validate() // BotName and BotDisplayName are now derived properties /// - /// Gets the internal name for the endpoint registration, derived from WebAppName. + /// Gets the internal name for the endpoint registration. + /// - For AzureAppService, derived from WebAppName. + /// - For External hosting, derived from MessagingEndpoint host if possible. /// [JsonIgnore] - public string BotName => string.IsNullOrWhiteSpace(WebAppName) ? string.Empty : $"{WebAppName}-endpoint"; + public string BotName + { + get + { + if (!string.IsNullOrWhiteSpace(WebAppName)) + { + return $"{WebAppName}-endpoint"; + } + + if (!string.IsNullOrWhiteSpace(MessagingEndpoint) && + Uri.TryCreate(MessagingEndpoint, UriKind.Absolute, out var uri)) + { + return $"{uri.Host.Replace('.', '-')}-endpoint"; + } + + return string.Empty; + } + } /// - /// Gets the display name for the bot, derived from AgentBlueprintDisplayName. + /// Gets the display name for the bot, derived from AgentBlueprintDisplayName or WebAppName. /// [JsonIgnore] public string BotDisplayName => !string.IsNullOrWhiteSpace(AgentBlueprintDisplayName) ? AgentBlueprintDisplayName! : WebAppName; @@ -530,4 +577,4 @@ public class AtgConfiguration public List McpServers { get; set; } = new(); public List ToolsServers { get; set; } = new(); public string Agent365ToolsEndpoint { get; set; } = string.Empty; -} +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index cc8e6c57..e61135d3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -89,6 +89,10 @@ public async Task RunAsync(string configPath, string generatedConfigPath, if (string.IsNullOrWhiteSpace(planSku)) planSku = "B1"; var deploymentProjectPath = Get("deploymentProjectPath"); + var hostingModeRaw = Get("hostingMode"); + var isExternalHosting = string.Equals(hostingModeRaw, "External", StringComparison.OrdinalIgnoreCase); + + var skipInfra = blueprintOnly || isExternalHosting; if (new[] { subscriptionId, tenantId, resourceGroup, planName, webAppName, location }.Any(string.IsNullOrWhiteSpace)) { @@ -96,6 +100,28 @@ public async Task RunAsync(string configPath, string generatedConfigPath, return false; } + if (!skipInfra) + { + // Azure hosting scenario – need full infra details + if (new[] { subscriptionId, resourceGroup, planName, webAppName, location }.Any(string.IsNullOrWhiteSpace)) + { + _logger.LogError( + "Config missing required properties for Azure hosting. " + + "Need subscriptionId, resourceGroup, appServicePlanName, webAppName, location."); + return false; + } + } + else + { + // Non-Azure hosting or --blueprint: no infra required + if (string.IsNullOrWhiteSpace(subscriptionId)) + { + _logger.LogWarning( + "subscriptionId is not set. This is acceptable for blueprint-only or External hosting mode " + + "as Azure infrastructure will not be provisioned."); + } + } + // Detect project platform for appropriate runtime configuration var platform = Models.ProjectPlatform.DotNet; // Default fallback if (!string.IsNullOrWhiteSpace(deploymentProjectPath)) @@ -119,86 +145,93 @@ public async Task RunAsync(string configPath, string generatedConfigPath, // ======================================================================== // Phase 0: Ensure Azure CLI is logged in with proper scope // ======================================================================== - _logger.LogInformation("==> [0/5] Verifying Azure CLI authentication"); - - // Check if logged in - var accountCheck = await _executor.ExecuteAsync("az", "account show", captureOutput: true, suppressErrorLogging: true); - if (!accountCheck.Success) + if (!skipInfra) { - _logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope..."); - _logger.LogInformation("A browser window will open for authentication."); - - // Use standard login without scope parameter (more reliable) - var loginResult = await _executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); + _logger.LogInformation("==> [0/5] Verifying Azure CLI authentication"); - if (!loginResult.Success) + // Check if logged in + var accountCheck = await _executor.ExecuteAsync("az", "account show", captureOutput: true, suppressErrorLogging: true); + if (!accountCheck.Success) { - _logger.LogError("Azure CLI login failed. Please run manually: az login --scope https://management.core.windows.net//.default"); - return false; + _logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope..."); + _logger.LogInformation("A browser window will open for authentication."); + + // Use standard login without scope parameter (more reliable) + var loginResult = await _executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); + + if (!loginResult.Success) + { + _logger.LogError("Azure CLI login failed. Please run manually: az login --scope https://management.core.windows.net//.default"); + return false; + } + + _logger.LogInformation("Azure CLI login successful!"); + + // Wait a moment for the login to fully complete + await Task.Delay(2000, cancellationToken); } - - _logger.LogInformation("Azure CLI login successful!"); - - // Wait a moment for the login to fully complete - await Task.Delay(2000, cancellationToken); - } - else - { - _logger.LogInformation("Azure CLI already authenticated"); - } - - // Verify we have the management scope - if not, try to acquire it - _logger.LogInformation("Verifying access to Azure management resources..."); - var tokenCheck = await _executor.ExecuteAsync( - "az", - "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv", - captureOutput: true, - suppressErrorLogging: true, - cancellationToken: cancellationToken); - - if (!tokenCheck.Success) - { - _logger.LogWarning("Unable to acquire management scope token. Attempting re-authentication..."); - _logger.LogInformation("A browser window will open for authentication."); - - // Try standard login first (more reliable than scope-specific login) - var loginResult = await _executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); - - if (!loginResult.Success) + else { - _logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default"); - return false; + _logger.LogInformation("Azure CLI already authenticated"); } - _logger.LogInformation("Azure CLI re-authentication successful!"); - - // Wait a moment for the token cache to update - await Task.Delay(2000, cancellationToken); - - // Verify management token is now available - var retryTokenCheck = await _executor.ExecuteAsync( + // Verify we have the management scope - if not, try to acquire it + _logger.LogInformation("Verifying access to Azure management resources..."); + var tokenCheck = await _executor.ExecuteAsync( "az", "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv", captureOutput: true, suppressErrorLogging: true, cancellationToken: cancellationToken); - if (!retryTokenCheck.Success) + if (!tokenCheck.Success) { - _logger.LogWarning("Still unable to acquire management scope token after re-authentication."); - _logger.LogWarning("Continuing anyway - you may encounter permission errors later."); + _logger.LogWarning("Unable to acquire management scope token. Attempting re-authentication..."); + _logger.LogInformation("A browser window will open for authentication."); + + // Try standard login first (more reliable than scope-specific login) + var loginResult = await _executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); + + if (!loginResult.Success) + { + _logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default"); + return false; + } + + _logger.LogInformation("Azure CLI re-authentication successful!"); + + // Wait a moment for the token cache to update + await Task.Delay(2000, cancellationToken); + + // Verify management token is now available + var retryTokenCheck = await _executor.ExecuteAsync( + "az", + "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv", + captureOutput: true, + suppressErrorLogging: true, + cancellationToken: cancellationToken); + + if (!retryTokenCheck.Success) + { + _logger.LogWarning("Still unable to acquire management scope token after re-authentication."); + _logger.LogWarning("Continuing anyway - you may encounter permission errors later."); + } + else + { + _logger.LogInformation("Management scope token acquired successfully!"); + } } else { - _logger.LogInformation("Management scope token acquired successfully!"); + _logger.LogInformation("Management scope verified successfully"); } + + _logger.LogInformation(""); } else { - _logger.LogInformation("Management scope verified successfully"); + _logger.LogInformation("==> [0/5] Skipping Azure management authentication (blueprint-only or External hosting)"); } - - _logger.LogInformation(""); // ======================================================================== // Phase 1: Deploy Agent runtime (App Service) + System-assigned Managed Identity @@ -206,9 +239,14 @@ public async Task RunAsync(string configPath, string generatedConfigPath, string? principalId = null; JsonObject generatedConfig = new JsonObject(); - if (blueprintOnly) + if (skipInfra) { - _logger.LogInformation("==> [1/5] Skipping Azure infrastructure (--blueprint mode)"); + // Covers BOTH: + // - blueprintOnly == true + // - hostingMode == External (non-Azure) + var modeMessage = blueprintOnly ? "--blueprint mode" : "External hosting (non-Azure)"; + + _logger.LogInformation("==> [1/5] Skipping Azure infrastructure ({Mode})", modeMessage); _logger.LogInformation("Loading existing configuration..."); // Load existing generated config if available From 0d284035705218f992f790f9ca98c935d905e357 Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Wed, 19 Nov 2025 17:20:30 -0800 Subject: [PATCH 02/13] resolveing comments --- .../Commands/SetupCommand.cs | 2 +- .../Models/Agent365Config.cs | 4 ++-- .../Services/A365SetupRunner.cs | 7 ------- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index b5b9551a..50b66015 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -452,7 +452,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( // Derive endpoint name from host when there's no WebAppName var hostPart = uri.Host.Replace('.', '-'); var baseEndpointName = $"{hostPart}-endpoint"; - endpointName = EndpointHelper.GetEndpointName(baseEndpointName ); + endpointName = EndpointHelper.GetEndpointName(baseEndpointName); } if (endpointName.Length < 4) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index f9ab56fe..f576686c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -27,7 +27,7 @@ public List Validate() if (string.IsNullOrWhiteSpace(TenantId)) errors.Add("tenantId is required."); - if (HostingMode.Equals("AzureAppService", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(HostingMode, "AzureAppService", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); @@ -98,7 +98,7 @@ public List Validate() public string HostingMode { get; init; } = "AzureAppService"; /// - /// For External hosting, this is the HTTPS messaging endpoint that Bot Framework will call, + /// For External hosting, this is the HTTPS messaging endpoint that Bot Framework will call. /// For AzureAppService, this is optional; the CLI derives the endpoint from webAppName. /// [JsonPropertyName("messagingEndpoint")] diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index e61135d3..a7f5bf19 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -93,13 +93,6 @@ public async Task RunAsync(string configPath, string generatedConfigPath, var isExternalHosting = string.Equals(hostingModeRaw, "External", StringComparison.OrdinalIgnoreCase); var skipInfra = blueprintOnly || isExternalHosting; - - if (new[] { subscriptionId, tenantId, resourceGroup, planName, webAppName, location }.Any(string.IsNullOrWhiteSpace)) - { - _logger.LogError("Config missing required properties. Need subscriptionId, tenantId, resourceGroup, appServicePlanName, webAppName, location."); - return false; - } - if (!skipInfra) { // Azure hosting scenario – need full infra details From 96b48e69ff76165bca63209603d78977bd94a61f Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Thu, 20 Nov 2025 13:34:46 -0800 Subject: [PATCH 03/13] resolveing comments --- .../Commands/SetupCommand.cs | 58 +++++-------------- .../Models/Agent365Config.cs | 32 ++++++---- .../Services/A365SetupRunner.cs | 6 +- .../Services/ConfigService.cs | 34 +++++++---- 4 files changed, 60 insertions(+), 70 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 50b66015..05e88eb3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -64,9 +64,6 @@ public static Command CreateCommand( { // Validate configuration even in dry-run mode var dryRunConfig = await configService.LoadAsync(config.FullName); - - // Validate non-Azure messaging endpoint - ValidateMessagingEndpointForNonAzure(dryRunConfig, logger); logger.LogInformation("DRY RUN: Agent 365 Setup - Blueprint + Messaging Endpoint Registration"); logger.LogInformation("This would execute the following operations:"); @@ -88,14 +85,17 @@ public static Command CreateCommand( { // Load configuration - ConfigService automatically finds generated config in same directory var setupConfig = await configService.LoadAsync(config.FullName); - - // Early fail for non-Azure if no MessagingEndpoint - ValidateMessagingEndpointForNonAzure(setupConfig, logger); - - // Validate Azure CLI authentication, subscription, and environment - if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) + if (setupConfig.NeedWebAppDeployment) { - Environment.Exit(1); + // Validate Azure CLI authentication, subscription, and environment + if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) + { + Environment.Exit(1); + } + } + else + { + logger.LogInformation("NeedWebAppDeployment=no – skipping Azure subscription validation."); } logger.LogInformation(""); @@ -416,7 +416,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( string messagingEndpoint; string endpointName; - if (string.Equals(setupConfig.HostingMode, "AzureAppService", StringComparison.OrdinalIgnoreCase)) + if (setupConfig.NeedWebAppDeployment) { if (string.IsNullOrEmpty(setupConfig.WebAppName)) { @@ -431,8 +431,9 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( // Construct messaging endpoint URL from web app name messagingEndpoint = $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages"; } - else // External hosting (non-Azure) + else // Non-Azure hosting { + // No deployment – use the provided botMessagingEndpoint if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint)) { logger.LogError("MessagingEndpoint must be provided in a365.config.json for External hosting mode"); @@ -443,7 +444,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( uri.Scheme != Uri.UriSchemeHttps) { logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}", - setupConfig.MessagingEndpoint); + setupConfig.BotMessagingEndpoint); throw new InvalidOperationException("MessagingEndpoint must be a valid HTTPS URL."); } @@ -556,37 +557,6 @@ private static async Task EnsureMcpInheritablePermissionsAsync( config.InheritanceConfigError = null; } - /// - /// Validate messaging endpoint configuration for non-Azure deployments - /// - private static void ValidateMessagingEndpointForNonAzure(Agent365Config config, ILogger logger) - { - if (!string.Equals(config.HostingMode, "External", StringComparison.OrdinalIgnoreCase)) - return; - - if (string.IsNullOrWhiteSpace(config.MessagingEndpoint)) - { - logger.LogError( - "For non-Azure deployments (hostingMode=External), 'messagingEndpoint' is required in a365.config.json. " + - "This is the HTTPS URL that Bot Framework will call (e.g. https://your-host.com/api/messages)."); - - throw new InvalidOperationException("Missing MessagingEndpoint for non-Azure deployment."); - } - - if (!Uri.TryCreate(config.MessagingEndpoint, UriKind.Absolute, out var uri) || - uri.Scheme != Uri.UriSchemeHttps) - { - logger.LogError( - "MessagingEndpoint must be a valid HTTPS URL for non-Azure deployments. Current value: {Endpoint}", - config.MessagingEndpoint); - - throw new InvalidOperationException("Invalid MessagingEndpoint URL for non-Azure deployment."); - } - - logger.LogInformation("Non-Azure hosting detected. Using external messaging endpoint: {Endpoint}", - config.MessagingEndpoint); - } - /// /// Display comprehensive setup summary showing what succeeded and what failed /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index f576686c..3e3f178b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -27,7 +27,7 @@ public List Validate() if (string.IsNullOrWhiteSpace(TenantId)) errors.Add("tenantId is required."); - if (string.Equals(HostingMode, "AzureAppService", StringComparison.OrdinalIgnoreCase)) + if (NeedWebAppDeployment) { if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); @@ -37,9 +37,9 @@ public List Validate() } else { - // External / non-Azure hosting + // Non-Azure hosting if (string.IsNullOrWhiteSpace(MessagingEndpoint)) - errors.Add("messagingEndpoint is required when hostingMode is 'External'."); + errors.Add("messagingEndpoint is required when needWebAppDeployment is 'no'."); } if (string.IsNullOrWhiteSpace(AgentIdentityDisplayName)) errors.Add("agentIdentityDisplayName is required."); @@ -89,14 +89,6 @@ public List Validate() [JsonPropertyName("environment")] public string Environment { get; init; } = "preprod"; - /// - /// Hosting mode for the agent runtime. - /// - "AzureAppService" (default): CLI provisions App Service + MSI, uses webAppName. - /// - "External": non-Azure hosting (K8s, other cloud, on-prem). CLI skips Azure infra. - /// - [JsonPropertyName("hostingMode")] - public string HostingMode { get; init; } = "AzureAppService"; - /// /// For External hosting, this is the HTTPS messaging endpoint that Bot Framework will call. /// For AzureAppService, this is optional; the CLI derives the endpoint from webAppName. @@ -104,6 +96,15 @@ public List Validate() [JsonPropertyName("messagingEndpoint")] public string? MessagingEndpoint { get; init; } + /// + /// Whether the CLI should create and deploy an Azure Web App for this agent. + /// Backed by the 'needDeployment' config value: + /// - "yes" (default) => CLI provisions App Service + MSI, a365 deploy app is active. + /// - "no" => CLI does NOT create a web app; a365 deploy app is a no-op and botMessagingEndpoint must be provided. + /// + [JsonPropertyName("needDeployment")] + public string NeedDeployment { get; init; } = "yes"; + #endregion #region App Service Configuration @@ -193,7 +194,7 @@ public List Validate() /// /// Gets the internal name for the endpoint registration. /// - For AzureAppService, derived from WebAppName. - /// - For External hosting, derived from MessagingEndpoint host if possible. + /// - For non-Azure hosting, derived from BotMessagingEndpoint host if possible. /// [JsonIgnore] public string BotName @@ -215,6 +216,13 @@ public string BotName } } + /// + /// Whether the CLI should perform web app deployment for the agent. + /// + [JsonIgnore] + public bool NeedWebAppDeployment => + !string.Equals(NeedDeployment, "no", StringComparison.OrdinalIgnoreCase); + /// /// Gets the display name for the bot, derived from AgentBlueprintDisplayName or WebAppName. /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index a7f5bf19..e0c24dc3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -89,10 +89,10 @@ public async Task RunAsync(string configPath, string generatedConfigPath, if (string.IsNullOrWhiteSpace(planSku)) planSku = "B1"; var deploymentProjectPath = Get("deploymentProjectPath"); - var hostingModeRaw = Get("hostingMode"); - var isExternalHosting = string.Equals(hostingModeRaw, "External", StringComparison.OrdinalIgnoreCase); + var needDeploymentRaw = Get("needDeployment"); + var needWebAppDeployment = !string.Equals(needDeploymentRaw, "no", StringComparison.OrdinalIgnoreCase); - var skipInfra = blueprintOnly || isExternalHosting; + var skipInfra = blueprintOnly || !needWebAppDeployment; if (!skipInfra) { // Azure hosting scenario – need full infra details diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs index d0d524b0..6ddf7d11 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs @@ -377,20 +377,32 @@ public async Task ValidateAsync(Agent365Config config) var errors = new List(); var warnings = new List(); - // Validate required static properties ValidateRequired(config.TenantId, nameof(config.TenantId), errors); - ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); - ValidateRequired(config.ResourceGroup, nameof(config.ResourceGroup), errors); - ValidateRequired(config.Location, nameof(config.Location), errors); - - // Validate GUID formats ValidateGuid(config.TenantId, nameof(config.TenantId), errors); - ValidateGuid(config.SubscriptionId, nameof(config.SubscriptionId), errors); - // Validate Azure naming conventions - ValidateResourceGroupName(config.ResourceGroup, errors); - ValidateAppServicePlanName(config.AppServicePlanName, errors); - ValidateWebAppName(config.WebAppName, errors); + if (!config.NeedWebAppDeployment) + { + // Validate required static properties + ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); + ValidateRequired(config.ResourceGroup, nameof(config.ResourceGroup), errors); + ValidateRequired(config.Location, nameof(config.Location), errors); + ValidateRequired(config.AppServicePlanName, nameof(config.AppServicePlanName), errors); + ValidateRequired(config.WebAppName, nameof(config.WebAppName), errors); + + // Validate GUID formats + ValidateGuid(config.SubscriptionId, nameof(config.SubscriptionId), errors); + + // Validate Azure naming conventions + ValidateResourceGroupName(config.ResourceGroup, errors); + ValidateAppServicePlanName(config.AppServicePlanName, errors); + ValidateWebAppName(config.WebAppName, errors); + } + else + { + // Only validate bot messaging endpoint + ValidateRequired(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); + ValidateUrl(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); + } // Validate dynamic properties if they exist if (config.ManagedIdentityPrincipalId != null) From f9f9a2636a77ec4a8eade4996fe212dfc6e8f856 Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Thu, 20 Nov 2025 14:18:14 -0800 Subject: [PATCH 04/13] fix failed tests --- .../Models/Agent365Config.cs | 3 +-- .../Services/ConfigService.cs | 9 +++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index 3e3f178b..2c2d6533 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -220,8 +220,7 @@ public string BotName /// Whether the CLI should perform web app deployment for the agent. /// [JsonIgnore] - public bool NeedWebAppDeployment => - !string.Equals(NeedDeployment, "no", StringComparison.OrdinalIgnoreCase); + public bool NeedWebAppDeployment => !string.Equals(NeedDeployment, "no", StringComparison.OrdinalIgnoreCase); /// /// Gets the display name for the bot, derived from AgentBlueprintDisplayName or WebAppName. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs index 6ddf7d11..70771f64 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs @@ -312,7 +312,7 @@ public async Task LoadAsync( _logger?.LogError("Configuration validation failed:"); foreach (var error in validationResult.Errors) { - _logger?.LogError(" � {Error}", error); + _logger?.LogError(" * {Error}", error); } // Convert validation errors to structured exception @@ -328,7 +328,7 @@ public async Task LoadAsync( { foreach (var warning in validationResult.Warnings) { - _logger?.LogWarning(" � {Warning}", warning); + _logger?.LogWarning(" * {Warning}", warning); } } @@ -380,7 +380,7 @@ public async Task ValidateAsync(Agent365Config config) ValidateRequired(config.TenantId, nameof(config.TenantId), errors); ValidateGuid(config.TenantId, nameof(config.TenantId), errors); - if (!config.NeedWebAppDeployment) + if (config.NeedWebAppDeployment) { // Validate required static properties ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); @@ -483,7 +483,8 @@ public async Task CreateDefaultConfigAsync( AgentIdentityDisplayName = string.Empty, // AgentIdentityScopes and AgentApplicationScopes are now hardcoded defaults DeploymentProjectPath = string.Empty, - AgentDescription = string.Empty + AgentDescription = string.Empty, + NeedDeployment = "yes" }; // Only serialize static (init) properties for the config file From 8657f8dd68aba3001309326f18c2f90d611aab88 Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Thu, 20 Nov 2025 14:23:56 -0800 Subject: [PATCH 05/13] fix typos --- .../Commands/SetupCommand.cs | 4 ++-- .../Models/Agent365Config.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 05e88eb3..c3f3ae09 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -433,7 +433,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( } else // Non-Azure hosting { - // No deployment – use the provided botMessagingEndpoint + // No deployment – use the provided MessagingEndpoint if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint)) { logger.LogError("MessagingEndpoint must be provided in a365.config.json for External hosting mode"); @@ -444,7 +444,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( uri.Scheme != Uri.UriSchemeHttps) { logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}", - setupConfig.BotMessagingEndpoint); + setupConfig.MessagingEndpoint); throw new InvalidOperationException("MessagingEndpoint must be a valid HTTPS URL."); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index 2c2d6533..e94d541c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -100,7 +100,7 @@ public List Validate() /// Whether the CLI should create and deploy an Azure Web App for this agent. /// Backed by the 'needDeployment' config value: /// - "yes" (default) => CLI provisions App Service + MSI, a365 deploy app is active. - /// - "no" => CLI does NOT create a web app; a365 deploy app is a no-op and botMessagingEndpoint must be provided. + /// - "no" => CLI does NOT create a web app; a365 deploy app is a no-op and MessagingEndpoint must be provided. /// [JsonPropertyName("needDeployment")] public string NeedDeployment { get; init; } = "yes"; @@ -194,7 +194,7 @@ public List Validate() /// /// Gets the internal name for the endpoint registration. /// - For AzureAppService, derived from WebAppName. - /// - For non-Azure hosting, derived from BotMessagingEndpoint host if possible. + /// - For non-Azure hosting, derived from MessagingEndpoint host if possible. /// [JsonIgnore] public string BotName From c356790d6d2664e42043330970f7e056ea811e39 Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Thu, 20 Nov 2025 15:52:47 -0800 Subject: [PATCH 06/13] Add SetupValidationException --- .../Commands/SetupCommand.cs | 75 +++++++++++++++---- .../Constants/ErrorCodes.cs | 1 + .../Exceptions/Agent365Exception.cs | 8 +- .../Exceptions/SetupValidationException.cs | 30 ++++++++ .../Services/A365SetupRunner.cs | 2 +- 5 files changed, 95 insertions(+), 21 deletions(-) create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/SetupValidationException.cs diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index c3f3ae09..69805c18 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -151,7 +151,7 @@ public static Command CreateCommand( logger.LogError("Agent blueprint creation failed"); setupResults.BlueprintCreated = false; setupResults.Errors.Add("Agent blueprint creation failed"); - throw new InvalidOperationException("Setup runner execution failed"); + throw new SetupValidationException("Setup runner execution failed"); } setupResults.BlueprintCreated = true; @@ -228,10 +228,10 @@ await EnsureMcpInheritablePermissionsAsync( logger.LogInformation(""); if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) - throw new InvalidOperationException("AgentBlueprintId is required."); + throw new SetupValidationException("AgentBlueprintId is required."); var blueprintSpObjectId = await graphService.LookupServicePrincipalByAppIdAsync(setupConfig.TenantId, setupConfig.AgentBlueprintId) - ?? throw new InvalidOperationException($"Blueprint Service Principal not found for appId {setupConfig.AgentBlueprintId}"); + ?? throw new SetupValidationException($"Blueprint Service Principal not found for appId {setupConfig.AgentBlueprintId}"); // Ensure Messaging Bot API SP exists var botApiResourceSpObjectId = await graphService.EnsureServicePrincipalForAppIdAsync( @@ -411,7 +411,21 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId)) { logger.LogError("Agent Blueprint ID not found. Blueprint creation may have failed."); - throw new InvalidOperationException("Agent Blueprint ID is required for messaging endpoint registration"); + throw new SetupValidationException( + issueDescription: "Agent blueprint was not found – messaging endpoint cannot be registered.", + errorDetails: new List + { + "AgentBlueprintId is missing from configuration. This usually means the blueprint creation step failed or a365.generated.config.json is out of sync." + }, + mitigationSteps: new List + { + "Verify that 'a365 setup' completed Step 1 (Agent blueprint creation) without errors.", + "Check a365.generated.config.json for 'agentBlueprintId'. If it's missing or incorrect, re-run 'a365 setup'." + }, + context: new Dictionary + { + ["AgentBlueprintId"] = setupConfig.AgentBlueprintId ?? "" + }); } string messagingEndpoint; @@ -421,7 +435,23 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( if (string.IsNullOrEmpty(setupConfig.WebAppName)) { logger.LogError("Web App Name not configured in a365.config.json"); - throw new InvalidOperationException("Web App Name is required for messaging endpoint registration"); + throw new SetupValidationException( + issueDescription: "Web App name is required to register a messaging endpoint when needDeployment is 'yes'.", + errorDetails: new List + { + "NeedWebAppDeployment is true, but 'webAppName' was not provided in a365.config.json." + }, + mitigationSteps: new List + { + "Open a365.config.json and ensure 'webAppName' is set to the Azure Web App name.", + "If you do not want the CLI to deploy an Azure Web App, set \"needDeployment\": \"no\" and provide \"MessagingEndpoint\" instead.", + "Re-run 'a365 setup'." + }, + context: new Dictionary + { + ["needDeployment"] = setupConfig.NeedWebAppDeployment.ToString(), + ["webAppName"] = setupConfig.WebAppName ?? "" + }); } // Generate endpoint name with Azure Bot Service constraints (4-42 chars) @@ -436,8 +466,21 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( // No deployment – use the provided MessagingEndpoint if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint)) { - logger.LogError("MessagingEndpoint must be provided in a365.config.json for External hosting mode"); - throw new InvalidOperationException("MessagingEndpoint is required for messaging endpoint registration"); + logger.LogError("MessagingEndpoint must be provided in a365.config.json for non-Azure hosting."); + throw new SetupValidationException( + issueDescription: "Messaging endpoint is required for messaging endpoint registration.", + errorDetails: new List + { + "needDeployment is set to 'no', but MessagingEndpoint was not provided in a365.config.json." + }, + mitigationSteps: new List + { + "Open your a365.config.json file.", + "If you want the CLI to deploy an Azure Web App, set \"needDeployment\": \"yes\" and provide \"webAppName\".", + "If your agent is hosted elsewhere, keep \"needDeployment\": \"no\" and add a \"MessagingEndpoint\" with a valid HTTPS URL (e.g. \"https://your-host/api/messages\").", + "Re-run 'a365 setup'." + } + ); } if (!Uri.TryCreate(setupConfig.MessagingEndpoint, UriKind.Absolute, out var uri) || @@ -445,7 +488,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( { logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}", setupConfig.MessagingEndpoint); - throw new InvalidOperationException("MessagingEndpoint must be a valid HTTPS URL."); + throw new SetupValidationException("MessagingEndpoint must be a valid HTTPS URL."); } messagingEndpoint = setupConfig.MessagingEndpoint; @@ -459,7 +502,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( if (endpointName.Length < 4) { logger.LogError("Bot endpoint name '{EndpointName}' is too short (must be at least 4 characters)", endpointName); - throw new InvalidOperationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)"); + throw new SetupValidationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)"); } logger.LogInformation(" - Registering blueprint messaging endpoint"); @@ -477,7 +520,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( if (!endpointRegistered) { logger.LogError("Failed to register blueprint messaging endpoint"); - throw new InvalidOperationException("Blueprint messaging endpoint registration failed"); + throw new SetupValidationException("Blueprint messaging endpoint registration failed"); } } @@ -492,12 +535,12 @@ private static async Task EnsureMcpOauth2PermissionGrantsAsync( CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) - throw new InvalidOperationException("AgentBlueprintId (appId) is required."); + throw new SetupValidationException("AgentBlueprintId (appId) is required."); var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct); if (string.IsNullOrWhiteSpace(blueprintSpObjectId)) { - throw new InvalidOperationException($"Blueprint Service Principal not found for appId {config.AgentBlueprintId}. " + + throw new SetupValidationException($"Blueprint Service Principal not found for appId {config.AgentBlueprintId}. " + "The service principal may not have propagated yet. Wait a few minutes and retry."); } @@ -505,7 +548,7 @@ private static async Task EnsureMcpOauth2PermissionGrantsAsync( var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct); if (string.IsNullOrWhiteSpace(Agent365ToolsSpObjectId)) { - throw new InvalidOperationException($"Agent 365 Tools Service Principal not found for appId {resourceAppId}. " + + throw new SetupValidationException($"Agent 365 Tools Service Principal not found for appId {resourceAppId}. " + $"Ensure the Agent 365 Tools application is available in your tenant for environment: {config.Environment}"); } @@ -517,7 +560,7 @@ private static async Task EnsureMcpOauth2PermissionGrantsAsync( if (!response) { - throw new InvalidOperationException( + throw new SetupValidationException( $"Failed to create/update OAuth2 permission grant from blueprint {config.AgentBlueprintId} to Agent 365 Tools {resourceAppId}. " + "This may be due to insufficient permissions. Ensure you have DelegatedPermissionGrant.ReadWrite.All or Application.ReadWrite.All permissions."); } @@ -534,7 +577,7 @@ private static async Task EnsureMcpInheritablePermissionsAsync( CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) - throw new InvalidOperationException("AgentBlueprintId (appId) is required."); + throw new SetupValidationException("AgentBlueprintId (appId) is required."); var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); @@ -548,7 +591,7 @@ private static async Task EnsureMcpInheritablePermissionsAsync( { config.InheritanceConfigured = false; config.InheritanceConfigError = err; - throw new InvalidOperationException($"Failed to set inheritable permissions: {err}. " + + throw new SetupValidationException($"Failed to set inheritable permissions: {err}. " + "Ensure you have Application.ReadWrite.All permissions and the blueprint supports inheritable permissions."); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs index 12474a29..167fde81 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs @@ -10,5 +10,6 @@ public static class ErrorCodes public const string DeploymentAppCompileFailed = "DEPLOYMENT_APP_COMPILE_FAILED"; public const string DeploymentScopesFailed = "DEPLOYMENT_SCOPES_FAILED"; public const string DeploymentMcpFailed = "DEPLOYMENT_MCP_FAILED"; + public const string SetupValidationFailed = "SETUP_VALIDATION_FAILED"; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs index 28e8ccbc..15ff1e8b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs @@ -1,6 +1,6 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using System.Text; namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; @@ -80,7 +80,7 @@ private static string BuildMessage(string errorCode, string issueDescription, Li sb.AppendLine(); foreach (var detail in errorDetails) { - sb.AppendLine($" � {detail}"); + sb.AppendLine($" * {detail}"); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/SetupValidationException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/SetupValidationException.cs new file mode 100644 index 00000000..63a414a0 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/SetupValidationException.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; + +namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; + +/// +/// Validation errors that occur during `a365 setup` (user-fixable issues). +/// +public sealed class SetupValidationException : Agent365Exception +{ + public override int ExitCode => 2; + + public SetupValidationException( + string issueDescription, + List? errorDetails = null, + List? mitigationSteps = null, + Dictionary? context = null, + Exception? innerException = null) + : base( + errorCode: ErrorCodes.SetupValidationFailed, + issueDescription: issueDescription, + errorDetails: errorDetails, + mitigationSteps: mitigationSteps, + context: context, + innerException: innerException) + { + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index e0c24dc3..ce811d5b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -187,7 +187,7 @@ public async Task RunAsync(string configPath, string generatedConfigPath, if (!loginResult.Success) { - _logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default"); + _logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default"); return false; } From 3be740adb94894e0e4dddf23dc5176d529697a6d Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Wed, 19 Nov 2025 17:06:23 -0800 Subject: [PATCH 07/13] Add non-Azure hosting support to a365 setup --- .../Commands/SetupCommand.cs | 128 ++++++++++---- .../Models/Agent365Config.cs | 65 ++++++- .../Services/A365SetupRunner.cs | 158 +++++++++++------- 3 files changed, 254 insertions(+), 97 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 450d37e2..75bf1ddc 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -65,6 +65,9 @@ public static Command CreateCommand( { // Validate configuration even in dry-run mode var dryRunConfig = await configService.LoadAsync(config.FullName); + + // Validate non-Azure messaging endpoint + ValidateMessagingEndpointForNonAzure(dryRunConfig, logger); logger.LogInformation("DRY RUN: Agent 365 Setup - Blueprint + Messaging Endpoint Registration"); logger.LogInformation("This would execute the following operations:"); @@ -86,6 +89,9 @@ public static Command CreateCommand( { // Load configuration - ConfigService automatically finds generated config in same directory var setupConfig = await configService.LoadAsync(config.FullName); + + // Early fail for non-Azure if no MessagingEndpoint + ValidateMessagingEndpointForNonAzure(setupConfig, logger); // Validate Azure CLI authentication, subscription, and environment if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) @@ -404,24 +410,53 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( throw new InvalidOperationException("Agent Blueprint ID is required for messaging endpoint registration"); } - if (string.IsNullOrEmpty(setupConfig.WebAppName)) + string messagingEndpoint; + string endpointName; + if (string.Equals(setupConfig.HostingMode, "AzureAppService", StringComparison.OrdinalIgnoreCase)) { - logger.LogError("Web App Name not configured in a365.config.json"); - throw new InvalidOperationException("Web App Name is required for messaging endpoint registration"); + if (string.IsNullOrEmpty(setupConfig.WebAppName)) + { + logger.LogError("Web App Name not configured in a365.config.json"); + throw new InvalidOperationException("Web App Name is required for messaging endpoint registration"); + } + + // Generate endpoint name with Azure Bot Service constraints (4-42 chars) + var baseEndpointName = $"{setupConfig.WebAppName}-endpoint"; + endpointName = EndpointHelper.GetEndpointName(baseEndpointName); + + // Construct messaging endpoint URL from web app name + messagingEndpoint = $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages"; + } + else // External hosting (non-Azure) + { + if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint)) + { + logger.LogError("MessagingEndpoint must be provided in a365.config.json for External hosting mode"); + throw new InvalidOperationException("MessagingEndpoint is required for messaging endpoint registration"); + } + + if (!Uri.TryCreate(setupConfig.MessagingEndpoint, UriKind.Absolute, out var uri) || + uri.Scheme != Uri.UriSchemeHttps) + { + logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}", + setupConfig.MessagingEndpoint); + throw new InvalidOperationException("MessagingEndpoint must be a valid HTTPS URL."); + } + + messagingEndpoint = setupConfig.MessagingEndpoint; + + // Derive endpoint name from host when there's no WebAppName + var hostPart = uri.Host.Replace('.', '-'); + var baseEndpointName = $"{hostPart}-endpoint"; + endpointName = EndpointHelper.GetEndpointName(baseEndpointName ); } - // Generate endpoint name with Azure Bot Service constraints (4-42 chars) - var baseEndpointName = $"{setupConfig.WebAppName}-endpoint"; - var endpointName = EndpointHelper.GetEndpointName(baseEndpointName); if (endpointName.Length < 4) { logger.LogError("Bot endpoint name '{EndpointName}' is too short (must be at least 4 characters)", endpointName); throw new InvalidOperationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)"); } - // Register messaging endpoint using agent blueprint identity and deployed web app URL - var messagingEndpoint = $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages"; - logger.LogInformation(" - Registering blueprint messaging endpoint"); logger.LogInformation(" * Endpoint Name: {EndpointName}", endpointName); logger.LogInformation(" * Messaging Endpoint: {Endpoint}", messagingEndpoint); @@ -441,74 +476,111 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( } } + /// + /// Ensure OAuth2 permission grants are set from blueprint to MCP server + /// private static async Task EnsureMcpOauth2PermissionGrantsAsync( GraphApiService graph, - Agent365Config cfg, + Agent365Config config, string[] scopes, ILogger logger, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(cfg.AgentBlueprintId)) + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) throw new InvalidOperationException("AgentBlueprintId (appId) is required."); - var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(cfg.TenantId, cfg.AgentBlueprintId, ct); + var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct); if (string.IsNullOrWhiteSpace(blueprintSpObjectId)) { - throw new InvalidOperationException($"Blueprint Service Principal not found for appId {cfg.AgentBlueprintId}. " + + throw new InvalidOperationException($"Blueprint Service Principal not found for appId {config.AgentBlueprintId}. " + "The service principal may not have propagated yet. Wait a few minutes and retry."); } - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(cfg.Environment); - var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(cfg.TenantId, resourceAppId, ct); + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct); if (string.IsNullOrWhiteSpace(Agent365ToolsSpObjectId)) { throw new InvalidOperationException($"Agent 365 Tools Service Principal not found for appId {resourceAppId}. " + - $"Ensure the Agent 365 Tools application is available in your tenant for environment: {cfg.Environment}"); + $"Ensure the Agent 365 Tools application is available in your tenant for environment: {config.Environment}"); } logger.LogInformation(" - OAuth2 grant: client {ClientId} to resource {ResourceId} scopes [{Scopes}]", blueprintSpObjectId, Agent365ToolsSpObjectId, string.Join(' ', scopes)); var response = await graph.CreateOrUpdateOauth2PermissionGrantAsync( - cfg.TenantId, blueprintSpObjectId, Agent365ToolsSpObjectId, scopes, ct); + config.TenantId, blueprintSpObjectId, Agent365ToolsSpObjectId, scopes, ct); if (!response) { throw new InvalidOperationException( - $"Failed to create/update OAuth2 permission grant from blueprint {cfg.AgentBlueprintId} to Agent 365 Tools {resourceAppId}. " + + $"Failed to create/update OAuth2 permission grant from blueprint {config.AgentBlueprintId} to Agent 365 Tools {resourceAppId}. " + "This may be due to insufficient permissions. Ensure you have DelegatedPermissionGrant.ReadWrite.All or Application.ReadWrite.All permissions."); } } + /// + /// Ensure inheritable permissions are set from blueprint to MCP server + /// private static async Task EnsureMcpInheritablePermissionsAsync( GraphApiService graph, - Agent365Config cfg, + Agent365Config config, string[] scopes, ILogger logger, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(cfg.AgentBlueprintId)) + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) throw new InvalidOperationException("AgentBlueprintId (appId) is required."); - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(cfg.Environment); + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); logger.LogInformation(" - Inheritable permissions: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", - cfg.AgentBlueprintId, resourceAppId, string.Join(' ', scopes)); + config.AgentBlueprintId, resourceAppId, string.Join(' ', scopes)); var (ok, alreadyExists, err) = await graph.SetInheritablePermissionsAsync( - cfg.TenantId, cfg.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct); + config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct); if (!ok && !alreadyExists) { - cfg.InheritanceConfigured = false; - cfg.InheritanceConfigError = err; + config.InheritanceConfigured = false; + config.InheritanceConfigError = err; throw new InvalidOperationException($"Failed to set inheritable permissions: {err}. " + "Ensure you have Application.ReadWrite.All permissions and the blueprint supports inheritable permissions."); } - cfg.InheritanceConfigured = true; - cfg.InheritablePermissionsAlreadyExist = alreadyExists; - cfg.InheritanceConfigError = null; + config.InheritanceConfigured = true; + config.InheritablePermissionsAlreadyExist = alreadyExists; + config.InheritanceConfigError = null; + } + + /// + /// Validate messaging endpoint configuration for non-Azure deployments + /// + private static void ValidateMessagingEndpointForNonAzure(Agent365Config config, ILogger logger) + { + if (!string.Equals(config.HostingMode, "External", StringComparison.OrdinalIgnoreCase)) + return; + + if (string.IsNullOrWhiteSpace(config.MessagingEndpoint)) + { + logger.LogError( + "For non-Azure deployments (hostingMode=External), 'messagingEndpoint' is required in a365.config.json. " + + "This is the HTTPS URL that Bot Framework will call (e.g. https://your-host.com/api/messages)."); + + throw new InvalidOperationException("Missing MessagingEndpoint for non-Azure deployment."); + } + + if (!Uri.TryCreate(config.MessagingEndpoint, UriKind.Absolute, out var uri) || + uri.Scheme != Uri.UriSchemeHttps) + { + logger.LogError( + "MessagingEndpoint must be a valid HTTPS URL for non-Azure deployments. Current value: {Endpoint}", + config.MessagingEndpoint); + + throw new InvalidOperationException("Invalid MessagingEndpoint URL for non-Azure deployment."); + } + + logger.LogInformation("Non-Azure hosting detected. Using external messaging endpoint: {Endpoint}", + config.MessagingEndpoint); } /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index ea4e4680..f9ab56fe 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -24,14 +24,27 @@ public class Agent365Config public List Validate() { var errors = new List(); + if (string.IsNullOrWhiteSpace(TenantId)) errors.Add("tenantId is required."); - if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); - if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); - if (string.IsNullOrWhiteSpace(Location)) errors.Add("location is required."); - if (string.IsNullOrWhiteSpace(AppServicePlanName)) errors.Add("appServicePlanName is required."); - if (string.IsNullOrWhiteSpace(WebAppName)) errors.Add("webAppName is required."); + + if (HostingMode.Equals("AzureAppService", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); + if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); + if (string.IsNullOrWhiteSpace(Location)) errors.Add("location is required."); + if (string.IsNullOrWhiteSpace(AppServicePlanName)) errors.Add("appServicePlanName is required."); + if (string.IsNullOrWhiteSpace(WebAppName)) errors.Add("webAppName is required."); + } + else + { + // External / non-Azure hosting + if (string.IsNullOrWhiteSpace(MessagingEndpoint)) + errors.Add("messagingEndpoint is required when hostingMode is 'External'."); + } + if (string.IsNullOrWhiteSpace(AgentIdentityDisplayName)) errors.Add("agentIdentityDisplayName is required."); if (string.IsNullOrWhiteSpace(DeploymentProjectPath)) errors.Add("deploymentProjectPath is required."); + // agentIdentityScopes and agentApplicationScopes are now hardcoded defaults // botName and botDisplayName are now derived, not required in config // Add more validation as needed (e.g., GUID format, allowed values, etc.) @@ -76,6 +89,21 @@ public List Validate() [JsonPropertyName("environment")] public string Environment { get; init; } = "preprod"; + /// + /// Hosting mode for the agent runtime. + /// - "AzureAppService" (default): CLI provisions App Service + MSI, uses webAppName. + /// - "External": non-Azure hosting (K8s, other cloud, on-prem). CLI skips Azure infra. + /// + [JsonPropertyName("hostingMode")] + public string HostingMode { get; init; } = "AzureAppService"; + + /// + /// For External hosting, this is the HTTPS messaging endpoint that Bot Framework will call, + /// For AzureAppService, this is optional; the CLI derives the endpoint from webAppName. + /// + [JsonPropertyName("messagingEndpoint")] + public string? MessagingEndpoint { get; init; } + #endregion #region App Service Configuration @@ -163,13 +191,32 @@ public List Validate() // BotName and BotDisplayName are now derived properties /// - /// Gets the internal name for the endpoint registration, derived from WebAppName. + /// Gets the internal name for the endpoint registration. + /// - For AzureAppService, derived from WebAppName. + /// - For External hosting, derived from MessagingEndpoint host if possible. /// [JsonIgnore] - public string BotName => string.IsNullOrWhiteSpace(WebAppName) ? string.Empty : $"{WebAppName}-endpoint"; + public string BotName + { + get + { + if (!string.IsNullOrWhiteSpace(WebAppName)) + { + return $"{WebAppName}-endpoint"; + } + + if (!string.IsNullOrWhiteSpace(MessagingEndpoint) && + Uri.TryCreate(MessagingEndpoint, UriKind.Absolute, out var uri)) + { + return $"{uri.Host.Replace('.', '-')}-endpoint"; + } + + return string.Empty; + } + } /// - /// Gets the display name for the bot, derived from AgentBlueprintDisplayName. + /// Gets the display name for the bot, derived from AgentBlueprintDisplayName or WebAppName. /// [JsonIgnore] public string BotDisplayName => !string.IsNullOrWhiteSpace(AgentBlueprintDisplayName) ? AgentBlueprintDisplayName! : WebAppName; @@ -530,4 +577,4 @@ public class AtgConfiguration public List McpServers { get; set; } = new(); public List ToolsServers { get; set; } = new(); public string Agent365ToolsEndpoint { get; set; } = string.Empty; -} +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index ae697658..3bfb1d5a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -136,6 +136,10 @@ public async Task RunAsync(string configPath, string generatedConfigPath, if (string.IsNullOrWhiteSpace(planSku)) planSku = "B1"; var deploymentProjectPath = Get("deploymentProjectPath"); + var hostingModeRaw = Get("hostingMode"); + var isExternalHosting = string.Equals(hostingModeRaw, "External", StringComparison.OrdinalIgnoreCase); + + var skipInfra = blueprintOnly || isExternalHosting; if (new[] { subscriptionId, tenantId, resourceGroup, planName, webAppName, location }.Any(string.IsNullOrWhiteSpace)) { @@ -143,6 +147,28 @@ public async Task RunAsync(string configPath, string generatedConfigPath, return false; } + if (!skipInfra) + { + // Azure hosting scenario – need full infra details + if (new[] { subscriptionId, resourceGroup, planName, webAppName, location }.Any(string.IsNullOrWhiteSpace)) + { + _logger.LogError( + "Config missing required properties for Azure hosting. " + + "Need subscriptionId, resourceGroup, appServicePlanName, webAppName, location."); + return false; + } + } + else + { + // Non-Azure hosting or --blueprint: no infra required + if (string.IsNullOrWhiteSpace(subscriptionId)) + { + _logger.LogWarning( + "subscriptionId is not set. This is acceptable for blueprint-only or External hosting mode " + + "as Azure infrastructure will not be provisioned."); + } + } + // Detect project platform for appropriate runtime configuration var platform = Models.ProjectPlatform.DotNet; // Default fallback if (!string.IsNullOrWhiteSpace(deploymentProjectPath)) @@ -166,86 +192,93 @@ public async Task RunAsync(string configPath, string generatedConfigPath, // ======================================================================== // Phase 0: Ensure Azure CLI is logged in with proper scope // ======================================================================== - _logger.LogInformation("==> [0/5] Verifying Azure CLI authentication"); - - // Check if logged in - var accountCheck = await _executor.ExecuteAsync("az", "account show", captureOutput: true, suppressErrorLogging: true); - if (!accountCheck.Success) + if (!skipInfra) { - _logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope..."); - _logger.LogInformation("A browser window will open for authentication."); - - // Use standard login without scope parameter (more reliable) - var loginResult = await _executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); + _logger.LogInformation("==> [0/5] Verifying Azure CLI authentication"); - if (!loginResult.Success) + // Check if logged in + var accountCheck = await _executor.ExecuteAsync("az", "account show", captureOutput: true, suppressErrorLogging: true); + if (!accountCheck.Success) { - _logger.LogError("Azure CLI login failed. Please run manually: az login --scope https://management.core.windows.net//.default"); - return false; + _logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope..."); + _logger.LogInformation("A browser window will open for authentication."); + + // Use standard login without scope parameter (more reliable) + var loginResult = await _executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); + + if (!loginResult.Success) + { + _logger.LogError("Azure CLI login failed. Please run manually: az login --scope https://management.core.windows.net//.default"); + return false; + } + + _logger.LogInformation("Azure CLI login successful!"); + + // Wait a moment for the login to fully complete + await Task.Delay(2000, cancellationToken); } - - _logger.LogInformation("Azure CLI login successful!"); - - // Wait a moment for the login to fully complete - await Task.Delay(2000, cancellationToken); - } - else - { - _logger.LogInformation("Azure CLI already authenticated"); - } - - // Verify we have the management scope - if not, try to acquire it - _logger.LogInformation("Verifying access to Azure management resources..."); - var tokenCheck = await _executor.ExecuteAsync( - "az", - "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv", - captureOutput: true, - suppressErrorLogging: true, - cancellationToken: cancellationToken); - - if (!tokenCheck.Success) - { - _logger.LogWarning("Unable to acquire management scope token. Attempting re-authentication..."); - _logger.LogInformation("A browser window will open for authentication."); - - // Try standard login first (more reliable than scope-specific login) - var loginResult = await _executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); - - if (!loginResult.Success) + else { - _logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default"); - return false; + _logger.LogInformation("Azure CLI already authenticated"); } - _logger.LogInformation("Azure CLI re-authentication successful!"); - - // Wait a moment for the token cache to update - await Task.Delay(2000, cancellationToken); - - // Verify management token is now available - var retryTokenCheck = await _executor.ExecuteAsync( + // Verify we have the management scope - if not, try to acquire it + _logger.LogInformation("Verifying access to Azure management resources..."); + var tokenCheck = await _executor.ExecuteAsync( "az", "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv", captureOutput: true, suppressErrorLogging: true, cancellationToken: cancellationToken); - if (!retryTokenCheck.Success) + if (!tokenCheck.Success) { - _logger.LogWarning("Still unable to acquire management scope token after re-authentication."); - _logger.LogWarning("Continuing anyway - you may encounter permission errors later."); + _logger.LogWarning("Unable to acquire management scope token. Attempting re-authentication..."); + _logger.LogInformation("A browser window will open for authentication."); + + // Try standard login first (more reliable than scope-specific login) + var loginResult = await _executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); + + if (!loginResult.Success) + { + _logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default"); + return false; + } + + _logger.LogInformation("Azure CLI re-authentication successful!"); + + // Wait a moment for the token cache to update + await Task.Delay(2000, cancellationToken); + + // Verify management token is now available + var retryTokenCheck = await _executor.ExecuteAsync( + "az", + "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv", + captureOutput: true, + suppressErrorLogging: true, + cancellationToken: cancellationToken); + + if (!retryTokenCheck.Success) + { + _logger.LogWarning("Still unable to acquire management scope token after re-authentication."); + _logger.LogWarning("Continuing anyway - you may encounter permission errors later."); + } + else + { + _logger.LogInformation("Management scope token acquired successfully!"); + } } else { - _logger.LogInformation("Management scope token acquired successfully!"); + _logger.LogInformation("Management scope verified successfully"); } + + _logger.LogInformation(""); } else { - _logger.LogInformation("Management scope verified successfully"); + _logger.LogInformation("==> [0/5] Skipping Azure management authentication (blueprint-only or External hosting)"); } - - _logger.LogInformation(""); // ======================================================================== // Phase 1: Deploy Agent runtime (App Service) + System-assigned Managed Identity @@ -253,9 +286,14 @@ public async Task RunAsync(string configPath, string generatedConfigPath, string? principalId = null; JsonObject generatedConfig = new JsonObject(); - if (blueprintOnly) + if (skipInfra) { - _logger.LogInformation("==> [1/5] Skipping Azure infrastructure (--blueprint mode)"); + // Covers BOTH: + // - blueprintOnly == true + // - hostingMode == External (non-Azure) + var modeMessage = blueprintOnly ? "--blueprint mode" : "External hosting (non-Azure)"; + + _logger.LogInformation("==> [1/5] Skipping Azure infrastructure ({Mode})", modeMessage); _logger.LogInformation("Loading existing configuration..."); // Load existing generated config if available From fb1eb16a6d49ee0c49aa1afec9e1bfd351167eda Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Wed, 19 Nov 2025 17:20:30 -0800 Subject: [PATCH 08/13] resolveing comments --- .../Commands/SetupCommand.cs | 2 +- .../Models/Agent365Config.cs | 4 ++-- .../Services/A365SetupRunner.cs | 7 ------- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 75bf1ddc..11c19c8d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -448,7 +448,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( // Derive endpoint name from host when there's no WebAppName var hostPart = uri.Host.Replace('.', '-'); var baseEndpointName = $"{hostPart}-endpoint"; - endpointName = EndpointHelper.GetEndpointName(baseEndpointName ); + endpointName = EndpointHelper.GetEndpointName(baseEndpointName); } if (endpointName.Length < 4) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index f9ab56fe..f576686c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -27,7 +27,7 @@ public List Validate() if (string.IsNullOrWhiteSpace(TenantId)) errors.Add("tenantId is required."); - if (HostingMode.Equals("AzureAppService", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(HostingMode, "AzureAppService", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); @@ -98,7 +98,7 @@ public List Validate() public string HostingMode { get; init; } = "AzureAppService"; /// - /// For External hosting, this is the HTTPS messaging endpoint that Bot Framework will call, + /// For External hosting, this is the HTTPS messaging endpoint that Bot Framework will call. /// For AzureAppService, this is optional; the CLI derives the endpoint from webAppName. /// [JsonPropertyName("messagingEndpoint")] diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index 3bfb1d5a..91a8cdb9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -140,13 +140,6 @@ public async Task RunAsync(string configPath, string generatedConfigPath, var isExternalHosting = string.Equals(hostingModeRaw, "External", StringComparison.OrdinalIgnoreCase); var skipInfra = blueprintOnly || isExternalHosting; - - if (new[] { subscriptionId, tenantId, resourceGroup, planName, webAppName, location }.Any(string.IsNullOrWhiteSpace)) - { - _logger.LogError("Config missing required properties. Need subscriptionId, tenantId, resourceGroup, appServicePlanName, webAppName, location."); - return false; - } - if (!skipInfra) { // Azure hosting scenario – need full infra details From e07c30eacc2f06c16a64ffc651619d9dec4a003f Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Thu, 20 Nov 2025 13:34:46 -0800 Subject: [PATCH 09/13] resolveing comments --- .../Commands/SetupCommand.cs | 58 +++++-------------- .../Models/Agent365Config.cs | 32 ++++++---- .../Services/A365SetupRunner.cs | 6 +- .../Services/ConfigService.cs | 34 +++++++---- 4 files changed, 60 insertions(+), 70 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 11c19c8d..34a01ab0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -65,9 +65,6 @@ public static Command CreateCommand( { // Validate configuration even in dry-run mode var dryRunConfig = await configService.LoadAsync(config.FullName); - - // Validate non-Azure messaging endpoint - ValidateMessagingEndpointForNonAzure(dryRunConfig, logger); logger.LogInformation("DRY RUN: Agent 365 Setup - Blueprint + Messaging Endpoint Registration"); logger.LogInformation("This would execute the following operations:"); @@ -89,14 +86,17 @@ public static Command CreateCommand( { // Load configuration - ConfigService automatically finds generated config in same directory var setupConfig = await configService.LoadAsync(config.FullName); - - // Early fail for non-Azure if no MessagingEndpoint - ValidateMessagingEndpointForNonAzure(setupConfig, logger); - - // Validate Azure CLI authentication, subscription, and environment - if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) + if (setupConfig.NeedWebAppDeployment) { - Environment.Exit(1); + // Validate Azure CLI authentication, subscription, and environment + if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) + { + Environment.Exit(1); + } + } + else + { + logger.LogInformation("NeedWebAppDeployment=no – skipping Azure subscription validation."); } logger.LogInformation(""); @@ -412,7 +412,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( string messagingEndpoint; string endpointName; - if (string.Equals(setupConfig.HostingMode, "AzureAppService", StringComparison.OrdinalIgnoreCase)) + if (setupConfig.NeedWebAppDeployment) { if (string.IsNullOrEmpty(setupConfig.WebAppName)) { @@ -427,8 +427,9 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( // Construct messaging endpoint URL from web app name messagingEndpoint = $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages"; } - else // External hosting (non-Azure) + else // Non-Azure hosting { + // No deployment – use the provided botMessagingEndpoint if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint)) { logger.LogError("MessagingEndpoint must be provided in a365.config.json for External hosting mode"); @@ -439,7 +440,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( uri.Scheme != Uri.UriSchemeHttps) { logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}", - setupConfig.MessagingEndpoint); + setupConfig.BotMessagingEndpoint); throw new InvalidOperationException("MessagingEndpoint must be a valid HTTPS URL."); } @@ -552,37 +553,6 @@ private static async Task EnsureMcpInheritablePermissionsAsync( config.InheritanceConfigError = null; } - /// - /// Validate messaging endpoint configuration for non-Azure deployments - /// - private static void ValidateMessagingEndpointForNonAzure(Agent365Config config, ILogger logger) - { - if (!string.Equals(config.HostingMode, "External", StringComparison.OrdinalIgnoreCase)) - return; - - if (string.IsNullOrWhiteSpace(config.MessagingEndpoint)) - { - logger.LogError( - "For non-Azure deployments (hostingMode=External), 'messagingEndpoint' is required in a365.config.json. " + - "This is the HTTPS URL that Bot Framework will call (e.g. https://your-host.com/api/messages)."); - - throw new InvalidOperationException("Missing MessagingEndpoint for non-Azure deployment."); - } - - if (!Uri.TryCreate(config.MessagingEndpoint, UriKind.Absolute, out var uri) || - uri.Scheme != Uri.UriSchemeHttps) - { - logger.LogError( - "MessagingEndpoint must be a valid HTTPS URL for non-Azure deployments. Current value: {Endpoint}", - config.MessagingEndpoint); - - throw new InvalidOperationException("Invalid MessagingEndpoint URL for non-Azure deployment."); - } - - logger.LogInformation("Non-Azure hosting detected. Using external messaging endpoint: {Endpoint}", - config.MessagingEndpoint); - } - /// /// Display comprehensive setup summary showing what succeeded and what failed /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index f576686c..3e3f178b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -27,7 +27,7 @@ public List Validate() if (string.IsNullOrWhiteSpace(TenantId)) errors.Add("tenantId is required."); - if (string.Equals(HostingMode, "AzureAppService", StringComparison.OrdinalIgnoreCase)) + if (NeedWebAppDeployment) { if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); @@ -37,9 +37,9 @@ public List Validate() } else { - // External / non-Azure hosting + // Non-Azure hosting if (string.IsNullOrWhiteSpace(MessagingEndpoint)) - errors.Add("messagingEndpoint is required when hostingMode is 'External'."); + errors.Add("messagingEndpoint is required when needWebAppDeployment is 'no'."); } if (string.IsNullOrWhiteSpace(AgentIdentityDisplayName)) errors.Add("agentIdentityDisplayName is required."); @@ -89,14 +89,6 @@ public List Validate() [JsonPropertyName("environment")] public string Environment { get; init; } = "preprod"; - /// - /// Hosting mode for the agent runtime. - /// - "AzureAppService" (default): CLI provisions App Service + MSI, uses webAppName. - /// - "External": non-Azure hosting (K8s, other cloud, on-prem). CLI skips Azure infra. - /// - [JsonPropertyName("hostingMode")] - public string HostingMode { get; init; } = "AzureAppService"; - /// /// For External hosting, this is the HTTPS messaging endpoint that Bot Framework will call. /// For AzureAppService, this is optional; the CLI derives the endpoint from webAppName. @@ -104,6 +96,15 @@ public List Validate() [JsonPropertyName("messagingEndpoint")] public string? MessagingEndpoint { get; init; } + /// + /// Whether the CLI should create and deploy an Azure Web App for this agent. + /// Backed by the 'needDeployment' config value: + /// - "yes" (default) => CLI provisions App Service + MSI, a365 deploy app is active. + /// - "no" => CLI does NOT create a web app; a365 deploy app is a no-op and botMessagingEndpoint must be provided. + /// + [JsonPropertyName("needDeployment")] + public string NeedDeployment { get; init; } = "yes"; + #endregion #region App Service Configuration @@ -193,7 +194,7 @@ public List Validate() /// /// Gets the internal name for the endpoint registration. /// - For AzureAppService, derived from WebAppName. - /// - For External hosting, derived from MessagingEndpoint host if possible. + /// - For non-Azure hosting, derived from BotMessagingEndpoint host if possible. /// [JsonIgnore] public string BotName @@ -215,6 +216,13 @@ public string BotName } } + /// + /// Whether the CLI should perform web app deployment for the agent. + /// + [JsonIgnore] + public bool NeedWebAppDeployment => + !string.Equals(NeedDeployment, "no", StringComparison.OrdinalIgnoreCase); + /// /// Gets the display name for the bot, derived from AgentBlueprintDisplayName or WebAppName. /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index 91a8cdb9..f458df47 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -136,10 +136,10 @@ public async Task RunAsync(string configPath, string generatedConfigPath, if (string.IsNullOrWhiteSpace(planSku)) planSku = "B1"; var deploymentProjectPath = Get("deploymentProjectPath"); - var hostingModeRaw = Get("hostingMode"); - var isExternalHosting = string.Equals(hostingModeRaw, "External", StringComparison.OrdinalIgnoreCase); + var needDeploymentRaw = Get("needDeployment"); + var needWebAppDeployment = !string.Equals(needDeploymentRaw, "no", StringComparison.OrdinalIgnoreCase); - var skipInfra = blueprintOnly || isExternalHosting; + var skipInfra = blueprintOnly || !needWebAppDeployment; if (!skipInfra) { // Azure hosting scenario – need full infra details diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs index d0d524b0..6ddf7d11 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs @@ -377,20 +377,32 @@ public async Task ValidateAsync(Agent365Config config) var errors = new List(); var warnings = new List(); - // Validate required static properties ValidateRequired(config.TenantId, nameof(config.TenantId), errors); - ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); - ValidateRequired(config.ResourceGroup, nameof(config.ResourceGroup), errors); - ValidateRequired(config.Location, nameof(config.Location), errors); - - // Validate GUID formats ValidateGuid(config.TenantId, nameof(config.TenantId), errors); - ValidateGuid(config.SubscriptionId, nameof(config.SubscriptionId), errors); - // Validate Azure naming conventions - ValidateResourceGroupName(config.ResourceGroup, errors); - ValidateAppServicePlanName(config.AppServicePlanName, errors); - ValidateWebAppName(config.WebAppName, errors); + if (!config.NeedWebAppDeployment) + { + // Validate required static properties + ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); + ValidateRequired(config.ResourceGroup, nameof(config.ResourceGroup), errors); + ValidateRequired(config.Location, nameof(config.Location), errors); + ValidateRequired(config.AppServicePlanName, nameof(config.AppServicePlanName), errors); + ValidateRequired(config.WebAppName, nameof(config.WebAppName), errors); + + // Validate GUID formats + ValidateGuid(config.SubscriptionId, nameof(config.SubscriptionId), errors); + + // Validate Azure naming conventions + ValidateResourceGroupName(config.ResourceGroup, errors); + ValidateAppServicePlanName(config.AppServicePlanName, errors); + ValidateWebAppName(config.WebAppName, errors); + } + else + { + // Only validate bot messaging endpoint + ValidateRequired(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); + ValidateUrl(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); + } // Validate dynamic properties if they exist if (config.ManagedIdentityPrincipalId != null) From 926760b825524643bdd888d66c7fb52d82d41059 Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Thu, 20 Nov 2025 14:18:14 -0800 Subject: [PATCH 10/13] fix failed tests --- .../Models/Agent365Config.cs | 3 +-- .../Services/ConfigService.cs | 9 +++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index 3e3f178b..2c2d6533 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -220,8 +220,7 @@ public string BotName /// Whether the CLI should perform web app deployment for the agent. /// [JsonIgnore] - public bool NeedWebAppDeployment => - !string.Equals(NeedDeployment, "no", StringComparison.OrdinalIgnoreCase); + public bool NeedWebAppDeployment => !string.Equals(NeedDeployment, "no", StringComparison.OrdinalIgnoreCase); /// /// Gets the display name for the bot, derived from AgentBlueprintDisplayName or WebAppName. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs index 6ddf7d11..70771f64 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs @@ -312,7 +312,7 @@ public async Task LoadAsync( _logger?.LogError("Configuration validation failed:"); foreach (var error in validationResult.Errors) { - _logger?.LogError(" � {Error}", error); + _logger?.LogError(" * {Error}", error); } // Convert validation errors to structured exception @@ -328,7 +328,7 @@ public async Task LoadAsync( { foreach (var warning in validationResult.Warnings) { - _logger?.LogWarning(" � {Warning}", warning); + _logger?.LogWarning(" * {Warning}", warning); } } @@ -380,7 +380,7 @@ public async Task ValidateAsync(Agent365Config config) ValidateRequired(config.TenantId, nameof(config.TenantId), errors); ValidateGuid(config.TenantId, nameof(config.TenantId), errors); - if (!config.NeedWebAppDeployment) + if (config.NeedWebAppDeployment) { // Validate required static properties ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); @@ -483,7 +483,8 @@ public async Task CreateDefaultConfigAsync( AgentIdentityDisplayName = string.Empty, // AgentIdentityScopes and AgentApplicationScopes are now hardcoded defaults DeploymentProjectPath = string.Empty, - AgentDescription = string.Empty + AgentDescription = string.Empty, + NeedDeployment = "yes" }; // Only serialize static (init) properties for the config file From 43cf118ef620e40680b2547f0acc846354cc9b9e Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Thu, 20 Nov 2025 14:23:56 -0800 Subject: [PATCH 11/13] fix typos --- .../Commands/SetupCommand.cs | 4 ++-- .../Models/Agent365Config.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 34a01ab0..a5e1f895 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -429,7 +429,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( } else // Non-Azure hosting { - // No deployment – use the provided botMessagingEndpoint + // No deployment – use the provided MessagingEndpoint if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint)) { logger.LogError("MessagingEndpoint must be provided in a365.config.json for External hosting mode"); @@ -440,7 +440,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( uri.Scheme != Uri.UriSchemeHttps) { logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}", - setupConfig.BotMessagingEndpoint); + setupConfig.MessagingEndpoint); throw new InvalidOperationException("MessagingEndpoint must be a valid HTTPS URL."); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index 2c2d6533..e94d541c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -100,7 +100,7 @@ public List Validate() /// Whether the CLI should create and deploy an Azure Web App for this agent. /// Backed by the 'needDeployment' config value: /// - "yes" (default) => CLI provisions App Service + MSI, a365 deploy app is active. - /// - "no" => CLI does NOT create a web app; a365 deploy app is a no-op and botMessagingEndpoint must be provided. + /// - "no" => CLI does NOT create a web app; a365 deploy app is a no-op and MessagingEndpoint must be provided. /// [JsonPropertyName("needDeployment")] public string NeedDeployment { get; init; } = "yes"; @@ -194,7 +194,7 @@ public List Validate() /// /// Gets the internal name for the endpoint registration. /// - For AzureAppService, derived from WebAppName. - /// - For non-Azure hosting, derived from BotMessagingEndpoint host if possible. + /// - For non-Azure hosting, derived from MessagingEndpoint host if possible. /// [JsonIgnore] public string BotName From 50f65dbcf507c08727d7b26ef823b5a58a595ac9 Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Thu, 20 Nov 2025 15:52:47 -0800 Subject: [PATCH 12/13] Add SetupValidationException --- .../Commands/SetupCommand.cs | 75 +++++++++++++++---- .../Constants/ErrorCodes.cs | 1 + .../Exceptions/Agent365Exception.cs | 8 +- .../Exceptions/SetupValidationException.cs | 30 ++++++++ .../Services/A365SetupRunner.cs | 2 +- 5 files changed, 95 insertions(+), 21 deletions(-) create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/SetupValidationException.cs diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index a5e1f895..c9d271f0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -147,7 +147,7 @@ public static Command CreateCommand( logger.LogError("Agent blueprint creation failed"); setupResults.BlueprintCreated = false; setupResults.Errors.Add("Agent blueprint creation failed"); - throw new InvalidOperationException("Setup runner execution failed"); + throw new SetupValidationException("Setup runner execution failed"); } setupResults.BlueprintCreated = true; @@ -224,10 +224,10 @@ await EnsureMcpInheritablePermissionsAsync( logger.LogInformation(""); if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) - throw new InvalidOperationException("AgentBlueprintId is required."); + throw new SetupValidationException("AgentBlueprintId is required."); var blueprintSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync(setupConfig.TenantId, setupConfig.AgentBlueprintId) - ?? throw new InvalidOperationException($"Blueprint Service Principal not found for appId {setupConfig.AgentBlueprintId}"); + ?? throw new SetupValidationException($"Blueprint Service Principal not found for appId {setupConfig.AgentBlueprintId}"); // Ensure Messaging Bot API SP exists var botApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync( @@ -407,7 +407,21 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId)) { logger.LogError("Agent Blueprint ID not found. Blueprint creation may have failed."); - throw new InvalidOperationException("Agent Blueprint ID is required for messaging endpoint registration"); + throw new SetupValidationException( + issueDescription: "Agent blueprint was not found – messaging endpoint cannot be registered.", + errorDetails: new List + { + "AgentBlueprintId is missing from configuration. This usually means the blueprint creation step failed or a365.generated.config.json is out of sync." + }, + mitigationSteps: new List + { + "Verify that 'a365 setup' completed Step 1 (Agent blueprint creation) without errors.", + "Check a365.generated.config.json for 'agentBlueprintId'. If it's missing or incorrect, re-run 'a365 setup'." + }, + context: new Dictionary + { + ["AgentBlueprintId"] = setupConfig.AgentBlueprintId ?? "" + }); } string messagingEndpoint; @@ -417,7 +431,23 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( if (string.IsNullOrEmpty(setupConfig.WebAppName)) { logger.LogError("Web App Name not configured in a365.config.json"); - throw new InvalidOperationException("Web App Name is required for messaging endpoint registration"); + throw new SetupValidationException( + issueDescription: "Web App name is required to register a messaging endpoint when needDeployment is 'yes'.", + errorDetails: new List + { + "NeedWebAppDeployment is true, but 'webAppName' was not provided in a365.config.json." + }, + mitigationSteps: new List + { + "Open a365.config.json and ensure 'webAppName' is set to the Azure Web App name.", + "If you do not want the CLI to deploy an Azure Web App, set \"needDeployment\": \"no\" and provide \"MessagingEndpoint\" instead.", + "Re-run 'a365 setup'." + }, + context: new Dictionary + { + ["needDeployment"] = setupConfig.NeedWebAppDeployment.ToString(), + ["webAppName"] = setupConfig.WebAppName ?? "" + }); } // Generate endpoint name with Azure Bot Service constraints (4-42 chars) @@ -432,8 +462,21 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( // No deployment – use the provided MessagingEndpoint if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint)) { - logger.LogError("MessagingEndpoint must be provided in a365.config.json for External hosting mode"); - throw new InvalidOperationException("MessagingEndpoint is required for messaging endpoint registration"); + logger.LogError("MessagingEndpoint must be provided in a365.config.json for non-Azure hosting."); + throw new SetupValidationException( + issueDescription: "Messaging endpoint is required for messaging endpoint registration.", + errorDetails: new List + { + "needDeployment is set to 'no', but MessagingEndpoint was not provided in a365.config.json." + }, + mitigationSteps: new List + { + "Open your a365.config.json file.", + "If you want the CLI to deploy an Azure Web App, set \"needDeployment\": \"yes\" and provide \"webAppName\".", + "If your agent is hosted elsewhere, keep \"needDeployment\": \"no\" and add a \"MessagingEndpoint\" with a valid HTTPS URL (e.g. \"https://your-host/api/messages\").", + "Re-run 'a365 setup'." + } + ); } if (!Uri.TryCreate(setupConfig.MessagingEndpoint, UriKind.Absolute, out var uri) || @@ -441,7 +484,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( { logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}", setupConfig.MessagingEndpoint); - throw new InvalidOperationException("MessagingEndpoint must be a valid HTTPS URL."); + throw new SetupValidationException("MessagingEndpoint must be a valid HTTPS URL."); } messagingEndpoint = setupConfig.MessagingEndpoint; @@ -455,7 +498,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( if (endpointName.Length < 4) { logger.LogError("Bot endpoint name '{EndpointName}' is too short (must be at least 4 characters)", endpointName); - throw new InvalidOperationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)"); + throw new SetupValidationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)"); } logger.LogInformation(" - Registering blueprint messaging endpoint"); @@ -473,7 +516,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( if (!endpointRegistered) { logger.LogError("Failed to register blueprint messaging endpoint"); - throw new InvalidOperationException("Blueprint messaging endpoint registration failed"); + throw new SetupValidationException("Blueprint messaging endpoint registration failed"); } } @@ -488,12 +531,12 @@ private static async Task EnsureMcpOauth2PermissionGrantsAsync( CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) - throw new InvalidOperationException("AgentBlueprintId (appId) is required."); + throw new SetupValidationException("AgentBlueprintId (appId) is required."); var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct); if (string.IsNullOrWhiteSpace(blueprintSpObjectId)) { - throw new InvalidOperationException($"Blueprint Service Principal not found for appId {config.AgentBlueprintId}. " + + throw new SetupValidationException($"Blueprint Service Principal not found for appId {config.AgentBlueprintId}. " + "The service principal may not have propagated yet. Wait a few minutes and retry."); } @@ -501,7 +544,7 @@ private static async Task EnsureMcpOauth2PermissionGrantsAsync( var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct); if (string.IsNullOrWhiteSpace(Agent365ToolsSpObjectId)) { - throw new InvalidOperationException($"Agent 365 Tools Service Principal not found for appId {resourceAppId}. " + + throw new SetupValidationException($"Agent 365 Tools Service Principal not found for appId {resourceAppId}. " + $"Ensure the Agent 365 Tools application is available in your tenant for environment: {config.Environment}"); } @@ -513,7 +556,7 @@ private static async Task EnsureMcpOauth2PermissionGrantsAsync( if (!response) { - throw new InvalidOperationException( + throw new SetupValidationException( $"Failed to create/update OAuth2 permission grant from blueprint {config.AgentBlueprintId} to Agent 365 Tools {resourceAppId}. " + "This may be due to insufficient permissions. Ensure you have DelegatedPermissionGrant.ReadWrite.All or Application.ReadWrite.All permissions."); } @@ -530,7 +573,7 @@ private static async Task EnsureMcpInheritablePermissionsAsync( CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) - throw new InvalidOperationException("AgentBlueprintId (appId) is required."); + throw new SetupValidationException("AgentBlueprintId (appId) is required."); var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); @@ -544,7 +587,7 @@ private static async Task EnsureMcpInheritablePermissionsAsync( { config.InheritanceConfigured = false; config.InheritanceConfigError = err; - throw new InvalidOperationException($"Failed to set inheritable permissions: {err}. " + + throw new SetupValidationException($"Failed to set inheritable permissions: {err}. " + "Ensure you have Application.ReadWrite.All permissions and the blueprint supports inheritable permissions."); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs index 8c2db1be..5625538a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs @@ -11,5 +11,6 @@ public static class ErrorCodes public const string DeploymentScopesFailed = "DEPLOYMENT_SCOPES_FAILED"; public const string DeploymentMcpFailed = "DEPLOYMENT_MCP_FAILED"; public const string HighPrivilegeScopeDetected = "HIGH_PRIVILEGE_SCOPE_DETECTED"; + public const string SetupValidationFailed = "SETUP_VALIDATION_FAILED"; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs index 28e8ccbc..15ff1e8b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs @@ -1,6 +1,6 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using System.Text; namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; @@ -80,7 +80,7 @@ private static string BuildMessage(string errorCode, string issueDescription, Li sb.AppendLine(); foreach (var detail in errorDetails) { - sb.AppendLine($" � {detail}"); + sb.AppendLine($" * {detail}"); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/SetupValidationException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/SetupValidationException.cs new file mode 100644 index 00000000..63a414a0 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/SetupValidationException.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; + +namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; + +/// +/// Validation errors that occur during `a365 setup` (user-fixable issues). +/// +public sealed class SetupValidationException : Agent365Exception +{ + public override int ExitCode => 2; + + public SetupValidationException( + string issueDescription, + List? errorDetails = null, + List? mitigationSteps = null, + Dictionary? context = null, + Exception? innerException = null) + : base( + errorCode: ErrorCodes.SetupValidationFailed, + issueDescription: issueDescription, + errorDetails: errorDetails, + mitigationSteps: mitigationSteps, + context: context, + innerException: innerException) + { + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index f458df47..d00adda0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -234,7 +234,7 @@ public async Task RunAsync(string configPath, string generatedConfigPath, if (!loginResult.Success) { - _logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default"); + _logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default"); return false; } From 3344c263a2848ea8c658c518e33f0991202610fd Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Thu, 20 Nov 2025 17:23:49 -0800 Subject: [PATCH 13/13] Change needDeploy to bool --- .../Commands/SetupCommand.cs | 10 +++---- .../Models/Agent365Config.cs | 16 ++++------ .../Services/A365SetupRunner.cs | 29 +++++++++++++++++-- .../Services/ConfigService.cs | 5 ++-- 4 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index c9d271f0..8d5e9712 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -86,7 +86,7 @@ public static Command CreateCommand( { // Load configuration - ConfigService automatically finds generated config in same directory var setupConfig = await configService.LoadAsync(config.FullName); - if (setupConfig.NeedWebAppDeployment) + if (setupConfig.NeedDeployment) { // Validate Azure CLI authentication, subscription, and environment if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) @@ -96,7 +96,7 @@ public static Command CreateCommand( } else { - logger.LogInformation("NeedWebAppDeployment=no – skipping Azure subscription validation."); + logger.LogInformation("NeedDeployment=false – skipping Azure subscription validation."); } logger.LogInformation(""); @@ -426,7 +426,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( string messagingEndpoint; string endpointName; - if (setupConfig.NeedWebAppDeployment) + if (setupConfig.NeedDeployment) { if (string.IsNullOrEmpty(setupConfig.WebAppName)) { @@ -435,7 +435,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( issueDescription: "Web App name is required to register a messaging endpoint when needDeployment is 'yes'.", errorDetails: new List { - "NeedWebAppDeployment is true, but 'webAppName' was not provided in a365.config.json." + "NeedDeployment is true, but 'webAppName' was not provided in a365.config.json." }, mitigationSteps: new List { @@ -445,7 +445,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( }, context: new Dictionary { - ["needDeployment"] = setupConfig.NeedWebAppDeployment.ToString(), + ["needDeployment"] = setupConfig.NeedDeployment.ToString(), ["webAppName"] = setupConfig.WebAppName ?? "" }); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index e94d541c..6b5e5ab5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -27,7 +27,7 @@ public List Validate() if (string.IsNullOrWhiteSpace(TenantId)) errors.Add("tenantId is required."); - if (NeedWebAppDeployment) + if (NeedDeployment) { if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); @@ -39,7 +39,7 @@ public List Validate() { // Non-Azure hosting if (string.IsNullOrWhiteSpace(MessagingEndpoint)) - errors.Add("messagingEndpoint is required when needWebAppDeployment is 'no'."); + errors.Add("messagingEndpoint is required when needDeployment is 'no'."); } if (string.IsNullOrWhiteSpace(AgentIdentityDisplayName)) errors.Add("agentIdentityDisplayName is required."); @@ -99,11 +99,11 @@ public List Validate() /// /// Whether the CLI should create and deploy an Azure Web App for this agent. /// Backed by the 'needDeployment' config value: - /// - "yes" (default) => CLI provisions App Service + MSI, a365 deploy app is active. - /// - "no" => CLI does NOT create a web app; a365 deploy app is a no-op and MessagingEndpoint must be provided. + /// - true (default) => CLI provisions App Service + MSI, a365 deploy app is active. + /// - false => CLI does NOT create a web app; a365 deploy app is a no-op and MessagingEndpoint must be provided. /// [JsonPropertyName("needDeployment")] - public string NeedDeployment { get; init; } = "yes"; + public bool NeedDeployment { get; init; } = true; #endregion @@ -216,12 +216,6 @@ public string BotName } } - /// - /// Whether the CLI should perform web app deployment for the agent. - /// - [JsonIgnore] - public bool NeedWebAppDeployment => !string.Equals(NeedDeployment, "no", StringComparison.OrdinalIgnoreCase); - /// /// Gets the display name for the bot, derived from AgentBlueprintDisplayName or WebAppName. /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index d00adda0..b1134575 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -136,10 +136,10 @@ public async Task RunAsync(string configPath, string generatedConfigPath, if (string.IsNullOrWhiteSpace(planSku)) planSku = "B1"; var deploymentProjectPath = Get("deploymentProjectPath"); - var needDeploymentRaw = Get("needDeployment"); - var needWebAppDeployment = !string.Equals(needDeploymentRaw, "no", StringComparison.OrdinalIgnoreCase); - var skipInfra = blueprintOnly || !needWebAppDeployment; + bool needDeployment = CheckNeedDeployment(cfg); + + var skipInfra = blueprintOnly || !needDeployment; if (!skipInfra) { // Azure hosting scenario – need full infra details @@ -1748,6 +1748,29 @@ private async Task EnsureDelegatedConsentWithRetriesAsync( return false; } + + private bool CheckNeedDeployment(JsonObject setupConfig) + { + bool needDeployment = true; // default + if (setupConfig.TryGetPropertyValue("needDeployment", out var needDeploymentNode) && + needDeploymentNode is JsonValue nv) + { + // Prefer native bool + if (nv.TryGetValue(out var boolVal)) + { + needDeployment = boolVal; + } + else if (nv.TryGetValue(out var strVal) && !string.IsNullOrWhiteSpace(strVal)) + { + // Backward compatibility with "yes"/"no" / "true"/"false" + needDeployment = + !string.Equals(strVal, "no", StringComparison.OrdinalIgnoreCase) && + !string.Equals(strVal, "false", StringComparison.OrdinalIgnoreCase); + } + } + + return needDeployment; + } /// /// Get the Azure Web App runtime string based on the detected platform diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs index 70771f64..27cd914d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs @@ -380,7 +380,7 @@ public async Task ValidateAsync(Agent365Config config) ValidateRequired(config.TenantId, nameof(config.TenantId), errors); ValidateGuid(config.TenantId, nameof(config.TenantId), errors); - if (config.NeedWebAppDeployment) + if (config.NeedDeployment) { // Validate required static properties ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); @@ -483,8 +483,7 @@ public async Task CreateDefaultConfigAsync( AgentIdentityDisplayName = string.Empty, // AgentIdentityScopes and AgentApplicationScopes are now hardcoded defaults DeploymentProjectPath = string.Empty, - AgentDescription = string.Empty, - NeedDeployment = "yes" + AgentDescription = string.Empty }; // Only serialize static (init) properties for the config file