diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index a7329d67..57a2b11e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -1,714 +1,59 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Agents.A365.DevTools.Cli.Constants; -using Microsoft.Agents.A365.DevTools.Cli.Exceptions; -using Microsoft.Agents.A365.DevTools.Cli.Helpers; -using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; using Microsoft.Agents.A365.DevTools.Cli.Services; -using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; using System.CommandLine; -using System.Text.Json; -namespace Microsoft.Agents.A365.DevTools.Cli.Commands; - -/// -/// Setup command - Complete initial agent deployment (blueprint, messaging endpoint registration) in one step -/// -public class SetupCommand +namespace Microsoft.Agents.A365.DevTools.Cli.Commands { - // Test hook: if set, this delegate will be invoked instead of creating/running the real A365SetupRunner. - // Signature: (setupConfigPath, generatedConfigPath, executor, webAppCreator) => Task - public static Func>? SetupRunnerInvoker { get; set; } - - public static Command CreateCommand( - ILogger logger, - IConfigService configService, - CommandExecutor executor, - DeploymentService deploymentService, // still injected for future use, not used here - IBotConfigurator botConfigurator, - IAzureValidator azureValidator, - AzureWebAppCreator webAppCreator, - PlatformDetector platformDetector, - GraphApiService graphApiService) - { - var command = new Command("setup", "Set up your Agent 365 environment by creating Azure resources, configuring\npermissions, and registering your agent blueprint for deployment"); - - // Options for the main setup command - var configOption = new Option( - ["--config", "-c"], - getDefaultValue: () => new FileInfo("a365.config.json"), - description: "Setup configuration file path"); - - var verboseOption = new Option( - ["--verbose", "-v"], - description: "Show detailed output"); - - var dryRunOption = new Option( - "--dry-run", - description: "Show what would be done without executing"); - - var blueprintOnlyOption = new Option( - "--blueprint", - description: "Skip Azure infrastructure setup and create blueprint only. "); - - command.AddOption(configOption); - command.AddOption(verboseOption); - command.AddOption(dryRunOption); - command.AddOption(blueprintOnlyOption); - - // No subcommands - all logic is in the main handler - command.SetHandler(async (config, verbose, dryRun, blueprintOnly) => - { - if (dryRun) - { - // Validate configuration even in dry-run mode - var dryRunConfig = await configService.LoadAsync(config.FullName); - - logger.LogInformation("DRY RUN: Agent 365 Setup - Blueprint + Messaging Endpoint Registration"); - logger.LogInformation("This would execute the following operations:"); - logger.LogInformation(" 1. Create agent blueprint and Azure resources"); - logger.LogInformation(" 2. Register blueprint messaging endpoint"); - logger.LogInformation("No actual changes will be made."); - logger.LogInformation("Configuration file validated successfully: {ConfigFile}", config.FullName); - return; - } - - logger.LogInformation("Agent 365 Setup - Blueprint + Messaging Endpoint Registration"); - logger.LogInformation("Creating blueprint and registering messaging endpoint..."); - logger.LogInformation(""); - - // Track setup results for summary - var setupResults = new SetupResults(); - - try - { - // Load configuration - ConfigService automatically finds generated config in same directory - var setupConfig = await configService.LoadAsync(config.FullName); - if (setupConfig.NeedDeployment) - { - // Validate Azure CLI authentication, subscription, and environment - if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) - { - Environment.Exit(1); - } - } - else - { - logger.LogInformation("NeedDeployment=false – skipping Azure subscription validation."); - } - - logger.LogInformation(""); - - // Step 1: Create blueprint - logger.LogInformation("Step 1: Creating agent blueprint..."); - logger.LogInformation(""); - - var generatedConfigPath = Path.Combine( - config.DirectoryName ?? Environment.CurrentDirectory, - "a365.generated.config.json"); - - bool success; - - var delegatedConsentService = new DelegatedConsentService( - LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(), - graphApiService); - - var setupRunner = new A365SetupRunner( - LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(), - executor, - graphApiService, - webAppCreator, - delegatedConsentService, - platformDetector); - - // Use test invoker if set (for testing), otherwise use real runner - if (SetupRunnerInvoker != null) - { - success = await SetupRunnerInvoker(config.FullName, generatedConfigPath, executor, webAppCreator); - - // If using test invoker, stop here - tests don't mock all the downstream services - if (success) - { - logger.LogDebug("Generated config saved at: {Path}", generatedConfigPath); - logger.LogInformation("Setup completed successfully (test mode)"); - return; - } - } - else - { - // Pass blueprintOnly option to setup runner - success = await setupRunner.RunAsync(config.FullName, generatedConfigPath, blueprintOnly); - } - - if (!success) - { - logger.LogError("Agent blueprint creation failed"); - setupResults.BlueprintCreated = false; - setupResults.Errors.Add("Agent blueprint creation failed"); - throw new SetupValidationException("Setup runner execution failed"); - } - - setupResults.BlueprintCreated = true; - - // Reload config to get blueprint ID and check for inheritable permissions status - var tempConfig = await configService.LoadAsync(config.FullName); - setupResults.BlueprintId = tempConfig.AgentBlueprintId; - - logger.LogInformation("Agent blueprint created successfully"); - logger.LogDebug("Generated config saved at: {Path}", generatedConfigPath); - - logger.LogInformation(""); - logger.LogInformation("Step 2a: Applying MCP server permissions (OAuth2 permission grants + inheritable permissions)"); - logger.LogInformation(""); - - // Reload configuration to pick up blueprint ID from generated config - // ConfigService automatically resolves generated config in same directory - setupConfig = await configService.LoadAsync(config.FullName); - - // Read scopes from toolingManifest.json (at deploymentProjectPath) - var manifestPath = Path.Combine(setupConfig.DeploymentProjectPath ?? string.Empty, "toolingManifest.json"); - var toolingScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath); - - // Apply OAuth2 permission grant (admin consent) and inheritable permissions - // Wrap in try-catch to prevent unhandled exceptions - try - { - await EnsureMcpOauth2PermissionGrantsAsync( - graphApiService, - setupConfig, - toolingScopes, - logger - ); - - // Apply inheritable permissions on the agent identity blueprint - await EnsureMcpInheritablePermissionsAsync( - graphApiService, - setupConfig, - toolingScopes, - logger - ); - - setupResults.McpPermissionsConfigured = true; - logger.LogInformation("MCP server permissions configured successfully"); - // Check if inheritable permissions were configured successfully - // The A365SetupRunner sets this flag in generated config - setupResults.InheritablePermissionsConfigured = tempConfig.InheritanceConfigured; - - if (!tempConfig.InheritanceConfigured) - { - setupResults.Warnings.Add("Inheritable permissions configuration incomplete"); - - if (!string.IsNullOrEmpty(tempConfig.InheritanceConfigError)) - { - setupResults.Warnings.Add($"Inheritable permissions error: {tempConfig.InheritanceConfigError}"); - } - } - } - catch (Exception mcpEx) - { - setupResults.McpPermissionsConfigured = false; - setupResults.InheritablePermissionsConfigured = false; // ADD THIS LINE - setupResults.Errors.Add($"MCP permissions: {mcpEx.Message}"); - logger.LogError("Failed to configure MCP server permissions: {Message}", mcpEx.Message); - logger.LogWarning("Setup will continue, but MCP server permissions must be configured manually"); - logger.LogInformation("To configure MCP permissions manually:"); - logger.LogInformation(" 1. Ensure the agent blueprint has the required permissions in Azure Portal"); - logger.LogInformation(" 2. Grant admin consent for the MCP scopes"); - logger.LogInformation(" 3. Run 'a365 deploy mcp' to retry MCP permission configuration"); - } - - logger.LogInformation(""); - logger.LogInformation("Step 2b: add Messaging Bot API permission + inheritable permissions"); - logger.LogInformation(""); - - if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) - throw new SetupValidationException("AgentBlueprintId is required."); - - var blueprintSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync(setupConfig.TenantId, 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( - setupConfig.TenantId, - ConfigConstants.MessagingBotApiAppId); - - try - { - // Grant oauth2PermissionGrants: blueprint SP -> Messaging Bot API SP - var botApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( - setupConfig.TenantId, - blueprintSpObjectId, - botApiResourceSpObjectId, - new[] { "Authorization.ReadWrite", "user_impersonation" }); - - if (!botApiGrantOk) - { - setupResults.Warnings.Add("Failed to create/update oauth2PermissionGrant for Messaging Bot API"); - logger.LogWarning("Failed to create/update oauth2PermissionGrant for Messaging Bot API."); - } - - // Add inheritable permissions on blueprint for Messaging Bot API - var (ok, already, err) = await graphApiService.SetInheritablePermissionsAsync( - setupConfig.TenantId, - setupConfig.AgentBlueprintId, - ConfigConstants.MessagingBotApiAppId, - new[] { "Authorization.ReadWrite", "user_impersonation" }); - - if (!ok && !already) - { - setupResults.Warnings.Add($"Failed to set inheritable permissions for Messaging Bot API: {err}"); - logger.LogWarning("Failed to set inheritable permissions for Messaging Bot API: " + err); - } - - setupResults.BotApiPermissionsConfigured = true; - logger.LogInformation("Messaging Bot API permissions configured (grant + inheritable) successfully."); - } - catch (Exception botEx) - { - setupResults.BotApiPermissionsConfigured = false; - setupResults.Errors.Add($"Bot API permissions: {botEx.Message}"); - logger.LogError("Failed to configure Messaging Bot API permissions: {Message}", botEx.Message); - } - - logger.LogInformation(""); - logger.LogInformation("Step 3: Registering blueprint messaging endpoint..."); - - try - { - // Reload config to get any updated values from blueprint creation - setupConfig = await configService.LoadAsync(config.FullName); - - await RegisterBlueprintMessagingEndpointAsync(setupConfig, logger, botConfigurator); - await configService.SaveStateAsync(setupConfig); - setupResults.MessagingEndpointRegistered = true; - logger.LogInformation("Blueprint messaging endpoint registered successfully"); - } - catch (Exception endpointEx) - { - setupResults.MessagingEndpointRegistered = false; - setupResults.Errors.Add($"Messaging endpoint: {endpointEx.Message}"); - logger.LogError("Failed to register messaging endpoint: {Message}", endpointEx.Message); - } - - // Sync generated config in project settings from deployment project - try - { - generatedConfigPath = Path.Combine( - config.DirectoryName ?? Environment.CurrentDirectory, - "a365.generated.config.json"); - await ProjectSettingsSyncHelper.ExecuteAsync( - a365ConfigPath: config.FullName, - a365GeneratedPath: generatedConfigPath, - configService: configService, - platformDetector: platformDetector, - logger: logger - ); - - logger.LogDebug("Generated config synced to project settings"); - } - catch (Exception syncEx) - { - logger.LogWarning(syncEx, "Project settings sync failed (non-blocking). Please sync settings manually."); - } - - // Display verification URLs and next steps - await DisplayVerificationInfoAsync(config, logger); - - // Display comprehensive setup summary - DisplaySetupSummary(setupResults, logger); - } - catch (Agent365Exception ex) - { - ExceptionHandler.HandleAgent365Exception(ex); - } - catch (Exception ex) - { - logger.LogError(ex, "Setup failed: {Message}", ex.Message); - throw; - } - }, configOption, verboseOption, dryRunOption, blueprintOnlyOption); - - return command; - } - - - /// - /// Display verification URLs and next steps after successful setup - /// - private static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, ILogger logger) - { - try - { - logger.LogInformation("Generating verification information..."); - var baseDir = setupConfigFile.DirectoryName ?? Environment.CurrentDirectory; - var generatedConfigPath = Path.Combine(baseDir, "a365.generated.config.json"); - - if (!File.Exists(generatedConfigPath)) - { - logger.LogWarning("Generated config not found - skipping verification info"); - return; - } - - using var stream = File.OpenRead(generatedConfigPath); - using var doc = await JsonDocument.ParseAsync(stream); - var root = doc.RootElement; - - logger.LogInformation(""); - logger.LogInformation("Verification URLs and Next Steps:"); - logger.LogInformation("=========================================="); - - // Azure Web App URL - construct from AppServiceName - if (root.TryGetProperty("AppServiceName", out var appServiceProp) && !string.IsNullOrWhiteSpace(appServiceProp.GetString())) - { - var webAppUrl = $"https://{appServiceProp.GetString()}.azurewebsites.net"; - logger.LogInformation("Agent Web App: {Url}", webAppUrl); - } - - // Azure Resource Group - if (root.TryGetProperty("ResourceGroup", out var rgProp) && !string.IsNullOrWhiteSpace(rgProp.GetString())) - { - var resourceGroup = rgProp.GetString(); - logger.LogInformation("Azure Resource Group: https://portal.azure.com/#@/resource/subscriptions/{SubscriptionId}/resourceGroups/{ResourceGroup}", - root.TryGetProperty("SubscriptionId", out var subProp) ? subProp.GetString() : "{subscription}", - resourceGroup); - } - - // Entra ID Application - if (root.TryGetProperty("AgentBlueprintId", out var blueprintProp) && !string.IsNullOrWhiteSpace(blueprintProp.GetString())) - { - logger.LogInformation("Entra ID Application: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/{AppId}", - blueprintProp.GetString()); - } - - logger.LogInformation(""); - logger.LogInformation("Next Steps:"); - logger.LogInformation(" 1. Review Azure resources in the portal"); - logger.LogInformation(" 2. View configuration: a365 config display"); - logger.LogInformation(" 3. Create agent instance: a365 create-instance identity"); - logger.LogInformation(" 4. Deploy application: a365 deploy app"); - logger.LogInformation(""); - } - catch (Exception ex) - { - logger.LogError(ex, "Could not display verification info: {Message}", ex.Message); - } - } - - /// - /// Register blueprint messaging endpoint using deployed web app URL - /// - private static async Task RegisterBlueprintMessagingEndpointAsync( - Agent365Config setupConfig, - ILogger logger, - IBotConfigurator botConfigurator) - { - // Validate required configuration - if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId)) - { - logger.LogError("Agent Blueprint ID not found. Blueprint creation may have failed."); - 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; - string endpointName; - if (setupConfig.NeedDeployment) - { - if (string.IsNullOrEmpty(setupConfig.WebAppName)) - { - logger.LogError("Web App Name not configured in a365.config.json"); - throw new SetupValidationException( - issueDescription: "Web App name is required to register a messaging endpoint when needDeployment is 'yes'.", - errorDetails: new List - { - "NeedDeployment 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.NeedDeployment.ToString(), - ["webAppName"] = setupConfig.WebAppName ?? "" - }); - } - - // 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 // Non-Azure hosting - { - // No deployment – use the provided MessagingEndpoint - if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint)) - { - 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) || - uri.Scheme != Uri.UriSchemeHttps) - { - logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}", - setupConfig.MessagingEndpoint); - throw new SetupValidationException("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); - - } - - if (endpointName.Length < 4) - { - logger.LogError("Bot endpoint name '{EndpointName}' is too short (must be at least 4 characters)", endpointName); - throw new SetupValidationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)"); - } - - logger.LogInformation(" - Registering blueprint messaging endpoint"); - logger.LogInformation(" * Endpoint Name: {EndpointName}", endpointName); - logger.LogInformation(" * Messaging Endpoint: {Endpoint}", messagingEndpoint); - logger.LogInformation(" * Using Agent Blueprint ID: {AgentBlueprintId}", setupConfig.AgentBlueprintId); - - var endpointRegistered = await botConfigurator.CreateEndpointWithAgentBlueprintAsync( - endpointName: endpointName, - location: setupConfig.Location, - messagingEndpoint: messagingEndpoint, - agentDescription: "Agent 365 messaging endpoint for automated interactions", - agentBlueprintId: setupConfig.AgentBlueprintId); - - if (!endpointRegistered) - { - logger.LogError("Failed to register blueprint messaging endpoint"); - throw new SetupValidationException("Blueprint messaging endpoint registration failed"); - } - // Update Agent365Config state properties - setupConfig.BotId = setupConfig.AgentBlueprintId; - setupConfig.BotMsaAppId = setupConfig.AgentBlueprintId; - setupConfig.BotMessagingEndpoint = messagingEndpoint; - - } - /// - /// Ensure OAuth2 permission grants are set from blueprint to MCP server + /// Setup command - Agent 365 environment setup with granular subcommands + /// Supports permission-based workflow: infrastructure -> blueprint -> permissions -> endpoint /// - private static async Task EnsureMcpOauth2PermissionGrantsAsync( - GraphApiService graph, - Agent365Config config, - string[] scopes, - ILogger logger, - CancellationToken ct = default) + public class SetupCommand { - if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) - throw new SetupValidationException("AgentBlueprintId (appId) is required."); - - var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct); - if (string.IsNullOrWhiteSpace(blueprintSpObjectId)) - { - 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."); - } - - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); - var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct); - if (string.IsNullOrWhiteSpace(Agent365ToolsSpObjectId)) - { - 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}"); - } - - logger.LogInformation(" - OAuth2 grant: client {ClientId} to resource {ResourceId} scopes [{Scopes}]", - blueprintSpObjectId, Agent365ToolsSpObjectId, string.Join(' ', scopes)); - - var response = await graph.CreateOrUpdateOauth2PermissionGrantAsync( - config.TenantId, blueprintSpObjectId, Agent365ToolsSpObjectId, scopes, ct); - - if (!response) - { - 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."); + public static Command CreateCommand( + ILogger logger, + IConfigService configService, + CommandExecutor executor, + DeploymentService deploymentService, + IBotConfigurator botConfigurator, + IAzureValidator azureValidator, + AzureWebAppCreator webAppCreator, + PlatformDetector platformDetector, + GraphApiService graphApiService) + { + var command = new Command("setup", + "Set up your Agent 365 environment with granular control over each step\n\n" + + "Recommended execution order:\n" + + " 1. a365 setup infrastructure (or skip if infrastructure exists)\n" + + " 2. a365 setup blueprint\n" + + " 3. a365 setup permissions mcp\n" + + " 4. a365 setup permissions bot\n" + + " 5. a365 setup endpoint\n\n" + + "Or run all steps at once:\n" + + " a365 setup all # Full setup (includes infrastructure)\n" + + " a365 setup all --skip-infrastructure # Skip infrastructure if it already exists"); + + // Add subcommands + command.AddCommand(InfrastructureSubcommand.CreateCommand( + logger, configService, azureValidator, webAppCreator, platformDetector, executor)); + + command.AddCommand(BlueprintSubcommand.CreateCommand( + logger, configService, executor, azureValidator, webAppCreator, platformDetector)); + + command.AddCommand(PermissionsSubcommand.CreateCommand( + logger, configService, executor, graphApiService)); + + command.AddCommand(EndpointSubcommand.CreateCommand( + logger, configService, botConfigurator, platformDetector)); + + command.AddCommand(AllSubcommand.CreateCommand( + logger, configService, executor, botConfigurator, azureValidator, webAppCreator, platformDetector, graphApiService)); + + return command; } } - - /// - /// Ensure inheritable permissions are set from blueprint to MCP server - /// - private static async Task EnsureMcpInheritablePermissionsAsync( - GraphApiService graph, - Agent365Config config, - string[] scopes, - ILogger logger, - CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) - throw new SetupValidationException("AgentBlueprintId (appId) is required."); - - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); - - logger.LogInformation(" - Inheritable permissions: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", - config.AgentBlueprintId, resourceAppId, string.Join(' ', scopes)); - - var (ok, alreadyExists, err) = await graph.SetInheritablePermissionsAsync( - config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct); - - if (!ok && !alreadyExists) - { - config.InheritanceConfigured = false; - config.InheritanceConfigError = err; - throw new SetupValidationException($"Failed to set inheritable permissions: {err}. " + - "Ensure you have Application.ReadWrite.All permissions and the blueprint supports inheritable permissions."); - } - - config.InheritanceConfigured = true; - config.InheritablePermissionsAlreadyExist = alreadyExists; - config.InheritanceConfigError = null; - } - - /// - /// Display comprehensive setup summary showing what succeeded and what failed - /// - private static void DisplaySetupSummary(SetupResults results, ILogger logger) - { - logger.LogInformation(""); - logger.LogInformation("=========================================="); - logger.LogInformation("Setup Summary"); - logger.LogInformation("=========================================="); - - // Show what succeeded - logger.LogInformation("Completed Steps:"); - if (results.BlueprintCreated) - { - logger.LogInformation(" [OK] Agent blueprint created (Blueprint ID: {BlueprintId})", results.BlueprintId ?? "unknown"); - } - if (results.McpPermissionsConfigured) - logger.LogInformation(" [OK] MCP server permissions configured"); - if (results.InheritablePermissionsConfigured) - logger.LogInformation(" [OK] Inheritable permissions configured"); - if (results.BotApiPermissionsConfigured) - logger.LogInformation(" [OK] Messaging Bot API permissions configured"); - if (results.MessagingEndpointRegistered) - logger.LogInformation(" [OK] Messaging endpoint registered"); - - // Show what failed - if (results.Errors.Count > 0) - { - logger.LogInformation(""); - logger.LogInformation("Failed Steps:"); - foreach (var error in results.Errors) - { - logger.LogInformation(" [FAILED] {Error}", error); - } - } - - // Show warnings - if (results.Warnings.Count > 0) - { - logger.LogInformation(""); - logger.LogInformation("Warnings:"); - foreach (var warning in results.Warnings) - { - logger.LogInformation(" [WARN] {Warning}", warning); - } - } - - logger.LogInformation(""); - - // Overall status - if (results.HasErrors) - { - logger.LogWarning("Setup completed with errors"); - logger.LogInformation(""); - logger.LogInformation("Recovery Actions:"); - - if (!results.InheritablePermissionsConfigured) - { - logger.LogInformation(" - Inheritable Permissions: Refer to Agent 365 CLI documentation for manual configuration"); - } - - if (!results.McpPermissionsConfigured) - { - logger.LogInformation(" - MCP Permissions: Refer to Agent 365 CLI documentation for manual configuration"); - } - - if (!results.BotApiPermissionsConfigured) - { - logger.LogInformation(" - Bot API Permissions: Refer to Agent 365 CLI documentation for manual configuration"); - } - - if (!results.MessagingEndpointRegistered) - { - logger.LogInformation(" - Messaging Endpoint: Refer to Agent 365 CLI documentation for manual configuration"); - } - } - else if (results.HasWarnings) - { - logger.LogInformation("Setup completed successfully with warnings"); - logger.LogInformation("Review warnings above and take action if needed"); - } - else - { - logger.LogInformation("Setup completed successfully"); - logger.LogInformation("All components configured correctly"); - } - - logger.LogInformation("=========================================="); - } -} - -/// -/// Tracks the results of each setup step for summary reporting -/// -internal class SetupResults -{ - public bool BlueprintCreated { get; set; } - public string? BlueprintId { get; set; } - public bool McpPermissionsConfigured { get; set; } - public bool BotApiPermissionsConfigured { get; set; } - public bool MessagingEndpointRegistered { get; set; } - public bool InheritablePermissionsConfigured { get; set; } - - public List Errors { get; } = new(); - public List Warnings { get; } = new(); - - public bool HasErrors => Errors.Count > 0; - public bool HasWarnings => Warnings.Count > 0; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs new file mode 100644 index 00000000..debdcd7a --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using System.CommandLine; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// All subcommand - Runs complete setup (all steps in sequence) +/// Orchestrates individual subcommand implementations +/// Required permissions: +/// - Azure Subscription Contributor/Owner (for infrastructure and endpoint) +/// - Agent ID Developer role (for blueprint creation) +/// - Global Administrator (for permission grants and admin consent) +/// +internal static class AllSubcommand +{ + public static Command CreateCommand( + ILogger logger, + IConfigService configService, + CommandExecutor executor, + IBotConfigurator botConfigurator, + IAzureValidator azureValidator, + AzureWebAppCreator webAppCreator, + PlatformDetector platformDetector, + GraphApiService graphApiService) + { + var command = new Command("all", + "Run complete Agent 365 setup (all steps in sequence)\n" + + "Includes: Infrastructure + Blueprint + Permissions + Endpoint\n\n" + + "Minimum required permissions (Global Administrator has all of these):\n" + + " - Azure Subscription Contributor (for infrastructure and endpoint)\n" + + " - Agent ID Developer role (for blueprint creation)\n" + + " - Global Administrator (for permission grants and admin consent)\n\n"); + + var configOption = new Option( + ["--config", "-c"], + getDefaultValue: () => new FileInfo("a365.config.json"), + description: "Configuration file path"); + + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Show detailed output"); + + var dryRunOption = new Option( + "--dry-run", + description: "Show what would be done without executing"); + + var skipInfrastructureOption = new Option( + "--skip-infrastructure", + description: "Skip Azure infrastructure creation (use if infrastructure already exists)\n" + + "This will still create: Blueprint + Permissions + Endpoint"); + + command.AddOption(configOption); + command.AddOption(verboseOption); + command.AddOption(dryRunOption); + command.AddOption(skipInfrastructureOption); + + command.SetHandler(async (config, verbose, dryRun, skipInfrastructure) => + { + if (dryRun) + { + logger.LogInformation("DRY RUN: Complete Agent 365 Setup"); + logger.LogInformation("This would execute the following operations:"); + + if (!skipInfrastructure) + { + logger.LogInformation(" 1. Create Azure infrastructure"); + } + else + { + logger.LogInformation(" 1. [SKIPPED] Azure infrastructure (--skip-infrastructure flag used)"); + } + + logger.LogInformation(" 2. Create agent blueprint (Entra ID application)"); + logger.LogInformation(" 3. Configure MCP server permissions"); + logger.LogInformation(" 4. Configure Bot API permissions"); + logger.LogInformation(" 5. Register blueprint messaging endpoint and sync project settings"); + logger.LogInformation("No actual changes will be made."); + return; + } + + logger.LogInformation("Agent 365 Setup"); + logger.LogInformation("Running all setup steps..."); + + if (skipInfrastructure) + { + logger.LogInformation("NOTE: Skipping infrastructure creation (--skip-infrastructure flag used)"); + } + + logger.LogInformation(""); + + var setupResults = new SetupResults(); + + try + { + // Load configuration + var setupConfig = await configService.LoadAsync(config.FullName); + + // Validate Azure authentication + if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) + { + Environment.Exit(1); + } + + logger.LogInformation(""); + + var generatedConfigPath = Path.Combine( + config.DirectoryName ?? Environment.CurrentDirectory, + "a365.generated.config.json"); + + // Step 1: Infrastructure (optional) + try + { + logger.LogInformation("Step 1:"); + logger.LogInformation(""); + + bool setupInfra = await InfrastructureSubcommand.CreateInfrastructureImplementationAsync( + logger, + config.FullName, + generatedConfigPath, + executor, + platformDetector, + setupConfig.NeedDeployment, + skipInfrastructure, + CancellationToken.None); + + setupResults.InfrastructureCreated = skipInfrastructure ? false : setupInfra; + } + catch (Exception infraEx) + { + setupResults.InfrastructureCreated = false; + setupResults.Errors.Add($"Infrastructure: {infraEx.Message}"); + logger.LogError(infraEx, "Failed to create infrastructure: {Message}", infraEx.Message); + throw; + } + + // Step 2: Blueprint + logger.LogInformation(""); + logger.LogInformation("Step 2:"); + logger.LogInformation(""); + + try + { + var blueprintCreated = await BlueprintSubcommand.CreateBlueprintImplementationAsync( + setupConfig, + config, + executor, + azureValidator, + logger, + skipInfrastructure, + true); + + setupResults.BlueprintCreated = blueprintCreated; + + if (blueprintCreated) + { + // CRITICAL: Wait for file system to ensure config file is fully written + // Blueprint creation writes directly to disk and may not be immediately readable + logger.LogInformation("Ensuring configuration file is synchronized..."); + await Task.Delay(2000); // 2 second delay to ensure file write is complete + + // Reload config to get blueprint ID + // Use full path to ensure we're reading from the correct location + var fullConfigPath = Path.GetFullPath(config.FullName); + setupConfig = await configService.LoadAsync(fullConfigPath); + setupResults.BlueprintId = setupConfig.AgentBlueprintId; + + // Validate blueprint ID was properly saved + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) + { + throw new SetupValidationException( + "Blueprint creation completed but AgentBlueprintId was not saved to configuration. " + + "This is required for the next steps (MCP permissions, Bot permissions, and endpoint registration)."); + } + } + } + catch (Exception blueprintEx) + { + setupResults.BlueprintCreated = false; + setupResults.Errors.Add($"Blueprint: {blueprintEx.Message}"); + logger.LogError(blueprintEx, "Failed to create blueprint: {Message}", blueprintEx.Message); + throw; + } + + // Step 3: MCP Permissions + logger.LogInformation(""); + logger.LogInformation("Step 3:"); + logger.LogInformation(""); + + try + { + bool mcpPermissionSetup = await PermissionsSubcommand.ConfigureMcpPermissionsAsync( + config.FullName, + logger, + configService, + executor, + graphApiService, + setupConfig, + true); + + setupResults.McpPermissionsConfigured = mcpPermissionSetup; + if (mcpPermissionSetup) + { + setupResults.InheritablePermissionsConfigured = setupConfig.InheritanceConfigured; + } + } + catch (Exception mcpPermEx) + { + setupResults.McpPermissionsConfigured = false; + setupResults.Errors.Add($"MCP Permissions: {mcpPermEx.Message}"); + logger.LogWarning("Setup will continue, but MCP server permissions must be configured manually"); + } + + // Step 4: Bot API Permissions + + logger.LogInformation(""); + logger.LogInformation("Step 4:"); + logger.LogInformation(""); + + try + { + bool botPermissionSetup = await PermissionsSubcommand.ConfigureBotPermissionsAsync( + config.FullName, + logger, + configService, + executor, + setupConfig, + graphApiService, + true); + + setupResults.BotApiPermissionsConfigured = botPermissionSetup; + } + catch (Exception botPermEx) + { + setupResults.BotApiPermissionsConfigured = false; + setupResults.Errors.Add($"Bot API Permissions: {botPermEx.Message}"); + logger.LogWarning("Setup will continue, but Bot API permissions must be configured manually"); + } + + // Step 5: Register endpoint and sync + logger.LogInformation(""); + logger.LogInformation("Step 5:"); + logger.LogInformation(""); + + try + { + await EndpointSubcommand.RegisterEndpointAndSyncAsync( + config.FullName, + logger, + configService, + botConfigurator, + platformDetector); + + setupResults.MessagingEndpointRegistered = true; + logger.LogInformation("Blueprint messaging endpoint registered successfully"); + } + catch (Exception endpointEx) + { + setupResults.MessagingEndpointRegistered = false; + setupResults.Errors.Add($"Messaging endpoint: {endpointEx.Message}"); + logger.LogError("Failed to register messaging endpoint: {Message}", endpointEx.Message); + } + + // Display verification info and summary + logger.LogInformation(""); + await SetupHelpers.DisplayVerificationInfoAsync(config, logger); + SetupHelpers.DisplaySetupSummary(setupResults, logger); + } + catch (Agent365Exception ex) + { + ExceptionHandler.HandleAgent365Exception(ex); + Environment.Exit(1); + } + catch (Exception ex) + { + logger.LogError(ex, "Setup failed: {Message}", ex.Message); + throw; + } + }, configOption, verboseOption, dryRunOption, skipInfrastructureOption); + + return command; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs new file mode 100644 index 00000000..aa63c959 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -0,0 +1,950 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Extensions.Logging; +using Microsoft.Graph; +using System.CommandLine; +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Blueprint subcommand - Creates agent blueprint (Entra ID application) +/// Required Permissions: Agent ID Developer role +/// COMPLETE IMPLEMENTATION of A365SetupRunner Phase 2 blueprint creation +/// +internal static class BlueprintSubcommand +{ + private const string MicrosoftGraphCommandLineToolsAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; + + public static Command CreateCommand( + ILogger logger, + IConfigService configService, + CommandExecutor executor, + IAzureValidator azureValidator, + AzureWebAppCreator webAppCreator, + PlatformDetector platformDetector) + { + var command = new Command("blueprint", + "Create agent blueprint (Entra ID application registration)\n" + + "Minimum required permissions: Agent ID Developer role\n"); + + var configOption = new Option( + ["--config", "-c"], + getDefaultValue: () => new FileInfo("a365.config.json"), + description: "Configuration file path"); + + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Show detailed output"); + + var dryRunOption = new Option( + "--dry-run", + description: "Show what would be done without executing"); + + command.AddOption(configOption); + command.AddOption(verboseOption); + command.AddOption(dryRunOption); + + command.SetHandler(async (config, verbose, dryRun) => + { + var setupConfig = await configService.LoadAsync(config.FullName); + + if (dryRun) + { + logger.LogInformation("DRY RUN: Create Agent Blueprint"); + logger.LogInformation("Would create Entra ID application:"); + logger.LogInformation(" - Display Name: {DisplayName}", setupConfig.AgentBlueprintDisplayName); + logger.LogInformation(" - Tenant: {TenantId}", setupConfig.TenantId); + logger.LogInformation(" - Would request admin consent for Graph and Connectivity APIs"); + return; + } + + await CreateBlueprintImplementationAsync( + setupConfig, + config, + executor, + azureValidator, + logger, + false, + false); + + }, configOption, verboseOption, dryRunOption); + + return command; + } + + public static async Task CreateBlueprintImplementationAsync( + Models.Agent365Config setupConfig, + FileInfo config, + CommandExecutor executor, + IAzureValidator azureValidator, + ILogger logger, + bool skipInfrastructure, + bool isSetupAll, + CancellationToken cancellationToken = default) + { + logger.LogInformation(""); + logger.LogInformation("==> Creating Agent Blueprint"); + + // Validate Azure authentication + if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) + { + return false; + } + + var generatedConfigPath = Path.Combine( + config.DirectoryName ?? Environment.CurrentDirectory, + "a365.generated.config.json"); + + // Load existing generated config (for MSI Principal ID) + JsonObject generatedConfig = new JsonObject(); + string? principalId = null; + + if (File.Exists(generatedConfigPath)) + { + try + { + generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath))?.AsObject() ?? new JsonObject(); + + if (generatedConfig.TryGetPropertyValue("managedIdentityPrincipalId", out var existingPrincipalId)) + { + principalId = existingPrincipalId?.GetValue(); + logger.LogInformation("Found existing Managed Identity Principal ID: {Id}", principalId ?? "(none)"); + } + } + catch (Exception ex) + { + logger.LogWarning("Could not load existing config: {Message}. Starting fresh.", ex.Message); + } + } + else + { + logger.LogInformation("No existing configuration found - blueprint will be created without managed identity"); + } + + // Create required services + var delegatedConsentService = new DelegatedConsentService( + LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(), + new GraphApiService( + LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(), + executor)); + + var graphService = new GraphApiService( + LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(), + executor); + + // ======================================================================== + // Phase 2.1: Delegated Consent + // ======================================================================== + + logger.LogInformation(""); + logger.LogInformation("==> Creating Agent Blueprint"); + + // CRITICAL: Grant AgentApplication.Create permission BEFORE creating blueprint + // This replaces the PowerShell call to DelegatedAgentApplicationCreateConsent.ps1 + logger.LogInformation(""); + logger.LogInformation("==> Ensuring AgentApplication.Create Permission"); + logger.LogInformation("This permission is required to create Agent Blueprints"); + + var consentResult = await EnsureDelegatedConsentWithRetriesAsync( + delegatedConsentService, + setupConfig.TenantId, + logger); + + if (!consentResult) + { + logger.LogError("Failed to ensure AgentApplication.Create permission after multiple attempts"); + return false; + } + + // ======================================================================== + // Phase 2.2: Create Blueprint + // ======================================================================== + + logger.LogInformation(""); + logger.LogInformation("==> Creating Agent Blueprint Application"); + + // Validate required config + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintDisplayName)) + { + throw new InvalidOperationException("agentBlueprintDisplayName missing in configuration"); + } + + var useManagedIdentity = (setupConfig.NeedDeployment && !skipInfrastructure) || skipInfrastructure; + + var blueprintResult = await CreateAgentBlueprintAsync( + logger, + executor, + setupConfig.TenantId, + setupConfig.AgentBlueprintDisplayName, + setupConfig.AgentIdentityDisplayName, + principalId, + useManagedIdentity, + generatedConfig, + setupConfig, + cancellationToken); + + if (!blueprintResult.success) + { + logger.LogError("Failed to create agent blueprint"); + return false; + } + + var blueprintAppId = blueprintResult.appId; + var blueprintObjectId = blueprintResult.objectId; + + logger.LogInformation("Agent Blueprint Details:"); + logger.LogInformation(" - Display Name: {Name}", setupConfig.AgentBlueprintDisplayName); + logger.LogInformation(" - App ID: {Id}", blueprintAppId); + logger.LogInformation(" - Object ID: {Id}", blueprintObjectId); + logger.LogInformation(" - Identifier URI: api://{Id}", blueprintAppId); + + // Convert to camelCase and save + var camelCaseConfig = new JsonObject + { + ["managedIdentityPrincipalId"] = generatedConfig["managedIdentityPrincipalId"]?.DeepClone(), + ["agentBlueprintId"] = blueprintAppId, + ["agentBlueprintObjectId"] = blueprintObjectId, + ["displayName"] = setupConfig.AgentBlueprintDisplayName, + ["servicePrincipalId"] = blueprintResult.servicePrincipalId, + ["identifierUri"] = $"api://{blueprintAppId}", + ["tenantId"] = setupConfig.TenantId, + ["consentUrlGraph"] = generatedConfig["consentUrlGraph"]?.DeepClone(), + ["consentUrlConnectivity"] = generatedConfig["consentUrlConnectivity"]?.DeepClone(), + ["consent1Granted"] = generatedConfig["consent1Granted"]?.DeepClone(), + ["consent2Granted"] = generatedConfig["consent2Granted"]?.DeepClone(), + + }; + + await File.WriteAllTextAsync(generatedConfigPath, camelCaseConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken); + generatedConfig = camelCaseConfig; + + // ======================================================================== + // Phase 2.5: Create Client Secret (logging handled by method) + // ======================================================================== + + logger.LogInformation(""); + logger.LogInformation("==> Creating Client Secret for Agent Blueprint"); + + await CreateBlueprintClientSecretAsync( + blueprintObjectId!, + blueprintAppId!, + generatedConfig, + generatedConfigPath, + graphService, + setupConfig, + logger); + + // Final summary + logger.LogInformation(""); + logger.LogInformation("Agent blueprint created successfully"); + logger.LogInformation("Generated config saved: {Path}", generatedConfigPath); + logger.LogInformation(""); + if (!isSetupAll) + { + logger.LogInformation("Next steps:"); + logger.LogInformation(" 1. Run 'a365 setup permissions mcp' to configure MCP permissions"); + logger.LogInformation(" 2. Run 'a365 setup permissions bot' to configure Bot API permissions"); + logger.LogInformation(" 3. Run 'a365 setup endpoint' to register messaging endpoint"); + } + + return true; + } + + /// + /// Ensures AgentApplication.Create permission with retry logic (3 attempts, 5-second delays) + /// Used by: BlueprintSubcommand and A365SetupRunner Phase 2.1 + /// + public static async Task EnsureDelegatedConsentWithRetriesAsync( + DelegatedConsentService delegatedConsentService, + string tenantId, + ILogger logger, + CancellationToken cancellationToken = default) + { + const int maxRetries = 3; + const int retryDelaySeconds = 5; + + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + if (attempt > 1) + { + logger.LogInformation("Retry attempt {Attempt} of {MaxRetries} for delegated consent", attempt, maxRetries); + await Task.Delay(TimeSpan.FromSeconds(retryDelaySeconds), cancellationToken); + } + + var success = await delegatedConsentService.EnsureAgentApplicationCreateConsentAsync( + MicrosoftGraphCommandLineToolsAppId, + tenantId, + cancellationToken); + + if (success) + { + logger.LogInformation("Successfully ensured delegated application consent on attempt {Attempt}", attempt); + return true; + } + + logger.LogWarning("Consent attempt {Attempt} returned false", attempt); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Consent attempt {Attempt} failed: {Message}", attempt, ex.Message); + + if (attempt == maxRetries) + { + logger.LogError("All retry attempts exhausted for delegated consent"); + logger.LogError("Common causes:"); + logger.LogError(" 1. Insufficient permissions - You need Application.ReadWrite.All and DelegatedPermissionGrant.ReadWrite.All"); + logger.LogError(" 2. Not a Global Administrator or similar privileged role"); + logger.LogError(" 3. Azure CLI authentication expired - Run 'az login' and retry"); + logger.LogError(" 4. Network connectivity issues"); + return false; + } + } + } + + return false; + } + + /// + /// Creates Agent Blueprint application using Graph API + /// Used by: BlueprintSubcommand and A365SetupRunner Phase 2.2 + /// Returns: (success, appId, objectId, servicePrincipalId) + /// + public static async Task<(bool success, string? appId, string? objectId, string? servicePrincipalId)> CreateAgentBlueprintAsync( + ILogger logger, + CommandExecutor executor, + string tenantId, + string displayName, + string? agentIdentityDisplayName, + string? managedIdentityPrincipalId, + bool useManagedIdentity, + JsonObject generatedConfig, + Models.Agent365Config setupConfig, + CancellationToken ct) + { + try + { + logger.LogInformation("Creating Agent Blueprint using Microsoft Graph SDK..."); + + GraphServiceClient graphClient; + try + { + graphClient = await GetAuthenticatedGraphClientAsync(logger, tenantId, ct); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to get authenticated Graph client: {Message}", ex.Message); + return (false, null, null, null); + } + + // Get current user for sponsors field (mimics PowerShell script behavior) + string? sponsorUserId = null; + try + { + var me = await graphClient.Me.GetAsync(cancellationToken: ct); + if (me != null && !string.IsNullOrEmpty(me.Id)) + { + sponsorUserId = me.Id; + logger.LogInformation("Current user: {DisplayName} <{UPN}>", me.DisplayName, me.UserPrincipalName); + logger.LogInformation("Sponsor: https://graph.microsoft.com/v1.0/users/{UserId}", sponsorUserId); + } + } + catch (Exception ex) + { + logger.LogWarning("Could not retrieve current user for sponsors field: {Message}", ex.Message); + } + + // Define the application manifest with @odata.type for Agent Identity Blueprint + var appManifest = new JsonObject + { + ["@odata.type"] = "Microsoft.Graph.AgentIdentityBlueprint", // CRITICAL: Required for Agent Blueprint type + ["displayName"] = displayName, + ["signInAudience"] = "AzureADMultipleOrgs" // Multi-tenant + }; + + // Add sponsors field if we have the current user (PowerShell script includes this) + if (!string.IsNullOrEmpty(sponsorUserId)) + { + appManifest["sponsors@odata.bind"] = new JsonArray + { + $"https://graph.microsoft.com/v1.0/users/{sponsorUserId}" + }; + } + + // Create the application using Microsoft Graph SDK + using var httpClient = new HttpClient(); + var graphToken = await GetTokenFromGraphClient(logger, graphClient, tenantId); + if (string.IsNullOrEmpty(graphToken)) + { + logger.LogError("Failed to extract access token from Graph client"); + return (false, null, null, null); + } + + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); + httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); + httpClient.DefaultRequestHeaders.Add("OData-Version", "4.0"); // Required for @odata.type + + var createAppUrl = "https://graph.microsoft.com/beta/applications"; + + logger.LogInformation("Creating Agent Blueprint application..."); + logger.LogInformation(" - Display Name: {DisplayName}", displayName); + if (!string.IsNullOrEmpty(sponsorUserId)) + { + logger.LogInformation(" - Sponsor: User ID {UserId}", sponsorUserId); + } + + var appResponse = await httpClient.PostAsync( + createAppUrl, + new StringContent(appManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), + ct); + + if (!appResponse.IsSuccessStatusCode) + { + var errorContent = await appResponse.Content.ReadAsStringAsync(ct); + + // If sponsors field causes error (Bad Request 400), retry without it + if (appResponse.StatusCode == System.Net.HttpStatusCode.BadRequest && + !string.IsNullOrEmpty(sponsorUserId)) + { + logger.LogWarning("Agent Blueprint creation with sponsors failed (Bad Request). Retrying without sponsors..."); + + // Remove sponsors field and retry + appManifest.Remove("sponsors@odata.bind"); + + appResponse = await httpClient.PostAsync( + createAppUrl, + new StringContent(appManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), + ct); + + if (!appResponse.IsSuccessStatusCode) + { + errorContent = await appResponse.Content.ReadAsStringAsync(ct); + logger.LogError("Failed to create application (fallback): {Status} - {Error}", appResponse.StatusCode, errorContent); + return (false, null, null, null); + } + } + else + { + logger.LogError("Failed to create application: {Status} - {Error}", appResponse.StatusCode, errorContent); + return (false, null, null, null); + } + } + + var appJson = await appResponse.Content.ReadAsStringAsync(ct); + var app = JsonNode.Parse(appJson)!.AsObject(); + var appId = app["appId"]!.GetValue(); + var objectId = app["id"]!.GetValue(); + + logger.LogInformation("Application created successfully"); + logger.LogInformation(" - App ID: {AppId}", appId); + logger.LogInformation(" - Object ID: {ObjectId}", objectId); + + // Wait for application propagation + const int maxRetries = 30; + const int delayMs = 4000; + bool appAvailable = false; + for (int i = 0; i < maxRetries; i++) + { + var checkResp = await httpClient.GetAsync($"https://graph.microsoft.com/v1.0/applications/{objectId}", ct); + if (checkResp.IsSuccessStatusCode) + { + appAvailable = true; + break; + } + logger.LogInformation("Waiting for application object to be available in directory (attempt {Attempt}/{Max})...", i + 1, maxRetries); + await Task.Delay(delayMs, ct); + } + + if (!appAvailable) + { + logger.LogError("App object not available after creation. Aborting setup."); + return (false, null, null, null); + } + + // Update application with identifier URI + var identifierUri = $"api://{appId}"; + var patchAppUrl = $"https://graph.microsoft.com/v1.0/applications/{objectId}"; + var patchBody = new JsonObject + { + ["identifierUris"] = new JsonArray { identifierUri } + }; + + var patchResponse = await httpClient.PatchAsync( + patchAppUrl, + new StringContent(patchBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), + ct); + + if (!patchResponse.IsSuccessStatusCode) + { + var patchError = await patchResponse.Content.ReadAsStringAsync(ct); + logger.LogInformation("Waiting for application propagation before setting identifier URI..."); + logger.LogDebug("Identifier URI update deferred (propagation delay): {Error}", patchError); + } + else + { + logger.LogInformation("Identifier URI set to: {Uri}", identifierUri); + } + + // Create service principal + logger.LogInformation("Creating service principal..."); + + var spManifest = new JsonObject + { + ["appId"] = appId + }; + + var createSpUrl = "https://graph.microsoft.com/v1.0/servicePrincipals"; + var spResponse = await httpClient.PostAsync( + createSpUrl, + new StringContent(spManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), + ct); + + string? servicePrincipalId = null; + if (spResponse.IsSuccessStatusCode) + { + var spJson = await spResponse.Content.ReadAsStringAsync(ct); + var sp = JsonNode.Parse(spJson)!.AsObject(); + servicePrincipalId = sp["id"]!.GetValue(); + logger.LogInformation("Service principal created: {SpId}", servicePrincipalId); + } + else + { + var spError = await spResponse.Content.ReadAsStringAsync(ct); + logger.LogInformation("Waiting for application propagation before creating service principal..."); + logger.LogDebug("Service principal creation deferred (propagation delay): {Error}", spError); + } + + // Wait for service principal propagation + logger.LogInformation("Waiting 10 seconds to ensure Service Principal is fully propagated..."); + await Task.Delay(10000, ct); + + // Create Federated Identity Credential ONLY when MSI is relevant (if managed identity provided) + if (useManagedIdentity && !string.IsNullOrWhiteSpace(managedIdentityPrincipalId)) + { + logger.LogInformation("Creating Federated Identity Credential for Managed Identity..."); + var credentialName = $"{displayName.Replace(" ", "")}-MSI"; + + var ficSuccess = await CreateFederatedIdentityCredentialAsync( + tenantId, + objectId, + credentialName, + managedIdentityPrincipalId, + graphToken, + logger, + ct); + + if (ficSuccess) + { + logger.LogInformation("Federated Identity Credential created successfully"); + } + else + { + logger.LogWarning("Failed to create Federated Identity Credential"); + } + } + else if (!useManagedIdentity) + { + logger.LogInformation("Skipping Federated Identity Credential creation (external hosting / no MSI configured)"); + } + else + { + logger.LogInformation("Skipping Federated Identity Credential creation (no MSI Principal ID provided)"); + } + + // Request admin consent + logger.LogInformation("Requesting admin consent for application"); + + // Get application scopes from config (fallback to hardcoded defaults) + var applicationScopes = new List(); + + var appScopesFromConfig = setupConfig.AgentApplicationScopes; + if (appScopesFromConfig != null && appScopesFromConfig.Count > 0) + { + logger.LogInformation(" Found 'agentApplicationScopes' in typed config"); + applicationScopes.AddRange(appScopesFromConfig); + } + else + { + logger.LogInformation(" 'agentApplicationScopes' not found in config, using hardcoded defaults"); + applicationScopes.AddRange(ConfigConstants.DefaultAgentApplicationScopes); + } + + // Final fallback (should not happen with proper defaults) + if (applicationScopes.Count == 0) + { + logger.LogWarning("No application scopes available, falling back to User.Read"); + applicationScopes.Add("User.Read"); + } + + logger.LogInformation(" - Application scopes: {Scopes}", string.Join(", ", applicationScopes)); + + // Generate consent URLs for Graph and Connectivity + var applicationScopesJoined = string.Join(' ', applicationScopes); + var consentUrlGraph = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={appId}&scope={Uri.EscapeDataString(applicationScopesJoined)}&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123"; + var consentUrlConnectivity = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={appId}&scope=0ddb742a-e7dc-4899-a31e-80e797ec7144/Connectivity.Connections.Read&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123"; + + logger.LogInformation("Opening browser for Graph API admin consent..."); + TryOpenBrowser(consentUrlGraph); + + var consent1Success = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, appId, "Graph API Scopes", 180, 5, ct); + + if (consent1Success) + { + logger.LogInformation("Graph API admin consent granted successfully!"); + } + else + { + logger.LogWarning("Graph API admin consent may not have completed"); + } + + logger.LogInformation(""); + logger.LogInformation("Opening browser for Connectivity admin consent..."); + TryOpenBrowser(consentUrlConnectivity); + + var consent2Success = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, appId, "Connectivity Scope", 180, 5, ct); + + if (consent2Success) + { + logger.LogInformation("Connectivity admin consent granted successfully!"); + } + else + { + logger.LogWarning("Connectivity admin consent may not have completed"); + } + + // Save consent URLs and status to generated config + generatedConfig["consentUrlGraph"] = consentUrlGraph; + generatedConfig["consentUrlConnectivity"] = consentUrlConnectivity; + generatedConfig["consent1Granted"] = consent1Success; + generatedConfig["consent2Granted"] = consent2Success; + + if (!consent1Success || !consent2Success) + { + logger.LogWarning(""); + logger.LogWarning("One or more consents may not have been detected"); + logger.LogWarning("The setup will continue, but you may need to grant consent manually."); + logger.LogWarning("Consent URL (Graph): {Url}", consentUrlGraph); + logger.LogWarning("Consent URL (Connectivity): {Url}", consentUrlConnectivity); + } + + return (true, appId, objectId, servicePrincipalId); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create agent blueprint: {Message}", ex.Message); + return (false, null, null, null); + } + } + + /// + /// Extracts the access token from a GraphServiceClient for use in direct HTTP calls. + /// This uses InteractiveBrowserCredential directly which is simpler and more reliable. + /// + private static async Task GetTokenFromGraphClient(ILogger logger, GraphServiceClient graphClient, string tenantId) + { + try + { + // Use Azure.Identity to get the token directly + // This is cleaner and more reliable than trying to extract it from GraphServiceClient + var credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions + { + TenantId = tenantId, + ClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" // Microsoft Graph PowerShell app ID + }); + + var tokenRequestContext = new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" }); + var token = await credential.GetTokenAsync(tokenRequestContext, CancellationToken.None); + + return token.Token; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get access token"); + return null; + } + } + + /// + /// Creates and authenticates a GraphServiceClient using InteractiveGraphAuthService. + /// This common method consolidates the authentication logic used across multiple methods. + /// + private async static Task GetAuthenticatedGraphClientAsync(ILogger logger,string tenantId, CancellationToken ct) + { + logger.LogInformation("Authenticating to Microsoft Graph using interactive browser authentication..."); + logger.LogInformation("IMPORTANT: Agent Blueprint operations require Application.ReadWrite.All permission."); + logger.LogInformation("This will open a browser window for interactive authentication."); + logger.LogInformation("Please sign in with a Global Administrator account."); + logger.LogInformation(""); + + // Use InteractiveGraphAuthService to get proper authentication + var interactiveAuth = new InteractiveGraphAuthService( + LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger()); + + try + { + var graphClient = await interactiveAuth.GetAuthenticatedGraphClientAsync(tenantId, ct); + logger.LogInformation("Successfully authenticated to Microsoft Graph"); + return graphClient; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to authenticate to Microsoft Graph: {Message}", ex.Message); + logger.LogError(""); + logger.LogError("TROUBLESHOOTING:"); + logger.LogError("1. Ensure you are a Global Administrator or have Application.ReadWrite.All permission"); + logger.LogError("2. The account must have already consented to these permissions"); + logger.LogError(""); + throw new InvalidOperationException($"Microsoft Graph authentication failed: {ex.Message}", ex); + } + } + + private static void TryOpenBrowser(string url) + { + try + { + using var p = new System.Diagnostics.Process(); + p.StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }; + p.Start(); + } + catch + { + // non-fatal + } + } + + /// + /// Creates client secret for Agent Blueprint (Phase 2.5) + /// Used by: BlueprintSubcommand and A365SetupRunner + /// + public static async Task CreateBlueprintClientSecretAsync( + string blueprintObjectId, + string blueprintAppId, + JsonObject generatedConfig, + string generatedConfigPath, + GraphApiService graphService, + Models.Agent365Config setupConfig, + ILogger logger, + CancellationToken ct = default) + { + try + { + logger.LogInformation("Creating client secret for Agent Blueprint using Graph API..."); + + var graphToken = await graphService.GetGraphAccessTokenAsync( + generatedConfig["tenantId"]?.GetValue() ?? string.Empty, ct); + + if (string.IsNullOrWhiteSpace(graphToken)) + { + logger.LogError("Failed to acquire Graph API access token"); + throw new InvalidOperationException("Cannot create client secret without Graph API token"); + } + + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); + + var secretBody = new JsonObject + { + ["passwordCredential"] = new JsonObject + { + ["displayName"] = "Agent 365 CLI Generated Secret", + ["endDateTime"] = DateTime.UtcNow.AddYears(2).ToString("yyyy-MM-ddTHH:mm:ss.fffZ") + } + }; + + var addPasswordUrl = $"https://graph.microsoft.com/v1.0/applications/{blueprintObjectId}/addPassword"; + var passwordResponse = await httpClient.PostAsync( + addPasswordUrl, + new StringContent(secretBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), + ct); + + if (!passwordResponse.IsSuccessStatusCode) + { + var errorContent = await passwordResponse.Content.ReadAsStringAsync(ct); + logger.LogError("Failed to create client secret: {Status} - {Error}", passwordResponse.StatusCode, errorContent); + throw new InvalidOperationException($"Failed to create client secret: {errorContent}"); + } + + var passwordJson = await passwordResponse.Content.ReadAsStringAsync(ct); + var passwordResult = JsonNode.Parse(passwordJson)!.AsObject(); + + var secretTextNode = passwordResult["secretText"]; + if (secretTextNode == null || string.IsNullOrWhiteSpace(secretTextNode.GetValue())) + { + logger.LogError("Client secret text is empty in response"); + throw new InvalidOperationException("Client secret creation returned empty secret"); + } + + var protectedSecret = ProtectSecret(secretTextNode.GetValue(), logger); + + var isProtected = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + generatedConfig["agentBlueprintClientSecret"] = protectedSecret; + generatedConfig["agentBlueprintClientSecretProtected"] = isProtected; + setupConfig.AgentBlueprintClientSecret = protectedSecret; + setupConfig.AgentBlueprintClientSecretProtected = isProtected; + + await File.WriteAllTextAsync( + generatedConfigPath, + generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), + ct); + + logger.LogInformation("Client secret created successfully!"); + logger.LogInformation($" - Secret stored in generated config (encrypted: {isProtected})"); + logger.LogWarning("IMPORTANT: The client secret has been stored in {Path}", generatedConfigPath); + logger.LogWarning("Keep this file secure and do not commit it to source control!"); + + if (!isProtected) + { + logger.LogWarning("WARNING: Secret encryption is only available on Windows. The secret is stored in plaintext."); + logger.LogWarning("Consider using environment variables or Azure Key Vault for production deployments."); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to create client secret: {Message}", ex.Message); + logger.LogInformation("You can create a client secret manually:"); + logger.LogInformation(" 1. Go to Azure Portal > App Registrations"); + logger.LogInformation(" 2. Find your Agent Blueprint: {AppId}", blueprintAppId); + logger.LogInformation(" 3. Navigate to Certificates & secrets > Client secrets"); + logger.LogInformation(" 4. Click 'New client secret' and save the value"); + logger.LogInformation(" 5. Add it to {Path} as 'agentBlueprintClientSecret'", generatedConfigPath); + } + } + + #region Private Helper Methods + + private static async Task CreateFederatedIdentityCredentialAsync( + string tenantId, + string blueprintObjectId, + string credentialName, + string msiPrincipalId, + string graphToken, + ILogger logger, + CancellationToken ct) + { + const int maxRetries = 5; + const int initialDelayMs = 2000; + + try + { + var federatedCredential = new JsonObject + { + ["name"] = credentialName, + ["issuer"] = $"https://login.microsoftonline.com/{tenantId}/v2.0", + ["subject"] = msiPrincipalId, + ["audiences"] = new JsonArray { "api://AzureADTokenExchange" } + }; + + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); + httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); + + var urls = new [] + { + $"https://graph.microsoft.com/beta/applications/{blueprintObjectId}/federatedIdentityCredentials", + $"https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/federatedIdentityCredentials" + }; + + string? lastError = null; + + foreach (var url in urls) + { + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + var response = await httpClient.PostAsync( + url, + new StringContent(federatedCredential.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), + ct); + + if (response.IsSuccessStatusCode) + { + logger.LogInformation(" - Credential Name: {Name}", credentialName); + logger.LogInformation(" - Issuer: https://login.microsoftonline.com/{TenantId}/v2.0", tenantId); + logger.LogInformation(" - Subject (MSI Principal ID): {MsiId}", msiPrincipalId); + return true; + } + + var error = await response.Content.ReadAsStringAsync(ct); + lastError = error; + + if ((error.Contains("Request_ResourceNotFound") || error.Contains("does not exist")) && attempt < maxRetries) + { + var delayMs = initialDelayMs * (int)Math.Pow(2, attempt - 1); + logger.LogWarning("Application object not yet propagated (attempt {Attempt}/{MaxRetries}). Retrying in {Delay}ms...", + attempt, maxRetries, delayMs); + await Task.Delay(delayMs, ct); + continue; + } + + if (error.Contains("Agent Blueprints are not supported on the API version")) + { + logger.LogDebug("Standard endpoint not supported, trying Agent Blueprint-specific path..."); + break; + } + + logger.LogDebug("FIC creation failed with error: {Error}", error); + break; + } + } + + logger.LogDebug("Failed to create federated identity credential after trying all endpoints: {Error}", lastError); + return false; + } + catch (Exception ex) + { + logger.LogError(ex, "Exception creating federated identity credential: {Message}", ex.Message); + return false; + } + } + + private static string ProtectSecret(string plaintext, ILogger logger) + { + if (string.IsNullOrWhiteSpace(plaintext)) + { + return plaintext; + } + + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var plaintextBytes = System.Text.Encoding.UTF8.GetBytes(plaintext); + var protectedBytes = ProtectedData.Protect( + plaintextBytes, + optionalEntropy: null, + scope: DataProtectionScope.CurrentUser); + + return Convert.ToBase64String(protectedBytes); + } + else + { + logger.LogWarning("DPAPI encryption not available on this platform. Secret will be stored in plaintext."); + return plaintext; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to encrypt secret, storing in plaintext: {Message}", ex.Message); + return plaintext; + } + } + + #endregion +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/EndpointSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/EndpointSubcommand.cs new file mode 100644 index 00000000..d2f6e530 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/EndpointSubcommand.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using System.CommandLine; +using System.Text.Json; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Endpoint subcommand - Registers blueprint messaging endpoint (Azure Bot Service) +/// Required Permissions: Azure Subscription Contributor +/// +internal static class EndpointSubcommand +{ + public static Command CreateCommand( + ILogger logger, + IConfigService configService, + IBotConfigurator botConfigurator, + PlatformDetector platformDetector) + { + var command = new Command("endpoint", + "Register blueprint messaging endpoint (Azure Bot Service)\n" + + "Minimum required permissions: Azure Subscription Contributor\n"); + + var configOption = new Option( + ["--config", "-c"], + getDefaultValue: () => new FileInfo("a365.config.json"), + description: "Configuration file path"); + + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Show detailed output"); + + var dryRunOption = new Option( + "--dry-run", + description: "Show what would be done without executing"); + + command.AddOption(configOption); + command.AddOption(verboseOption); + command.AddOption(dryRunOption); + + command.SetHandler(async (config, verbose, dryRun) => + { + var setupConfig = await configService.LoadAsync(config.FullName); + + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) + { + logger.LogError("Blueprint ID not found. Run 'a365 setup blueprint' first."); + Environment.Exit(1); + } + + if (string.IsNullOrWhiteSpace(setupConfig.WebAppName)) + { + logger.LogError("Web App Name not found. Run 'a365 setup infrastructure' first."); + Environment.Exit(1); + } + + if (dryRun) + { + logger.LogInformation("DRY RUN: Register Messaging Endpoint"); + logger.LogInformation("Would register Bot Service endpoint:"); + logger.LogInformation(" - Endpoint Name: {Name}-endpoint", setupConfig.WebAppName); + logger.LogInformation(" - Messaging URL: https://{Name}.azurewebsites.net/api/messages", setupConfig.WebAppName); + logger.LogInformation(" - Blueprint ID: {Id}", setupConfig.AgentBlueprintId); + logger.LogInformation("Would sync generated configuration to project settings"); + return; + } + + await RegisterEndpointAndSyncAsync( + configPath: config.FullName, + logger: logger, + configService: configService, + botConfigurator: botConfigurator, + platformDetector: platformDetector); + + // Display verification info and summary + await SetupHelpers.DisplayVerificationInfoAsync(config, logger); + + }, configOption, verboseOption, dryRunOption); + + return command; + } + + #region Public Static Implementation Method (for AllSubcommand) + + /// + /// Registers blueprint messaging endpoint and syncs project settings. + /// Public method that can be called by AllSubcommand. + /// + public static async Task RegisterEndpointAndSyncAsync( + string configPath, + ILogger logger, + IConfigService configService, + IBotConfigurator botConfigurator, + PlatformDetector platformDetector, + CancellationToken cancellationToken = default) + { + var setupConfig = await configService.LoadAsync(configPath); + + logger.LogInformation("Registering blueprint messaging endpoint..."); + logger.LogInformation(""); + + await SetupHelpers.RegisterBlueprintMessagingEndpointAsync( + setupConfig, logger, botConfigurator); + + + setupConfig.Completed = true; + setupConfig.CompletedAt = DateTime.UtcNow; + + await configService.SaveStateAsync(setupConfig); + + logger.LogInformation(""); + logger.LogInformation("Blueprint messaging endpoint registered successfully"); + + // Sync generated config to project settings (appsettings.json or .env) + logger.LogInformation(""); + logger.LogInformation("Syncing configuration to project settings..."); + + var configFileInfo = new FileInfo(configPath); + var generatedConfigPath = Path.Combine( + configFileInfo.DirectoryName ?? Environment.CurrentDirectory, + "a365.generated.config.json"); + + try + { + await ProjectSettingsSyncHelper.ExecuteAsync( + a365ConfigPath: configPath, + a365GeneratedPath: generatedConfigPath, + configService: configService, + platformDetector: platformDetector, + logger: logger); + + logger.LogInformation("Configuration synced to project settings successfully"); + } + catch (Exception syncEx) + { + logger.LogWarning(syncEx, "Project settings sync failed (non-blocking). Please sync settings manually if needed."); + } + } + + #endregion +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs new file mode 100644 index 00000000..2e5c4fde --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs @@ -0,0 +1,631 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Infrastructure subcommand - Creates Azure infrastructure (Resource Group, App Service Plan, Web App, MSI) +/// Required Permissions: Azure Subscription Contributor/Owner +/// COMPLETE REPLICATION of A365SetupRunner Phase 0 and Phase 1 functionality +/// +public static class InfrastructureSubcommand +{ + public static Command CreateCommand( + ILogger logger, + IConfigService configService, + IAzureValidator azureValidator, + AzureWebAppCreator webAppCreator, + PlatformDetector platformDetector, + CommandExecutor executor) + { + var command = new Command("infrastructure", + "Create Azure infrastructure\n" + + "Minimum required permissions: Azure Subscription Contributor or Owner\n"); + + var configOption = new Option( + ["--config", "-c"], + getDefaultValue: () => new FileInfo("a365.config.json"), + description: "Configuration file path"); + + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Show detailed output"); + + var dryRunOption = new Option( + "--dry-run", + description: "Show what would be done without executing"); + + command.AddOption(configOption); + command.AddOption(verboseOption); + command.AddOption(dryRunOption); + + command.SetHandler(async (config, verbose, dryRun) => + { + if (dryRun) + { + var dryRunConfig = await configService.LoadAsync(config.FullName); + + logger.LogInformation("DRY RUN: Create Azure Infrastructure"); + logger.LogInformation("Would create the following resources:"); + logger.LogInformation(" - Resource Group: {ResourceGroup}", dryRunConfig.ResourceGroup); + logger.LogInformation(" - Location: {Location}", dryRunConfig.Location); + logger.LogInformation(" - App Service Plan: {PlanName} (SKU: {Sku})", + dryRunConfig.AppServicePlanName, dryRunConfig.AppServicePlanSku); + logger.LogInformation(" - Web App: {WebAppName}", dryRunConfig.WebAppName); + logger.LogInformation(" - Managed Service Identity: Enabled"); + + // Detect platform (even in dry-run for informational purposes) + if (!string.IsNullOrWhiteSpace(dryRunConfig.DeploymentProjectPath)) + { + var detectedPlatform = platformDetector.Detect(dryRunConfig.DeploymentProjectPath); + var detectedRuntime = GetRuntimeForPlatform(detectedPlatform); + logger.LogInformation(" - Detected Platform: {Platform}", detectedPlatform); + logger.LogInformation(" - Runtime: {Runtime}", detectedRuntime); + } + + return; + } + + // Load configuration - ConfigService automatically finds generated config in same directory + var setupConfig = await configService.LoadAsync(config.FullName); + if (setupConfig.NeedDeployment) + { + // Validate Azure CLI authentication, subscription, and environment + if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) + { + Environment.Exit(1); + } + } + else + { + logger.LogInformation("NeedDeployment=false – skipping Azure subscription validation."); + } + + var generatedConfigPath = Path.Combine( + config.DirectoryName ?? Environment.CurrentDirectory, + "a365.generated.config.json"); + + await CreateInfrastructureImplementationAsync( + logger, + config.FullName, + generatedConfigPath, + executor, + platformDetector, + setupConfig.NeedDeployment, + false, + CancellationToken.None); + + logger.LogInformation(""); + logger.LogInformation("Next steps: Run 'a365 setup blueprint' to create the agent blueprint"); + + }, configOption, verboseOption, dryRunOption); + + return command; + } + + #region Public Static Methods (Reusable by A365SetupRunner) + + public static async Task CreateInfrastructureImplementationAsync( + ILogger logger, + string configPath, + string generatedConfigPath, + CommandExecutor commandExecutor, + PlatformDetector platformDetector, + bool needDeployment, + bool skipInfrastructure, + CancellationToken cancellationToken) + { + if (!File.Exists(configPath)) + { + logger.LogError("Config file not found at {Path}", configPath); + return false; + } + + JsonObject cfg; + try + { + cfg = JsonNode.Parse(await File.ReadAllTextAsync(configPath, cancellationToken))!.AsObject(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to parse config JSON: {Path}", configPath); + return false; + } + + string Get(string name) => cfg.TryGetPropertyValue(name, out var node) && node is JsonValue jv && jv.TryGetValue(out string? s) ? s ?? string.Empty : string.Empty; + + var subscriptionId = Get("subscriptionId"); + var tenantId = Get("tenantId"); + var resourceGroup = Get("resourceGroup"); + var planName = Get("appServicePlanName"); + var webAppName = Get("webAppName"); + var location = Get("location"); + var planSku = Get("appServicePlanSku"); + if (string.IsNullOrWhiteSpace(planSku)) planSku = ConfigConstants.DefaultAppServicePlanSku; + + var deploymentProjectPath = Get("deploymentProjectPath"); + + var skipInfra = skipInfrastructure || !needDeployment; + var externalHosting = !needDeployment && !skipInfrastructure; + + 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)) + { + platform = platformDetector.Detect(deploymentProjectPath); + logger.LogInformation("Detected project platform: {Platform}", platform); + } + else + { + logger.LogWarning("No deploymentProjectPath specified, defaulting to .NET runtime"); + } + + logger.LogInformation("Agent 365 Setup Infrastructure - Starting..."); + logger.LogInformation("Subscription: {Sub}", subscriptionId); + logger.LogInformation("Resource Group: {RG}", resourceGroup); + logger.LogInformation("App Service Plan: {Plan}", planName); + logger.LogInformation("Web App: {App}", webAppName); + logger.LogInformation("Location: {Loc}", location); + logger.LogInformation(""); + + if (!skipInfra) + { + bool isValidated = await ValidateAzureCliAuthenticationAsync( + commandExecutor, + tenantId, + logger, + cancellationToken); + + if (!isValidated) + { + return false; + } + } + else + { + logger.LogInformation("==> Skipping Azure management authentication (--skipInfrastructure or External hosting)"); + } + + await CreateInfrastructureAsync( + commandExecutor, + subscriptionId, + tenantId, + resourceGroup, + location, + planName, + planSku, + webAppName, + generatedConfigPath, + platform, + logger, + needDeployment, + skipInfra, + externalHosting, + cancellationToken); + + return true; + } + + /// + /// Phase 0: Validate Azure CLI authentication and acquire management scope token + /// + public static async Task ValidateAzureCliAuthenticationAsync( + CommandExecutor executor, + string tenantId, + ILogger logger, + CancellationToken cancellationToken = default) + { + logger.LogInformation("==> Verifying Azure CLI authentication"); + + // Check if logged in + var accountCheck = await executor.ExecuteAsync("az", "account show", captureOutput: true, suppressErrorLogging: true, cancellationToken: cancellationToken); + if (!accountCheck.Success) + { + logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope..."); + logger.LogInformation("A browser window will open for authentication."); + + 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!"); + await Task.Delay(2000, cancellationToken); + } + else + { + logger.LogInformation("Azure CLI already authenticated"); + } + + // Verify we have the management scope + 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."); + + 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!"); + await Task.Delay(2000, cancellationToken); + + 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 verified successfully"); + } + + logger.LogInformation(""); + return true; + } + + /// + /// Phase 1: Create Azure infrastructure (Resource Group, App Service Plan, Web App, Managed Identity) + /// Equivalent to A365SetupRunner Phase 1 (lines 223-334) + /// Returns the Managed Identity Principal ID (or null if not assigned) + /// + public static async Task CreateInfrastructureAsync( + CommandExecutor executor, + string subscriptionId, + string tenantId, + string resourceGroup, + string location, + string planName, + string? planSku, + string webAppName, + string generatedConfigPath, + Models.ProjectPlatform platform, + ILogger logger, + bool needDeployment, + bool skipInfra, + bool externalHosting, + CancellationToken cancellationToken = default) + { + string? principalId = null; + JsonObject generatedConfig = new JsonObject(); + + if (skipInfra) + { + var modeMessage = "External hosting (non-Azure)"; + + logger.LogInformation("==> Skipping Azure infrastructure ({Mode})", modeMessage); + logger.LogInformation("Loading existing configuration..."); + + // Load existing generated config if available + if (File.Exists(generatedConfigPath)) + { + try + { + generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath, cancellationToken))?.AsObject() ?? new JsonObject(); + + if (generatedConfig.TryGetPropertyValue("managedIdentityPrincipalId", out var existingPrincipalId)) + { + // Only reuse MSI in blueprint-only mode + principalId = existingPrincipalId?.GetValue(); + logger.LogInformation("Found existing Managed Identity Principal ID: {Id}", principalId ?? "(none)"); + } + else if (externalHosting) + { + logger.LogInformation("External hosting selected - Managed Identity will NOT be used."); + + // Make sure we don't create FIC later + principalId = null; + } + + logger.LogInformation("Existing configuration loaded successfully"); + } + catch (Exception ex) + { + logger.LogWarning("Could not load existing config: {Message}. Starting fresh.", ex.Message); + } + } + else + { + logger.LogInformation("No existing configuration found - blueprint will be created without managed identity"); + } + + logger.LogInformation(""); + } + else + { + logger.LogInformation("==> Deploying App Service + enabling Managed Identity"); + + // Set subscription context + try + { + await executor.ExecuteAsync("az", $"account set --subscription {subscriptionId}"); + } + catch (Exception) + { + logger.LogWarning("Failed to set az subscription context explicitly"); + } + + // Resource group + var rgExists = await executor.ExecuteAsync("az", $"group exists -n {resourceGroup} --subscription {subscriptionId}", captureOutput: true); + if (rgExists.Success && rgExists.StandardOutput.Trim().Equals("true", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation("Resource group already exists: {RG} (skipping creation)", resourceGroup); + } + else + { + logger.LogInformation("Creating resource group {RG}", resourceGroup); + await AzWarnAsync(executor, logger, $"group create -n {resourceGroup} -l {location} --subscription {subscriptionId}", "Create resource group"); + } + + // App Service plan + await EnsureAppServicePlanExistsAsync(executor, logger, resourceGroup, planName, planSku, subscriptionId); + + // Web App + var webShow = await executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); + if (!webShow.Success) + { + var runtime = GetRuntimeForPlatform(platform); + logger.LogInformation("Creating web app {App} with runtime {Runtime}", webAppName, runtime); + var createResult = await executor.ExecuteAsync("az", $"webapp create -g {resourceGroup} -p {planName} -n {webAppName} --runtime \"{runtime}\" --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); + if (!createResult.Success) + { + if (createResult.StandardError.Contains("AuthorizationFailed", StringComparison.OrdinalIgnoreCase)) + { + throw new AzureResourceException("WebApp", webAppName, createResult.StandardError, true); + } + else + { + logger.LogError("ERROR: Web app creation failed: {Err}", createResult.StandardError); + throw new InvalidOperationException($"Failed to create web app '{webAppName}'. Setup cannot continue."); + } + } + } + else + { + var linuxFxVersion = GetLinuxFxVersionForPlatform(platform); + logger.LogInformation("Web app already exists: {App} (skipping creation)", webAppName); + logger.LogInformation("Configuring web app to use {Platform} runtime ({LinuxFxVersion})...", platform, linuxFxVersion); + await AzWarnAsync(executor, logger, $"webapp config set -g {resourceGroup} -n {webAppName} --linux-fx-version \"{linuxFxVersion}\" --subscription {subscriptionId}", "Configure runtime"); + } + + // Verify web app + var verifyResult = await executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); + if (!verifyResult.Success) + { + logger.LogWarning("WARNING: Unable to verify web app via az webapp show."); + } + else + { + logger.LogInformation("Verified web app presence."); + } + + // Managed Identity + logger.LogInformation("Assigning (or confirming) system-assigned managed identity"); + var identity = await executor.ExecuteAsync("az", $"webapp identity assign -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}"); + if (identity.Success) + { + try + { + var json = JsonDocument.Parse(identity.StandardOutput); + principalId = json.RootElement.GetProperty("principalId").GetString(); + if (!string.IsNullOrEmpty(principalId)) + { + logger.LogInformation("Managed Identity principalId: {Id}", principalId); + } + } + catch + { + // ignore parse error + } + } + else if (identity.StandardError.Contains("already has a managed identity", StringComparison.OrdinalIgnoreCase) || + identity.StandardError.Contains("Conflict", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation("Managed identity already assigned (ignoring conflict)."); + } + else + { + logger.LogWarning("WARNING: identity assign returned error: {Err}", identity.StandardError.Trim()); + } + + // Load or create generated config + if (File.Exists(generatedConfigPath)) + { + try + { + generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath, cancellationToken))?.AsObject() ?? new JsonObject(); + } + catch + { + logger.LogWarning("Could not parse existing generated config, starting fresh"); + } + } + + if (!string.IsNullOrWhiteSpace(principalId)) + { + generatedConfig["managedIdentityPrincipalId"] = principalId; + await File.WriteAllTextAsync(generatedConfigPath, generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken); + logger.LogInformation("Generated config updated with MSI principalId: {Id}", principalId); + } + + logger.LogInformation("Waiting 10 seconds to ensure Service Principal is fully propagated..."); + await Task.Delay(10000, cancellationToken); + } + } + + /// + /// Save Managed Identity Principal ID to a365.generated.config.json + /// Equivalent to A365SetupRunner logic (lines 321-332) + /// + public static async Task SaveManagedIdentityToConfigAsync( + string principalId, + string generatedConfigPath, + ILogger logger, + CancellationToken cancellationToken = default) + { + // Load or create generated config + JsonObject generatedConfig = new JsonObject(); + if (File.Exists(generatedConfigPath)) + { + try + { + generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath, cancellationToken))?.AsObject() ?? new JsonObject(); + } + catch + { + logger.LogWarning("Could not parse existing generated config, starting fresh"); + } + } + + generatedConfig["managedIdentityPrincipalId"] = principalId; + await File.WriteAllTextAsync(generatedConfigPath, + generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), + cancellationToken); + + logger.LogInformation("Generated config updated with MSI principalId: {Id}", principalId); + } + + #endregion + + #region Private Helper Methods + + private static async Task AzWarnAsync(CommandExecutor executor, ILogger logger, string args, string description) + { + var result = await executor.ExecuteAsync("az", args, suppressErrorLogging: true); + if (!result.Success) + { + if (result.StandardError.Contains("already exists", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation("{Description} already exists (skipping creation)", description); + } + else if (result.StandardError.Contains("AuthorizationFailed", StringComparison.OrdinalIgnoreCase)) + { + var exception = new AzureResourceException(description, string.Empty, result.StandardError, true); + ExceptionHandler.HandleAgent365Exception(exception); + } + else + { + logger.LogWarning("az {Description} returned non-success (exit code {Code}). Error: {Err}", + description, result.ExitCode, Short(result.StandardError)); + } + } + } + + /// + /// Ensures that an App Service Plan exists, creating it if necessary and verifying its existence. + /// + internal static async Task EnsureAppServicePlanExistsAsync(CommandExecutor executor, ILogger logger, string resourceGroup, string planName, string? planSku, string subscriptionId) + { + var planShow = await executor.ExecuteAsync("az", $"appservice plan show -g {resourceGroup} -n {planName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); + if (planShow.Success) + { + logger.LogInformation("App Service plan already exists: {Plan} (skipping creation)", planName); + } + else + { + logger.LogInformation("Creating App Service plan {Plan}", planName); + await AzWarnAsync(executor, logger, $"appservice plan create -g {resourceGroup} -n {planName} --sku {planSku} --is-linux --subscription {subscriptionId}", "Create App Service plan"); + + // Verify the plan was created successfully + var verifyPlan = await executor.ExecuteAsync("az", $"appservice plan show -g {resourceGroup} -n {planName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); + if (!verifyPlan.Success) + { + logger.LogError("ERROR: App Service plan creation failed. The plan '{Plan}' does not exist.", planName); + throw new InvalidOperationException($"Failed to create App Service plan '{planName}'. This may be due to quota limits or insufficient permissions. Setup cannot continue."); + } + logger.LogInformation("App Service plan created successfully: {Plan}", planName); + } + } + + /// + /// Get the Azure Web App runtime string based on the detected platform + /// (from A365SetupRunner GetRuntimeForPlatform method) + /// + private static string GetRuntimeForPlatform(Models.ProjectPlatform platform) + { + return platform switch + { + Models.ProjectPlatform.Python => "PYTHON:3.11", + Models.ProjectPlatform.NodeJs => "NODE:18-lts", + Models.ProjectPlatform.DotNet => "DOTNETCORE:8.0", + _ => "DOTNETCORE:8.0" // Default fallback + }; + } + + /// + /// Get the Azure Web App Linux FX Version string based on the detected platform + /// (from A365SetupRunner GetLinuxFxVersionForPlatform method) + /// + private static string GetLinuxFxVersionForPlatform(Models.ProjectPlatform platform) + { + return platform switch + { + Models.ProjectPlatform.Python => "PYTHON|3.11", + Models.ProjectPlatform.NodeJs => "NODE|20-lts", + Models.ProjectPlatform.DotNet => "DOTNETCORE|8.0", + _ => "DOTNETCORE|8.0" // Default fallback + }; + } + + private static string Short(string? text) + => string.IsNullOrWhiteSpace(text) ? string.Empty : (text.Length <= 180 ? text.Trim() : text[..177] + "..."); + + #endregion +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs new file mode 100644 index 00000000..deb035ad --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using System.CommandLine; +using System.Threading; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Permissions subcommand - Configures OAuth2 permission grants and inheritable permissions +/// Required Permissions: Global Administrator (for admin consent) +/// +internal static class PermissionsSubcommand +{ + public static Command CreateCommand( + ILogger logger, + IConfigService configService, + CommandExecutor executor, + GraphApiService graphApiService) + { + var permissionsCommand = new Command("permissions", + "Configure OAuth2 permission grants and inheritable permissions\n" + + "Minimum required permissions: Global Administrator\n"); + + // Add subcommands + permissionsCommand.AddCommand(CreateMcpSubcommand(logger, configService, executor, graphApiService)); + permissionsCommand.AddCommand(CreateBotSubcommand(logger, configService, executor, graphApiService)); + + return permissionsCommand; + } + + /// + /// MCP permissions subcommand + /// + private static Command CreateMcpSubcommand( + ILogger logger, + IConfigService configService, + CommandExecutor executor, + GraphApiService graphApiService) + { + var command = new Command("mcp", + "Configure MCP server OAuth2 grants and inheritable permissions\n" + + "Minimum required permissions: Global Administrator\n\n"); + + var configOption = new Option( + ["--config", "-c"], + getDefaultValue: () => new FileInfo("a365.config.json"), + description: "Configuration file path"); + + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Show detailed output"); + + var dryRunOption = new Option( + "--dry-run", + description: "Show what would be done without executing"); + + command.AddOption(configOption); + command.AddOption(verboseOption); + command.AddOption(dryRunOption); + + command.SetHandler(async (config, verbose, dryRun) => + { + var setupConfig = await configService.LoadAsync(config.FullName); + + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) + { + logger.LogError("Blueprint ID not found. Run 'a365 setup blueprint' first."); + Environment.Exit(1); + } + + if (dryRun) + { + // Read scopes from toolingManifest.json + var manifestPath = Path.Combine(setupConfig.DeploymentProjectPath ?? string.Empty, "toolingManifest.json"); + var toolingScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath); + + logger.LogInformation("DRY RUN: Configure MCP Permissions"); + logger.LogInformation("Would configure OAuth2 grants and inheritable permissions:"); + logger.LogInformation(" - Blueprint: {BlueprintId}", setupConfig.AgentBlueprintId); + logger.LogInformation(" - Resource: Agent 365 Tools ({Environment})", setupConfig.Environment); + logger.LogInformation(" - Scopes: {Scopes}", string.Join(", ", toolingScopes)); + return; + } + + await ConfigureMcpPermissionsAsync( + config.FullName, + logger, + configService, + executor, + graphApiService, + setupConfig, + false); + + }, configOption, verboseOption, dryRunOption); + + return command; + } + + /// + /// Bot API permissions subcommand + /// + private static Command CreateBotSubcommand( + ILogger logger, + IConfigService configService, + CommandExecutor executor, + GraphApiService graphApiService) + { + var command = new Command("bot", + "Configure Messaging Bot API OAuth2 grants and inheritable permissions\n" + + "Minimum required permissions: Global Administrator\n\n" + + "Prerequisites: Blueprint and MCP permissions (run 'a365 setup permissions mcp' first)\n" + + "Next step: a365 setup endpoint"); + + var configOption = new Option( + ["--config", "-c"], + getDefaultValue: () => new FileInfo("a365.config.json"), + description: "Configuration file path"); + + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Show detailed output"); + + var dryRunOption = new Option( + "--dry-run", + description: "Show what would be done without executing"); + + command.AddOption(configOption); + command.AddOption(verboseOption); + command.AddOption(dryRunOption); + + command.SetHandler(async (config, verbose, dryRun) => + { + var setupConfig = await configService.LoadAsync(config.FullName); + + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) + { + logger.LogError("Blueprint ID not found. Run 'a365 setup blueprint' first."); + Environment.Exit(1); + } + + if (dryRun) + { + logger.LogInformation("DRY RUN: Configure Bot API Permissions"); + logger.LogInformation("Would configure Messaging Bot API permissions:"); + logger.LogInformation(" - Blueprint: {BlueprintId}", setupConfig.AgentBlueprintId); + logger.LogInformation(" - Scopes: Authorization.ReadWrite, user_impersonation"); + return; + } + + await ConfigureBotPermissionsAsync( + config.FullName, + logger, + configService, + executor, + setupConfig, + graphApiService, + false); + + }, configOption, verboseOption, dryRunOption); + + return command; + } + + /// + /// Configures MCP server permissions (OAuth2 grants and inheritable permissions). + /// Public method that can be called by AllSubcommand. + /// + public static async Task ConfigureMcpPermissionsAsync( + string configPath, + ILogger logger, + IConfigService configService, + CommandExecutor executor, + GraphApiService graphApiService, + Models.Agent365Config setupConfig, + bool iSetupAll, + CancellationToken cancellationToken = default) + { + logger.LogInformation(""); + logger.LogInformation("Configuring MCP server permissions..."); + logger.LogInformation(""); + + try + { + // Read scopes from toolingManifest.json + var manifestPath = Path.Combine(setupConfig.DeploymentProjectPath ?? string.Empty, "toolingManifest.json"); + var toolingScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath); + + // OAuth2 permission grants + await SetupHelpers.EnsureMcpOauth2PermissionGrantsAsync( + graphApiService, setupConfig, toolingScopes, logger); + + // Inheritable permissions + await SetupHelpers.EnsureMcpInheritablePermissionsAsync( + graphApiService, setupConfig, toolingScopes, logger); + + logger.LogInformation(""); + logger.LogInformation("MCP server permissions configured successfully"); + logger.LogInformation(""); + if (!iSetupAll) + { + logger.LogInformation("Next step: 'a365 setup permissions bot' to configure Bot API permissions"); + } + + // write changes to generated config + await configService.SaveStateAsync(setupConfig); + return true; + } + catch (Exception mcpEx) + { + logger.LogError("Failed to configure MCP server permissions: {Message}", mcpEx.Message); + logger.LogInformation("To configure MCP permissions manually:"); + logger.LogInformation(" 1. Ensure the agent blueprint has the required permissions in Azure Portal"); + logger.LogInformation(" 2. Grant admin consent for the MCP scopes"); + logger.LogInformation(" 3. Run 'a365 setup mcp' to retry MCP permission configuration"); + if (iSetupAll) + { + throw; + } + return false; + } + } + + /// + /// Configures Bot API permissions (OAuth2 grants and inheritable permissions). + /// Public method that can be called by AllSubcommand. + /// + public static async Task ConfigureBotPermissionsAsync( + string configPath, + ILogger logger, + IConfigService configService, + CommandExecutor executor, + Models.Agent365Config setupConfig, + GraphApiService graphService, + bool iSetupAll, + CancellationToken cancellationToken = default) + { + logger.LogInformation(""); + logger.LogInformation("Configuring Messaging Bot API permissions..."); + logger.LogInformation(""); + + try + { + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) + { + throw new SetupValidationException("AgentBlueprintId is required."); + } + + var blueprintSpObjectId = await graphService.LookupServicePrincipalByAppIdAsync(setupConfig.TenantId, 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( + setupConfig.TenantId, + ConfigConstants.MessagingBotApiAppId); + + // Grant OAuth2 permissions + var botApiGrantOk = await graphService.CreateOrUpdateOauth2PermissionGrantAsync( + setupConfig.TenantId, + blueprintSpObjectId, + botApiResourceSpObjectId, + new[] { "Authorization.ReadWrite", "user_impersonation" }); + + if (!botApiGrantOk) + { + throw new InvalidOperationException("Failed to create/update oauth2PermissionGrant for Messaging Bot API"); + } + + // Set inheritable permissions + var (ok, already, err) = await graphService.SetInheritablePermissionsAsync( + setupConfig.TenantId, + setupConfig.AgentBlueprintId, + ConfigConstants.MessagingBotApiAppId, + new[] { "Authorization.ReadWrite", "user_impersonation" }); + + if (!ok && !already) + { + throw new InvalidOperationException($"Failed to set inheritable permissions for Messaging Bot API: {err}"); + } + + // write changes to generated config + await configService.SaveStateAsync(setupConfig); + + logger.LogInformation(""); + logger.LogInformation("Messaging Bot API permissions configured successfully"); + logger.LogInformation(""); + if (!iSetupAll) + { + logger.LogInformation("Next step: Run 'a365 setup endpoint' to register messaging endpoint"); + } + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to configure Bot API permissions: {Message}", ex.Message); + if (iSetupAll) + { + throw; + } + return false; + } + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs new file mode 100644 index 00000000..877669af --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -0,0 +1,380 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Shared helper methods for setup subcommands +/// +internal static class SetupHelpers +{ + /// + /// Display verification URLs and next steps after successful setup + /// + public static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, ILogger logger) + { + try + { + logger.LogInformation("Generating verification information..."); + var baseDir = setupConfigFile.DirectoryName ?? Environment.CurrentDirectory; + var generatedConfigPath = Path.Combine(baseDir, "a365.generated.config.json"); + + if (!File.Exists(generatedConfigPath)) + { + logger.LogWarning("Generated config not found - skipping verification info"); + return; + } + + using var stream = File.OpenRead(generatedConfigPath); + using var doc = await JsonDocument.ParseAsync(stream); + var root = doc.RootElement; + + logger.LogInformation(""); + logger.LogInformation("Verification URLs and Next Steps:"); + logger.LogInformation("=========================================="); + + // Azure Web App URL + if (root.TryGetProperty("AppServiceName", out var appServiceProp) && !string.IsNullOrWhiteSpace(appServiceProp.GetString())) + { + var webAppUrl = $"https://{appServiceProp.GetString()}.azurewebsites.net"; + logger.LogInformation("Agent Web App: {Url}", webAppUrl); + } + + // Azure Resource Group + if (root.TryGetProperty("ResourceGroup", out var rgProp) && !string.IsNullOrWhiteSpace(rgProp.GetString())) + { + var resourceGroup = rgProp.GetString(); + logger.LogInformation("Azure Resource Group: https://portal.azure.com/#@/resource/subscriptions/{SubscriptionId}/resourceGroups/{ResourceGroup}", + root.TryGetProperty("SubscriptionId", out var subProp) ? subProp.GetString() : "{subscription}", + resourceGroup); + } + + // Entra ID Application + if (root.TryGetProperty("AgentBlueprintId", out var blueprintProp) && !string.IsNullOrWhiteSpace(blueprintProp.GetString())) + { + logger.LogInformation("Entra ID Application: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/{AppId}", + blueprintProp.GetString()); + } + + logger.LogInformation(""); + logger.LogInformation("Next Steps:"); + logger.LogInformation(" 1. Review Azure resources in the portal"); + logger.LogInformation(" 2. View configuration: a365 config display"); + logger.LogInformation(" 3. Create agent instance: a365 create-instance identity"); + logger.LogInformation(" 4. Deploy application: a365 deploy app"); + logger.LogInformation(""); + } + catch (Exception ex) + { + logger.LogError(ex, "Could not display verification info: {Message}", ex.Message); + } + } + + /// + /// Display comprehensive setup summary showing what succeeded and what failed + /// + public static void DisplaySetupSummary(SetupResults results, ILogger logger) + { + logger.LogInformation(""); + logger.LogInformation("=========================================="); + logger.LogInformation("Setup Summary"); + logger.LogInformation("=========================================="); + + // Show what succeeded + logger.LogInformation("Completed Steps:"); + if (results.InfrastructureCreated) + { + logger.LogInformation(" [OK] Infrastructure created"); + } + if (results.BlueprintCreated) + { + logger.LogInformation(" [OK] Agent blueprint created (Blueprint ID: {BlueprintId})", results.BlueprintId ?? "unknown"); + } + if (results.McpPermissionsConfigured) + logger.LogInformation(" [OK] MCP server permissions configured"); + if (results.InheritablePermissionsConfigured) + logger.LogInformation(" [OK] Inheritable permissions configured"); + if (results.BotApiPermissionsConfigured) + logger.LogInformation(" [OK] Messaging Bot API permissions configured"); + if (results.MessagingEndpointRegistered) + logger.LogInformation(" [OK] Messaging endpoint registered"); + + // Show what failed + if (results.Errors.Count > 0) + { + logger.LogInformation(""); + logger.LogInformation("Failed Steps:"); + foreach (var error in results.Errors) + { + logger.LogInformation(" [FAILED] {Error}", error); + } + } + + // Show warnings + if (results.Warnings.Count > 0) + { + logger.LogInformation(""); + logger.LogInformation("Warnings:"); + foreach (var warning in results.Warnings) + { + logger.LogInformation(" [WARN] {Warning}", warning); + } + } + + logger.LogInformation(""); + + // Overall status + if (results.HasErrors) + { + logger.LogWarning("Setup completed with errors"); + logger.LogInformation(""); + logger.LogInformation("Recovery Actions:"); + + if (!results.InheritablePermissionsConfigured) + { + logger.LogInformation(" - Inheritable Permissions: Run 'a365 setup permissions mcp' to retry"); + } + + if (!results.McpPermissionsConfigured) + { + logger.LogInformation(" - MCP Permissions: Run 'a365 setup permissions mcp' to retry"); + } + + if (!results.BotApiPermissionsConfigured) + { + logger.LogInformation(" - Bot API Permissions: Run 'a365 setup permissions bot' to retry"); + } + + if (!results.MessagingEndpointRegistered) + { + logger.LogInformation(" - Messaging Endpoint: Run 'a365 setup endpoint' to retry"); + } + } + else if (results.HasWarnings) + { + logger.LogInformation("Setup completed successfully with warnings"); + logger.LogInformation("Review warnings above and take action if needed"); + } + else + { + logger.LogInformation("Setup completed successfully"); + logger.LogInformation("All components configured correctly"); + } + + logger.LogInformation("=========================================="); + } + + /// + /// Ensure MCP OAuth2 permission grants (admin consent) + /// + public static async Task EnsureMcpOauth2PermissionGrantsAsync( + GraphApiService graph, + Agent365Config config, + string[] scopes, + ILogger logger, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) + throw new SetupValidationException("AgentBlueprintId (appId) is required."); + + var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct); + if (string.IsNullOrWhiteSpace(blueprintSpObjectId)) + { + 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."); + } + + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct); + if (string.IsNullOrWhiteSpace(Agent365ToolsSpObjectId)) + { + 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}"); + } + + logger.LogInformation(" - OAuth2 grant: client {ClientId} to resource {ResourceId} scopes [{Scopes}]", + blueprintSpObjectId, Agent365ToolsSpObjectId, string.Join(' ', scopes)); + + var response = await graph.CreateOrUpdateOauth2PermissionGrantAsync( + config.TenantId, blueprintSpObjectId, Agent365ToolsSpObjectId, scopes, ct); + + if (!response) + { + 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."); + } + } + + /// + /// Ensure MCP inheritable permissions on blueprint + /// + public static async Task EnsureMcpInheritablePermissionsAsync( + GraphApiService graph, + Agent365Config config, + string[] scopes, + ILogger logger, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) + throw new SetupValidationException("AgentBlueprintId (appId) is required."); + + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + + logger.LogInformation(" - Inheritable permissions: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", + config.AgentBlueprintId, resourceAppId, string.Join(' ', scopes)); + + var (ok, alreadyExists, err) = await graph.SetInheritablePermissionsAsync( + config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct); + + if (!ok && !alreadyExists) + { + config.InheritanceConfigured = false; + config.InheritanceConfigError = err; + throw new SetupValidationException($"Failed to set inheritable permissions: {err}. " + + "Ensure you have Application.ReadWrite.All permissions and the blueprint supports inheritable permissions."); + } + + config.InheritanceConfigured = true; + config.InheritablePermissionsAlreadyExist = alreadyExists; + config.InheritanceConfigError = null; + } + + /// + /// Register blueprint messaging endpoint + /// + public static async Task RegisterBlueprintMessagingEndpointAsync( + Agent365Config setupConfig, + ILogger logger, + IBotConfigurator botConfigurator) + { + // Validate required configuration + if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId)) + { + logger.LogError("Agent Blueprint ID not found. Blueprint creation may have failed."); + 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; + string endpointName; + if (setupConfig.NeedDeployment) + { + if (string.IsNullOrEmpty(setupConfig.WebAppName)) + { + logger.LogError("Web App Name not configured in a365.config.json"); + throw new SetupValidationException( + issueDescription: "Web App name is required to register a messaging endpoint when needDeployment is 'yes'.", + errorDetails: new List + { + "NeedDeployment 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.NeedDeployment.ToString(), + ["webAppName"] = setupConfig.WebAppName ?? "" + }); + } + + // 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 // Non-Azure hosting + { + // No deployment – use the provided MessagingEndpoint + if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint)) + { + 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) || + uri.Scheme != Uri.UriSchemeHttps) + { + logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}", + setupConfig.MessagingEndpoint); + throw new SetupValidationException("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); + } + + if (endpointName.Length < 4) + { + logger.LogError("Bot endpoint name '{EndpointName}' is too short (must be at least 4 characters)", endpointName); + throw new SetupValidationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)"); + } + + logger.LogInformation(" - Registering blueprint messaging endpoint"); + logger.LogInformation(" * Endpoint Name: {EndpointName}", endpointName); + logger.LogInformation(" * Messaging Endpoint: {Endpoint}", messagingEndpoint); + logger.LogInformation(" * Using Agent Blueprint ID: {AgentBlueprintId}", setupConfig.AgentBlueprintId); + + var endpointRegistered = await botConfigurator.CreateEndpointWithAgentBlueprintAsync( + endpointName: endpointName, + location: setupConfig.Location, + messagingEndpoint: messagingEndpoint, + agentDescription: "Agent 365 messaging endpoint for automated interactions", + agentBlueprintId: setupConfig.AgentBlueprintId); + + if (!endpointRegistered) + { + logger.LogError("Failed to register blueprint messaging endpoint"); + throw new SetupValidationException("Blueprint messaging endpoint registration failed"); + } + + // Update Agent365Config state properties + setupConfig.BotId = setupConfig.AgentBlueprintId; + setupConfig.BotMsaAppId = setupConfig.AgentBlueprintId; + setupConfig.BotMessagingEndpoint = messagingEndpoint; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs new file mode 100644 index 00000000..cfd9e064 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Tracks the results of each setup step for summary reporting +/// +public class SetupResults +{ + public bool InfrastructureCreated { get; set; } + public bool BlueprintCreated { get; set; } + public string? BlueprintId { get; set; } + public bool McpPermissionsConfigured { get; set; } + public bool BotApiPermissionsConfigured { get; set; } + public bool MessagingEndpointRegistered { get; set; } + public bool InheritablePermissionsConfigured { get; set; } + + public List Errors { get; } = new(); + public List Warnings { get; } = new(); + + public bool HasErrors => Errors.Count > 0; + public bool HasWarnings => Warnings.Count > 0; +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj b/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj index a17b2743..280abfdb 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj @@ -15,6 +15,9 @@ false + + + diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index 575fe307..6ead58f4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -306,6 +306,12 @@ public string BotName [JsonPropertyName("agentBlueprintClientSecret")] public string? AgentBlueprintClientSecret { get; set; } + /// + /// Boolean value indicating if the client secret is stored securely (e.g., in Key Vault). + /// + [JsonPropertyName("agentBlueprintClientSecretProtected")] + public bool AgentBlueprintClientSecretProtected { get; set; } + #endregion #region Bot State diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 3ce0fb4f..4334621e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -118,7 +118,6 @@ static async Task Main(string[] args) { // Unexpected error - this is a BUG Log.Fatal(exception, "Application terminated unexpectedly"); - Console.Error.WriteLine(); Console.Error.WriteLine("Unexpected error occurred. This may be a bug in the CLI."); Console.Error.WriteLine("Please report this issue at: https://github.com/microsoft/Agent365-devTools/issues"); Console.Error.WriteLine(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs deleted file mode 100644 index e195fe6c..00000000 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ /dev/null @@ -1,1823 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Azure.Core; -using Azure.Identity; -using Microsoft.Agents.A365.DevTools.Cli.Constants; -using Microsoft.Agents.A365.DevTools.Cli.Exceptions; -using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; -using Microsoft.Extensions.Logging; -using Microsoft.Graph; -using System.Net.Http.Headers; -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace Microsoft.Agents.A365.DevTools.Cli.Services; - -/// -/// C# implementation of a365-setup.ps1 with full feature parity. -/// Handles infrastructure setup, blueprint creation, consent flows, and MCP server configuration. -/// -public sealed class A365SetupRunner -{ - private readonly ILogger _logger; - private readonly CommandExecutor _executor; - private readonly GraphApiService _graphService; - private readonly AzureWebAppCreator _webAppCreator; - private readonly DelegatedConsentService _delegatedConsentService; - private readonly PlatformDetector _platformDetector; - private const string GraphResourceAppId = "00000003-0000-0000-c000-000000000000"; // Microsoft Graph - private const string ConnectivityResourceAppId = "0ddb742a-e7dc-4899-a31e-80e797ec7144"; // Connectivity - private const string InheritablePermissionsResourceAppIdId = "00000003-0000-0ff1-ce00-000000000000"; - private const string MicrosoftGraphCommandLineToolsAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; // Microsoft Graph Command Line Tools - private const string DocumentationMessage = "See documentation at https://aka.ms/agent365/setup for more information."; - - public A365SetupRunner( - ILogger logger, - CommandExecutor executor, - GraphApiService graphService, - AzureWebAppCreator webAppCreator, - DelegatedConsentService delegatedConsentService, - PlatformDetector platformDetector) - { - _logger = logger; - _executor = executor; - _graphService = graphService; - _webAppCreator = webAppCreator; - _delegatedConsentService = delegatedConsentService; - _platformDetector = platformDetector; - } - - /// - /// Validates Graph token string for disallowed high-privilege scopes. - /// Throws GraphTokenScopeException if Directory.AccessAsUser.All is present. - /// - private void ValidateGraphToken(string? token) - { - if (string.IsNullOrWhiteSpace(token)) - return; - - try - { - // JWT format: header.payload.signature - var parts = token.Split('.'); - if (parts.Length < 2) return; - - string payload = parts[1]; - // Base64URL decode - payload = payload.Replace('-', '+').Replace('_', '/'); - switch (payload.Length % 4) - { - case 2: payload += "=="; break; - case 3: payload += "="; break; - } - - var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload)); - using var doc = JsonDocument.Parse(json); - - // scopes may appear as 'scp' (space-separated) or 'roles' or 'wids'. Check scp - if (doc.RootElement.TryGetProperty("scp", out var scp)) - { - var scpValue = scp.GetString() ?? string.Empty; - if (scpValue.Split(' ', StringSplitOptions.RemoveEmptyEntries).Any(s => s.Equals("Directory.AccessAsUser.All", StringComparison.OrdinalIgnoreCase))) - { - _logger.LogError("Detected high-privilege scope in token: Directory.AccessAsUser.All"); - throw new GraphTokenScopeException("Directory.AccessAsUser.All"); - } - } - } - catch (Exception ex) when (ex is not Agent365Exception) - { - _logger.LogDebug(ex, "Failed to validate graph token payload"); - } - } - - /// - /// Execute setup using provided JSON config file. - /// Fully compatible with a365-setup.ps1 functionality. - /// - /// Path to a365.config.json - /// Path where a365.generated.config.json will be written - /// If true, skip Azure infrastructure (Phase 1) and create blueprint only - /// Cancellation token - public async Task RunAsync(string configPath, string generatedConfigPath, bool blueprintOnly = false, CancellationToken cancellationToken = default) - { - if (!File.Exists(configPath)) - { - _logger.LogError("Config file not found at {Path}", configPath); - return false; - } - - JsonObject cfg; - try - { - cfg = JsonNode.Parse(await File.ReadAllTextAsync(configPath, cancellationToken))!.AsObject(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to parse config JSON: {Path}", configPath); - return false; - } - - string Get(string name) => cfg.TryGetPropertyValue(name, out var node) && node is JsonValue jv && jv.TryGetValue(out string? s) ? s ?? string.Empty : string.Empty; - - var subscriptionId = Get("subscriptionId"); - var tenantId = Get("tenantId"); - var resourceGroup = Get("resourceGroup"); - var planName = Get("appServicePlanName"); - var webAppName = Get("webAppName"); - var location = Get("location"); - var planSku = Get("appServicePlanSku"); - if (string.IsNullOrWhiteSpace(planSku)) planSku = ConfigConstants.DefaultAppServicePlanSku; - - var deploymentProjectPath = Get("deploymentProjectPath"); - - bool needDeployment = CheckNeedDeployment(cfg); - var skipInfra = blueprintOnly || !needDeployment; - var externalHosting = !needDeployment && !blueprintOnly; - - 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)) - { - platform = _platformDetector.Detect(deploymentProjectPath); - _logger.LogInformation("Detected project platform: {Platform}", platform); - } - else - { - _logger.LogWarning("No deploymentProjectPath specified, defaulting to .NET runtime"); - } - - _logger.LogInformation("Agent 365 Setup - Starting..."); - _logger.LogInformation("Subscription: {Sub}", subscriptionId); - _logger.LogInformation("Resource Group: {RG}", resourceGroup); - _logger.LogInformation("App Service Plan: {Plan}", planName); - _logger.LogInformation("Web App: {App}", webAppName); - _logger.LogInformation("Location: {Loc}", location); - _logger.LogInformation(""); - - // ======================================================================== - // Phase 0: Ensure Azure CLI is logged in with proper scope - // ======================================================================== - if (!skipInfra) - { - _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) - { - _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); - } - 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) - { - _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 verified successfully"); - } - - _logger.LogInformation(""); - } - else - { - _logger.LogInformation("==> [0/5] Skipping Azure management authentication (blueprint-only or External hosting)"); - } - - // ======================================================================== - // Phase 1: Deploy Agent runtime (App Service) + System-assigned Managed Identity - // ======================================================================== - string? principalId = null; - JsonObject generatedConfig = new JsonObject(); - - if (skipInfra) - { - // 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 - if (File.Exists(generatedConfigPath)) - { - try - { - generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath, cancellationToken))?.AsObject() ?? new JsonObject(); - - if (blueprintOnly && generatedConfig.TryGetPropertyValue("managedIdentityPrincipalId", out var existingPrincipalId)) - { - // Only reuse MSI in blueprint-only mode - principalId = existingPrincipalId?.GetValue(); - _logger.LogInformation("Found existing Managed Identity Principal ID: {Id}", principalId ?? "(none)"); - } - else if (externalHosting) - { - _logger.LogInformation("External hosting selected - Managed Identity will NOT be used."); - - // Make sure we don't create FIC later - principalId = null; - } - - _logger.LogInformation("Existing configuration loaded successfully"); - } - catch (Exception ex) - { - _logger.LogWarning("Could not load existing config: {Message}. Starting fresh.", ex.Message); - } - } - else - { - _logger.LogInformation("No existing configuration found - blueprint will be created without managed identity"); - } - - _logger.LogInformation(""); - } - else - { - _logger.LogInformation("==> [1/5] Deploying App Service + enabling Managed Identity"); - - // Set subscription context - try - { - await _executor.ExecuteAsync("az", $"account set --subscription {subscriptionId}"); - } - catch (Exception) - { - _logger.LogWarning("Failed to set az subscription context explicitly"); - } - - // Resource group - var rgExists = await _executor.ExecuteAsync("az", $"group exists -n {resourceGroup} --subscription {subscriptionId}", captureOutput: true); - if (rgExists.Success && rgExists.StandardOutput.Trim().Equals("true", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Resource group already exists: {RG} (skipping creation)", resourceGroup); - } - else - { - _logger.LogInformation("Creating resource group {RG}", resourceGroup); - await AzWarnAsync($"group create -n {resourceGroup} -l {location} --subscription {subscriptionId}", "Create resource group"); - } - - // App Service plan - var planShow = await _executor.ExecuteAsync("az", $"appservice plan show -g {resourceGroup} -n {planName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); - if (planShow.Success) - { - _logger.LogInformation("App Service plan already exists: {Plan} (skipping creation)", planName); - } - else - { - _logger.LogInformation("Creating App Service plan {Plan}", planName); - await AzWarnAsync($"appservice plan create -g {resourceGroup} -n {planName} --sku {planSku} --is-linux --subscription {subscriptionId}", "Create App Service plan"); - } - - // Web App - var webShow = await _executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true); - if (!webShow.Success) - { - var runtime = GetRuntimeForPlatform(platform); - _logger.LogInformation("Creating web app {App} with runtime {Runtime}", webAppName, runtime); - var createResult = await _executor.ExecuteAsync("az", $"webapp create -g {resourceGroup} -p {planName} -n {webAppName} --runtime \"{runtime}\" --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); - if (!createResult.Success) - { - if (createResult.StandardError.Contains("AuthorizationFailed", StringComparison.OrdinalIgnoreCase)) - { - throw new AzureResourceException("WebApp", webAppName, createResult.StandardError, true); - } - else - { - _logger.LogError("ERROR: Web app creation failed: {Err}", createResult.StandardError); - throw new InvalidOperationException($"Failed to create web app '{webAppName}'. Setup cannot continue."); - } - } - } - else - { - var linuxFxVersion = GetLinuxFxVersionForPlatform(platform); - _logger.LogInformation("Web app already exists: {App} (skipping creation)", webAppName); - _logger.LogInformation("Configuring web app to use {Platform} runtime ({LinuxFxVersion})...", platform, linuxFxVersion); - await AzWarnAsync($"webapp config set -g {resourceGroup} -n {webAppName} --linux-fx-version \"{linuxFxVersion}\" --subscription {subscriptionId}", "Configure runtime"); - } - - // Verify web app - var verifyResult = await _executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); - if (!verifyResult.Success) - { - _logger.LogWarning("WARNING: Unable to verify web app via az webapp show."); - } - else - { - _logger.LogInformation("Verified web app presence."); - } - - // Managed Identity - _logger.LogInformation("Assigning (or confirming) system-assigned managed identity"); - var identity = await _executor.ExecuteAsync("az", $"webapp identity assign -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}"); - if (identity.Success) - { - try - { - var json = JsonDocument.Parse(identity.StandardOutput); - principalId = json.RootElement.GetProperty("principalId").GetString(); - if (!string.IsNullOrEmpty(principalId)) - { - _logger.LogInformation("Managed Identity principalId: {Id}", principalId); - } - } - catch - { - // ignore parse error - } - } - else if (identity.StandardError.Contains("already has a managed identity", StringComparison.OrdinalIgnoreCase) || - identity.StandardError.Contains("Conflict", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Managed identity already assigned (ignoring conflict)."); - } - else - { - _logger.LogWarning("WARNING: identity assign returned error: {Err}", identity.StandardError.Trim()); - } - - // Load or create generated config - if (File.Exists(generatedConfigPath)) - { - try - { - generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath, cancellationToken))?.AsObject() ?? new JsonObject(); - } - catch - { - _logger.LogWarning("Could not parse existing generated config, starting fresh"); - } - } - - if (!string.IsNullOrWhiteSpace(principalId)) - { - generatedConfig["managedIdentityPrincipalId"] = principalId; - await File.WriteAllTextAsync(generatedConfigPath, generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken); - _logger.LogInformation("Generated config updated with MSI principalId: {Id}", principalId); - } - - _logger.LogInformation("Waiting 10 seconds to ensure Service Principal is fully propagated..."); - await Task.Delay(10000, cancellationToken); - - } // End of !blueprintOnly block - - // ======================================================================== - // Phase 2: Agent Application (Blueprint) + Consent - // ======================================================================== - _logger.LogInformation(""); - _logger.LogInformation("==> [2/5] Creating Agent Blueprint"); - - // CRITICAL: Grant AgentApplication.Create permission BEFORE creating blueprint - // This replaces the PowerShell call to DelegatedAgentApplicationCreateConsent.ps1 - _logger.LogInformation(""); - _logger.LogInformation("==> [2.1/5] Ensuring AgentApplication.Create Permission"); - _logger.LogInformation("This permission is required to create Agent Blueprints"); - - var consentResult = await EnsureDelegatedConsentWithRetriesAsync(tenantId, cancellationToken); - if (!consentResult) - { - _logger.LogError("Failed to ensure AgentApplication.Create permission after multiple attempts"); - return false; - } - - _logger.LogInformation(""); - _logger.LogInformation("==> [2.2/5] Creating Agent Blueprint Application"); - - // Get required configuration values - var agentBlueprintDisplayName = Get("agentBlueprintDisplayName"); - var agentIdentityDisplayName = Get("agentIdentityDisplayName"); - - if (string.IsNullOrWhiteSpace(agentBlueprintDisplayName)) - { - _logger.LogError("agentBlueprintDisplayName missing in configuration"); - return false; - } - - try - { - // Validate that needDeployment and blueprintOnly are not both true - if (needDeployment && blueprintOnly) - { - _logger.LogError("Invalid configuration: both needDeployment and blueprintOnly are true. This is not supported, as it may result in attempting to use a managed identity that was not created."); - return false; - } - - var useManagedIdentity = (needDeployment && !blueprintOnly) || blueprintOnly; - - // Create the agent blueprint using Graph API directly (no PowerShell) - var blueprintResult = await CreateAgentBlueprintAsync( - tenantId, - agentBlueprintDisplayName, - agentIdentityDisplayName, - principalId, - useManagedIdentity, - generatedConfig, - cfg, - cancellationToken); - - if (!blueprintResult.success) - { - throw new InvalidOperationException("Failed to create agent blueprint"); - } - - var blueprintAppId = blueprintResult.appId; - var blueprintObjectId = blueprintResult.objectId; - - _logger.LogInformation("Agent Blueprint Details:"); - _logger.LogInformation(" - Display Name: {Name}", agentBlueprintDisplayName); - _logger.LogInformation(" - App ID: {Id}", blueprintAppId); - _logger.LogInformation(" - Object ID: {Id}", blueprintObjectId); - _logger.LogInformation(" - Identifier URI: api://{Id}", blueprintAppId); - - // Convert to camelCase and save - var camelCaseConfig = new JsonObject - { - ["managedIdentityPrincipalId"] = generatedConfig["managedIdentityPrincipalId"]?.DeepClone(), - ["agentBlueprintId"] = blueprintAppId, - ["agentBlueprintObjectId"] = blueprintObjectId, - ["displayName"] = agentBlueprintDisplayName, - ["servicePrincipalId"] = blueprintResult.servicePrincipalId, - ["identifierUri"] = $"api://{blueprintAppId}", - ["tenantId"] = tenantId - }; - - await File.WriteAllTextAsync(generatedConfigPath, camelCaseConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken); - generatedConfig = camelCaseConfig; - - // ======================================================================== - // Phase 2.5: Create Client Secret for Agent Blueprint - // ======================================================================== - _logger.LogInformation(""); - _logger.LogInformation("==> [2.5/5] Creating Client Secret for Agent Blueprint"); - - await CreateBlueprintClientSecretAsync(blueprintObjectId!, blueprintAppId!, generatedConfig, generatedConfigPath, cancellationToken); - - } - catch (Exception ex) when (ex is not Agent365Exception) - { - _logger.LogError(ex, "Failed to create agent blueprint: {Message}", ex.Message); - return false; - } - - // ==================================== - // Phase 3: MCP Server API Permissions - // ==================================== - _logger.LogInformation(""); - _logger.LogInformation("==> [3/5] Adding MCP Server API Permissions to Blueprint"); - - var blueprintAppIdForMcp = generatedConfig["agentBlueprintId"]?.GetValue(); - var blueprintObjectIdForMcp = generatedConfig["agentBlueprintObjectId"]?.GetValue(); - - if (!string.IsNullOrWhiteSpace(blueprintAppIdForMcp) && !string.IsNullOrWhiteSpace(blueprintObjectIdForMcp)) - { - await ConfigureMcpServerPermissionsAsync(cfg, generatedConfig, blueprintAppIdForMcp!, blueprintObjectIdForMcp!, tenantId, cancellationToken); - } - - // ======================================================================== - // Phase 4: Configure Inheritable Permissions (matching PowerShell Step 6) - // ======================================================================== - _logger.LogInformation(""); - _logger.LogInformation("==> [4/5] Configuring Inheritable Permissions for Agent Identities"); - - if (!string.IsNullOrWhiteSpace(blueprintObjectIdForMcp)) - { - await ConfigureInheritablePermissionsAsync(tenantId, generatedConfig, cfg, cancellationToken); - } - else - { - _logger.LogWarning("Blueprint Object ID not available, skipping inheritable permissions configuration"); - } - - // ======================================================================== - // Phase 5: Finalization - // ======================================================================== - _logger.LogInformation(""); - _logger.LogInformation("==> [5/5] Finalizing Setup"); - - generatedConfig["completed"] = true; - generatedConfig["completedAt"] = DateTime.UtcNow.ToString("o"); - await File.WriteAllTextAsync(generatedConfigPath, generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken); - - _logger.LogDebug("Generated config saved at: {Path}", generatedConfigPath); - _logger.LogInformation(""); - _logger.LogInformation("Blueprint installation completed successfully"); - _logger.LogInformation("Blueprint ID: {BlueprintId}", generatedConfig["agentBlueprintId"]?.GetValue()); - _logger.LogInformation("Identifier URI: api://{AppId}", generatedConfig["agentBlueprintId"]?.GetValue()); - - return true; - } - - /// - /// Create Agent Blueprint using Microsoft Graph API - /// - /// IMPORTANT: This requires interactive authentication with Application.ReadWrite.All permission. - /// Uses the same authentication flow as Connect-MgGraph in PowerShell. - /// - private async Task<(bool success, string? appId, string? objectId, string? servicePrincipalId)> CreateAgentBlueprintAsync( - string tenantId, - string displayName, - string? agentIdentityDisplayName, - string? managedIdentityPrincipalId, - bool useManagedIdentity, - JsonObject generatedConfig, - JsonObject setupConfig, - CancellationToken ct) - { - try - { - _logger.LogInformation("Creating Agent Blueprint using Microsoft Graph SDK..."); - - GraphServiceClient graphClient; - try - { - graphClient = await GetAuthenticatedGraphClientAsync(tenantId, ct); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to get authenticated Graph client: {Message}", ex.Message); - return (false, null, null, null); - } - - // Get current user for sponsors field (mimics PowerShell script behavior) - string? sponsorUserId = null; - try - { - var me = await graphClient.Me.GetAsync(cancellationToken: ct); - if (me != null && !string.IsNullOrEmpty(me.Id)) - { - sponsorUserId = me.Id; - _logger.LogInformation("Current user: {DisplayName} <{UPN}>", me.DisplayName, me.UserPrincipalName); - _logger.LogInformation("Sponsor: https://graph.microsoft.com/v1.0/users/{UserId}", sponsorUserId); - } - } - catch (Exception ex) - { - _logger.LogWarning("Could not retrieve current user for sponsors field: {Message}", ex.Message); - } - - // Define the application manifest with @odata.type for Agent Identity Blueprint - var appManifest = new JsonObject - { - ["@odata.type"] = "Microsoft.Graph.AgentIdentityBlueprint", // CRITICAL: Required for Agent Blueprint type - ["displayName"] = displayName, - ["signInAudience"] = "AzureADMultipleOrgs" // Multi-tenant - }; - - // Add sponsors field if we have the current user (PowerShell script includes this) - if (!string.IsNullOrEmpty(sponsorUserId)) - { - appManifest["sponsors@odata.bind"] = new JsonArray - { - $"https://graph.microsoft.com/v1.0/users/{sponsorUserId}" - }; - } - - // Create the application using Microsoft Graph SDK - using var httpClient = new HttpClient(); - var graphToken = await GetTokenFromGraphClient(graphClient, tenantId); - if (string.IsNullOrEmpty(graphToken)) - { - _logger.LogError("Failed to extract access token from Graph client"); - return (false, null, null, null); - } - - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); - httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); - httpClient.DefaultRequestHeaders.Add("OData-Version", "4.0"); // Required for @odata.type - - var createAppUrl = "https://graph.microsoft.com/beta/applications"; - - _logger.LogInformation("Creating Agent Blueprint application..."); - _logger.LogInformation(" - Display Name: {DisplayName}", displayName); - if (!string.IsNullOrEmpty(sponsorUserId)) - { - _logger.LogInformation(" - Sponsor: User ID {UserId}", sponsorUserId); - } - - var appResponse = await httpClient.PostAsync( - createAppUrl, - new StringContent(appManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - - if (!appResponse.IsSuccessStatusCode) - { - var errorContent = await appResponse.Content.ReadAsStringAsync(ct); - - // If sponsors field causes error (Bad Request 400), retry without it - if (appResponse.StatusCode == System.Net.HttpStatusCode.BadRequest && - !string.IsNullOrEmpty(sponsorUserId)) - { - _logger.LogWarning("Agent Blueprint creation with sponsors failed (Bad Request). Retrying without sponsors..."); - - // Remove sponsors field and retry - appManifest.Remove("sponsors@odata.bind"); - - appResponse = await httpClient.PostAsync( - createAppUrl, - new StringContent(appManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - - if (!appResponse.IsSuccessStatusCode) - { - errorContent = await appResponse.Content.ReadAsStringAsync(ct); - _logger.LogError("Failed to create application (fallback): {Status} - {Error}", appResponse.StatusCode, errorContent); - return (false, null, null, null); - } - } - else - { - _logger.LogError("Failed to create application: {Status} - {Error}", appResponse.StatusCode, errorContent); - return (false, null, null, null); - } - } - - var appJson = await appResponse.Content.ReadAsStringAsync(ct); - var app = JsonNode.Parse(appJson)!.AsObject(); - var appId = app["appId"]!.GetValue(); - var objectId = app["id"]!.GetValue(); - - _logger.LogInformation("Application created successfully"); - _logger.LogInformation(" - App ID: {AppId}", appId); - _logger.LogInformation(" - Object ID: {ObjectId}", objectId); - - // Wait for application propagation - const int maxRetries = 30; - const int delayMs = 4000; - bool appAvailable = false; - for (int i = 0; i < maxRetries; i++) - { - var checkResp = await httpClient.GetAsync($"https://graph.microsoft.com/v1.0/applications/{objectId}", ct); - if (checkResp.IsSuccessStatusCode) - { - appAvailable = true; - break; - } - _logger.LogInformation("Waiting for application object to be available in directory (attempt {Attempt}/{Max})...", i + 1, maxRetries); - await Task.Delay(delayMs, ct); - } - - if (!appAvailable) - { - _logger.LogError("App object not available after creation. Aborting setup."); - return (false, null, null, null); - } - - // Update application with identifier URI - var identifierUri = $"api://{appId}"; - var patchAppUrl = $"https://graph.microsoft.com/v1.0/applications/{objectId}"; - var patchBody = new JsonObject - { - ["identifierUris"] = new JsonArray { identifierUri } - }; - - var patchResponse = await httpClient.PatchAsync( - patchAppUrl, - new StringContent(patchBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - - if (!patchResponse.IsSuccessStatusCode) - { - var patchError = await patchResponse.Content.ReadAsStringAsync(ct); - _logger.LogInformation("Waiting for application propagation before setting identifier URI..."); - _logger.LogDebug("Identifier URI update deferred (propagation delay): {Error}", patchError); - } - else - { - _logger.LogInformation("Identifier URI set to: {Uri}", identifierUri); - } - - // Create service principal - _logger.LogInformation("Creating service principal..."); - - var spManifest = new JsonObject - { - ["appId"] = appId - }; - - var createSpUrl = "https://graph.microsoft.com/v1.0/servicePrincipals"; - var spResponse = await httpClient.PostAsync( - createSpUrl, - new StringContent(spManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - - string? servicePrincipalId = null; - if (spResponse.IsSuccessStatusCode) - { - var spJson = await spResponse.Content.ReadAsStringAsync(ct); - var sp = JsonNode.Parse(spJson)!.AsObject(); - servicePrincipalId = sp["id"]!.GetValue(); - _logger.LogInformation("Service principal created: {SpId}", servicePrincipalId); - } - else - { - var spError = await spResponse.Content.ReadAsStringAsync(ct); - _logger.LogInformation("Waiting for application propagation before creating service principal..."); - _logger.LogDebug("Service principal creation deferred (propagation delay): {Error}", spError); - } - - // Wait for service principal propagation - _logger.LogInformation("Waiting 10 seconds to ensure Service Principal is fully propagated..."); - await Task.Delay(10000, ct); - - // Create Federated Identity Credential ONLY when MSI is relevant (if managed identity provided) - if (useManagedIdentity && !string.IsNullOrWhiteSpace(managedIdentityPrincipalId)) - { - _logger.LogInformation("Creating Federated Identity Credential for Managed Identity..."); - var credentialName = $"{displayName.Replace(" ", "")}-MSI"; - - var ficSuccess = await CreateFederatedIdentityCredentialAsync( - tenantId, - objectId, - credentialName, - managedIdentityPrincipalId, - graphToken, - ct); - - if (ficSuccess) - { - _logger.LogInformation("Federated Identity Credential created successfully"); - } - else - { - _logger.LogWarning("Failed to create Federated Identity Credential"); - } - } - else if (!useManagedIdentity) - { - _logger.LogInformation("Skipping Federated Identity Credential creation (external hosting / no MSI configured)"); - } - else - { - _logger.LogInformation("Skipping Federated Identity Credential creation (no MSI Principal ID provided)"); - } - - // Request admin consent - _logger.LogInformation("Requesting admin consent for application"); - - // Get application scopes from config (fallback to hardcoded defaults) - var applicationScopes = new List(); - if (setupConfig.TryGetPropertyValue("agentApplicationScopes", out var appScopesNode) && - appScopesNode is JsonArray appScopesArr) - { - _logger.LogInformation(" Found 'agentApplicationScopes' in config"); - foreach (var scopeItem in appScopesArr) - { - var scope = scopeItem?.GetValue(); - if (!string.IsNullOrWhiteSpace(scope)) - { - applicationScopes.Add(scope); - } - } - } - else - { - _logger.LogInformation(" 'agentApplicationScopes' not found in config, using hardcoded defaults"); - applicationScopes.AddRange(ConfigConstants.DefaultAgentApplicationScopes); - } - - // Final fallback (should not happen with proper defaults) - if (applicationScopes.Count == 0) - { - _logger.LogWarning("No application scopes available, falling back to User.Read"); - applicationScopes.Add("User.Read"); - } - - _logger.LogInformation(" - Application scopes: {Scopes}", string.Join(", ", applicationScopes)); - - // Generate consent URLs for Graph and Connectivity - var applicationScopesJoined = string.Join(' ', applicationScopes); - var consentUrlGraph = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={appId}&scope={Uri.EscapeDataString(applicationScopesJoined)}&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123"; - var consentUrlConnectivity = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={appId}&scope=0ddb742a-e7dc-4899-a31e-80e797ec7144/Connectivity.Connections.Read&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123"; - - _logger.LogInformation("Opening browser for Graph API admin consent..."); - TryOpenBrowser(consentUrlGraph); - - var consent1Success = await AdminConsentHelper.PollAdminConsentAsync(_executor, _logger, appId, "Graph API Scopes", 180, 5, ct); - - if (consent1Success) - { - _logger.LogInformation("Graph API admin consent granted successfully!"); - } - else - { - _logger.LogWarning("Graph API admin consent may not have completed"); - } - - _logger.LogInformation(""); - _logger.LogInformation("Opening browser for Connectivity admin consent..."); - TryOpenBrowser(consentUrlConnectivity); - - var consent2Success = await AdminConsentHelper.PollAdminConsentAsync(_executor, _logger, appId, "Connectivity Scope", 180, 5, ct); - - if (consent2Success) - { - _logger.LogInformation("Connectivity admin consent granted successfully!"); - } - else - { - _logger.LogWarning("Connectivity admin consent may not have completed"); - } - - // Save consent URLs and status to generated config - generatedConfig["consentUrlGraph"] = consentUrlGraph; - generatedConfig["consentUrlConnectivity"] = consentUrlConnectivity; - generatedConfig["consent1Granted"] = consent1Success; - generatedConfig["consent2Granted"] = consent2Success; - - if (!consent1Success || !consent2Success) - { - _logger.LogWarning(""); - _logger.LogWarning("One or more consents may not have been detected"); - _logger.LogWarning("The setup will continue, but you may need to grant consent manually."); - _logger.LogWarning("Consent URL (Graph): {Url}", consentUrlGraph); - _logger.LogWarning("Consent URL (Connectivity): {Url}", consentUrlConnectivity); - } - - return (true, appId, objectId, servicePrincipalId); - } - catch (Exception ex) when (ex is not Agent365Exception) - { - _logger.LogError(ex, "Failed to create agent blueprint: {Message}", ex.Message); - return (false, null, null, null); - } - } - - /// - /// Create Federated Identity Credential to link managed identity to blueprint - /// Equivalent to createFederatedIdentityCredential function in PowerShell - /// Implements retry logic to handle Azure AD propagation delays - /// - private async Task CreateFederatedIdentityCredentialAsync( - string tenantId, - string blueprintObjectId, - string credentialName, - string msiPrincipalId, - string graphToken, - CancellationToken ct) - { - const int maxRetries = 5; - const int initialDelayMs = 2000; // Start with 2 seconds - - try - { - if (string.IsNullOrWhiteSpace(graphToken)) - { - _logger.LogError("Graph token is required for FIC creation"); - return false; - } - - var federatedCredential = new JsonObject - { - ["name"] = credentialName, - ["issuer"] = $"https://login.microsoftonline.com/{tenantId}/v2.0", - ["subject"] = msiPrincipalId, - ["audiences"] = new JsonArray { "api://AzureADTokenExchange" } - }; - - using var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); - httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); - - // Try standard endpoint first, then fallback to Agent Blueprint-specific path - var urls = new[] - { - $"https://graph.microsoft.com/beta/applications/{blueprintObjectId}/federatedIdentityCredentials", - $"https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/federatedIdentityCredentials" - }; - - string? lastError = null; - - foreach (var url in urls) - { - // Retry loop to handle propagation delays - for (int attempt = 1; attempt <= maxRetries; attempt++) - { - var response = await httpClient.PostAsync( - url, - new StringContent(federatedCredential.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - - if (response.IsSuccessStatusCode) - { - _logger.LogInformation(" - Credential Name: {Name}", credentialName); - _logger.LogInformation(" - Issuer: https://login.microsoftonline.com/{TenantId}/v2.0", tenantId); - _logger.LogInformation(" - Subject (MSI Principal ID): {MsiId}", msiPrincipalId); - return true; - } - - var error = await response.Content.ReadAsStringAsync(ct); - lastError = error; - - // Check if it's a propagation issue (resource not found) - if ((error.Contains("Request_ResourceNotFound") || error.Contains("does not exist")) && attempt < maxRetries) - { - var delayMs = initialDelayMs * (int)Math.Pow(2, attempt - 1); // Exponential backoff - _logger.LogWarning("Application object not yet propagated (attempt {Attempt}/{MaxRetries}). Retrying in {Delay}ms...", - attempt, maxRetries, delayMs); - await Task.Delay(delayMs, ct); - continue; - } - - // Check if it's an Agent Blueprint API version error - try fallback URL - if (error.Contains("Agent Blueprints are not supported on the API version")) - { - _logger.LogDebug("Standard endpoint not supported, trying Agent Blueprint-specific path..."); - break; // Break retry loop to try next URL - } - - // Other error - break retry loop to try next URL - _logger.LogDebug("FIC creation failed with error: {Error}", error); - break; - } - } - - // All attempts failed - _logger.LogDebug("Failed to create federated identity credential after trying all endpoints (may not be supported for Agent Blueprints yet): {Error}", lastError); - return false; - } - catch (Exception ex) - { - _logger.LogError(ex, "Exception creating federated identity credential: {Message}", ex.Message); - return false; - } - } - - /// - /// Configure MCP server API permissions (Step 6.5 from PowerShell script). - /// This was missing in the original C# implementation. - /// - private async Task ConfigureMcpServerPermissionsAsync( - JsonObject setupConfig, - JsonObject generatedConfig, - string blueprintAppId, - string blueprintObjectId, - string tenantId, - CancellationToken ct) - { - try - { - // Read ToolingManifest.json - string? toolingManifestPath = null; - var deploymentProjectPath = setupConfig["deploymentProjectPath"]?.GetValue(); - - if (!string.IsNullOrWhiteSpace(deploymentProjectPath)) - { - toolingManifestPath = Path.Combine(deploymentProjectPath, "ToolingManifest.json"); - _logger.LogInformation("Looking for ToolingManifest.json in deployment project path: {Path}", toolingManifestPath); - } - else - { - var scriptDir = Path.GetDirectoryName(Path.GetFullPath(setupConfig.ToJsonString())) ?? Environment.CurrentDirectory; - toolingManifestPath = Path.Combine(scriptDir, "ToolingManifest.json"); - _logger.LogInformation("Looking for ToolingManifest.json in script directory: {Path}", toolingManifestPath); - } - - if (!File.Exists(toolingManifestPath)) - { - _logger.LogInformation("ToolingManifest.json not found - skipping MCP API permissions"); - return; - } - - var manifest = JsonNode.Parse(await File.ReadAllTextAsync(toolingManifestPath, ct))!.AsObject(); - - if (!manifest.TryGetPropertyValue("mcpServers", out var serversNode) || serversNode is not JsonArray servers || servers.Count == 0) - { - _logger.LogInformation("No MCP servers found in ToolingManifest.json"); - return; - } - - var audienceGroups = new Dictionary>(); - - // Group servers by audience - foreach (var server in servers) - { - var serverObj = server?.AsObject(); - if (serverObj == null) continue; - - var scope = serverObj["scope"]?.GetValue(); - var audience = serverObj["audience"]?.GetValue(); - - if (string.IsNullOrWhiteSpace(scope) || string.IsNullOrWhiteSpace(audience)) - continue; - - // Extract app ID from audience (remove "api://" prefix) - var mcpAppId = audience.Replace("api://", ""); - - // Validate GUID format - if (!Guid.TryParse(mcpAppId, out _)) - { - _logger.LogWarning("Skipping MCP server - invalid audience format: {Audience} (not a valid App ID)", audience); - continue; - } - - if (!audienceGroups.ContainsKey(mcpAppId)) - { - audienceGroups[mcpAppId] = new List(); - } - - if (!audienceGroups[mcpAppId].Contains(scope)) - { - audienceGroups[mcpAppId].Add(scope); - } - - _logger.LogInformation(" Found MCP scope: {Scope} for audience: {Audience}", scope, audience); - } - - if (audienceGroups.Count == 0) - { - _logger.LogInformation(" No MCP API permissions found to add"); - return; - } - - // Note: Agentic Applications don't support RequiredResourceAccess property - // Skip updating the application with MCP API permissions, but still request admin consent - _logger.LogInformation(" Skipping MCP API permissions update (not supported for Agentic Applications)"); - _logger.LogInformation(" Will request admin consent directly for MCP scopes"); - - // Build consent URL for all MCP scopes - var mcpConsentScopes = new List(); - foreach (var (appId, scopes) in audienceGroups) - { - foreach (var scope in scopes) - { - mcpConsentScopes.Add($"{appId}/{scope}"); - } - } - - if (mcpConsentScopes.Count > 0) - { - var scopesJoined = string.Join(' ', mcpConsentScopes); - var consentUrlMcp = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={blueprintAppId}&scope={Uri.EscapeDataString(scopesJoined)}&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123"; - - _logger.LogInformation(" Opening browser for MCP server admin consent..."); - TryOpenBrowser(consentUrlMcp); - - var consentMcpSuccess = await AdminConsentHelper.PollAdminConsentAsync(_executor, _logger, blueprintAppId, "MCP Server Scopes", 180, 5, ct); - - if (consentMcpSuccess) - { - _logger.LogInformation(" MCP server admin consent granted successfully!"); - } - else - { - _logger.LogWarning(" WARNING: MCP server admin consent may not have completed"); - } - - generatedConfig["agentIdentityConsentUrlMcp"] = consentUrlMcp; - generatedConfig["consentMcpGranted"] = consentMcpSuccess; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "WARNING: Failed to add MCP API permissions: {Message}", ex.Message); - _logger.LogInformation(" Continuing with Blueprint setup..."); - } - } - - /// - /// Create a client secret for the Agent Blueprint using Microsoft Graph API. - /// The secret is encrypted using DPAPI on Windows before storage. - /// - private async Task CreateBlueprintClientSecretAsync( - string blueprintObjectId, - string blueprintAppId, - JsonObject generatedConfig, - string generatedConfigPath, - CancellationToken ct) - { - try - { - _logger.LogInformation("Creating client secret for Agent Blueprint using Graph API..."); - - // Get Graph access token - var graphToken = await _graphService.GetGraphAccessTokenAsync(generatedConfig["tenantId"]?.GetValue() ?? string.Empty, ct); - - if (string.IsNullOrWhiteSpace(graphToken)) - { - _logger.LogError("Failed to acquire Graph API access token"); - throw new InvalidOperationException("Cannot create client secret without Graph API token"); - } - - using var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); - - // Create password credential (client secret) - var secretBody = new JsonObject - { - ["passwordCredential"] = new JsonObject - { - ["displayName"] = "Agent 365 CLI Generated Secret", - ["endDateTime"] = DateTime.UtcNow.AddYears(2).ToString("yyyy-MM-ddTHH:mm:ss.fffZ") - } - }; - - var addPasswordUrl = $"https://graph.microsoft.com/v1.0/applications/{blueprintObjectId}/addPassword"; - var passwordResponse = await httpClient.PostAsync( - addPasswordUrl, - new StringContent(secretBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - - if (!passwordResponse.IsSuccessStatusCode) - { - var errorContent = await passwordResponse.Content.ReadAsStringAsync(ct); - _logger.LogError("Failed to create client secret: {Status} - {Error}", passwordResponse.StatusCode, errorContent); - throw new InvalidOperationException($"Failed to create client secret: {errorContent}"); - } - - var passwordJson = await passwordResponse.Content.ReadAsStringAsync(ct); - var passwordResult = JsonNode.Parse(passwordJson)!.AsObject(); - - // Extract and immediately encrypt the secret (no plaintext variable) - var secretTextNode = passwordResult["secretText"]; - if (secretTextNode == null || string.IsNullOrWhiteSpace(secretTextNode.GetValue())) - { - _logger.LogError("Client secret text is empty in response"); - throw new InvalidOperationException("Client secret creation returned empty secret"); - } - - // Encrypt immediately without intermediate plaintext storage - var protectedSecret = ProtectSecret(secretTextNode.GetValue()); - - // Store the encrypted client secret in generated config using camelCase - generatedConfig["agentBlueprintClientSecret"] = protectedSecret; - generatedConfig["agentBlueprintClientSecretProtected"] = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - - await File.WriteAllTextAsync( - generatedConfigPath, - generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), - ct); - - _logger.LogInformation("Client secret created successfully!"); - _logger.LogInformation(" - Secret stored in generated config (encrypted: {IsProtected})", RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); - _logger.LogWarning("IMPORTANT: The client secret has been stored in {Path}", generatedConfigPath); - _logger.LogWarning("Keep this file secure and do not commit it to source control!"); - - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - _logger.LogWarning("WARNING: Secret encryption is only available on Windows. The secret is stored in plaintext."); - _logger.LogWarning("Consider using environment variables or Azure Key Vault for production deployments."); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to create client secret: {Message}", ex.Message); - _logger.LogInformation("You can create a client secret manually:"); - _logger.LogInformation(" 1. Go to Azure Portal > App Registrations"); - _logger.LogInformation(" 2. Find your Agent Blueprint: {AppId}", blueprintAppId); - _logger.LogInformation(" 3. Navigate to Certificates & secrets > Client secrets"); - _logger.LogInformation(" 4. Click 'New client secret' and save the value"); - _logger.LogInformation(" 5. Add it to {Path} as 'agentBlueprintClientSecret'", generatedConfigPath); - } - } - - /// - /// Protects (encrypts) a secret string using DPAPI on Windows. - /// On non-Windows platforms, returns the plaintext with a warning. - /// - /// The secret to protect - /// Base64-encoded encrypted secret on Windows, plaintext on other platforms - private string ProtectSecret(string plaintext) - { - if (string.IsNullOrWhiteSpace(plaintext)) - { - return plaintext; - } - - try - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Use Windows DPAPI to encrypt the secret - var plaintextBytes = System.Text.Encoding.UTF8.GetBytes(plaintext); - var protectedBytes = ProtectedData.Protect( - plaintextBytes, - optionalEntropy: null, - scope: DataProtectionScope.CurrentUser); - - // Return as base64-encoded string - return Convert.ToBase64String(protectedBytes); - } - else - { - // On non-Windows platforms, we cannot use DPAPI - // Return plaintext and rely on file system permissions - _logger.LogWarning("DPAPI encryption not available on this platform. Secret will be stored in plaintext."); - return plaintext; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to encrypt secret, storing in plaintext: {Message}", ex.Message); - return plaintext; - } - } - - private async Task AzWarnAsync(string args, string description) - { - var result = await _executor.ExecuteAsync("az", args, suppressErrorLogging: true); - if (!result.Success) - { - if (result.StandardError.Contains("already exists", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("{Description} already exists (skipping creation)", description); - } - else if (result.StandardError.Contains("AuthorizationFailed", StringComparison.OrdinalIgnoreCase)) - { - var exception = new AzureResourceException(description, string.Empty, result.StandardError, true); - ExceptionHandler.HandleAgent365Exception(exception); - } - else - { - _logger.LogWarning("az {Description} returned non-success (exit code {Code}). Error: {Err}", - description, result.ExitCode, Short(result.StandardError)); - } - } - } - - private void TryOpenBrowser(string url) - { - try - { - using var p = new System.Diagnostics.Process(); - p.StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = url, - UseShellExecute = true - }; - p.Start(); - } - catch - { - // non-fatal - } - } - - private async Task ConfigureInheritablePermissionsAsync( - string tenantId, - JsonObject generatedConfig, - JsonObject setupConfig, - CancellationToken ct) - { - // Get the App Object ID from generatedConfig - var blueprintObjectId = generatedConfig["agentBlueprintObjectId"]?.ToString(); - if (string.IsNullOrWhiteSpace(blueprintObjectId)) - { - _logger.LogError("Blueprint Object ID missing in generated config."); - throw new InvalidOperationException("Blueprint Object ID missing."); - } - - // TODO: Detect 1P vs 3P agent blueprint. For now, assume 1P. Replace with real detection logic if available. - bool is1p = true; // Placeholder: set to false for 3P, or add detection logic - - if (is1p) - { - // 1P: POST inheritable permissions to beta endpoint - GraphServiceClient graphClient; - try - { - graphClient = await GetAuthenticatedGraphClientAsync(tenantId, ct); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get authenticated Graph client."); - _logger.LogWarning("Authentication failed, skipping inheritable permissions configuration."); - _logger.LogWarning(""); - _logger.LogWarning("MANUAL CONFIGURATION REQUIRED:"); - _logger.LogWarning(" You need to configure inheritable permissions manually in Azure Portal."); - _logger.LogWarning(" {DocumentationMessage}", DocumentationMessage); - _logger.LogWarning(""); - - generatedConfig["inheritanceConfigured"] = false; - generatedConfig["inheritanceConfigError"] = "Authentication failed: " + ex.Message; - return; - } - - var graphToken = await GetTokenFromGraphClient(graphClient, tenantId); - if (string.IsNullOrWhiteSpace(graphToken)) - { - _logger.LogError("Failed to acquire Graph API access token"); - _logger.LogWarning("Skipping inheritable permissions configuration"); - _logger.LogWarning(""); - _logger.LogWarning("MANUAL CONFIGURATION REQUIRED:"); - _logger.LogWarning(" You need to configure inheritable permissions manually in Azure Portal."); - _logger.LogWarning(" {DocumentationMessage}", DocumentationMessage); - _logger.LogWarning(""); - - generatedConfig["inheritanceConfigured"] = false; - generatedConfig["inheritanceConfigError"] = "Failed to acquire Graph API access token"; - return; - } - - // Read scopes from a365.config.json - var inheritableScopes = ReadInheritableScopesFromConfig(setupConfig); - - if (inheritableScopes.Count == 0) - { - _logger.LogInformation("No inheritable scopes found in configuration, skipping inheritable permissions"); - return; - } - - _logger.LogInformation("Configuring inheritable permissions with {Count} scopes: {Scopes}", - inheritableScopes.Count, string.Join(", ", inheritableScopes)); - - using var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); - - // =================================================================== - // Step 1: Configure Microsoft Graph inheritable permissions - // =================================================================== - var graphUrl = $"https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/inheritablePermissions"; - - _logger.LogInformation("Configuring Graph inheritable permissions"); - _logger.LogDebug(" - Request URL: {Url}", graphUrl); - _logger.LogDebug(" - Blueprint Object ID: {ObjectId}", blueprintObjectId); - - // Convert scope list to JsonArray - var scopesArray = new JsonArray(); - foreach (var scope in inheritableScopes) - { - scopesArray.Add(scope); - } - - var graphBody = new JsonObject - { - ["resourceAppId"] = GraphResourceAppId, - ["inheritableScopes"] = new JsonObject - { - ["@odata.type"] = "microsoft.graph.enumeratedScopes", - ["scopes"] = scopesArray - } - }; - - _logger.LogDebug(" - Request body: {Body}", graphBody.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); - - var graphResponse = await httpClient.PostAsync( - graphUrl, - new StringContent(graphBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - - if (!graphResponse.IsSuccessStatusCode) - { - var error = await graphResponse.Content.ReadAsStringAsync(ct); - - bool isAlreadyConfigured = - (error.Contains("already exists", StringComparison.OrdinalIgnoreCase) || - error.Contains("duplicate", StringComparison.OrdinalIgnoreCase)) || - graphResponse.StatusCode == System.Net.HttpStatusCode.Conflict; - - if (isAlreadyConfigured) - { - _logger.LogInformation(" - Graph inheritable permissions already configured (idempotent)"); - } - else - { - // Check if this is an authorization error - bool isAuthorizationError = - error.Contains("Authorization_RequestDenied", StringComparison.OrdinalIgnoreCase) || - error.Contains("Insufficient privileges", StringComparison.OrdinalIgnoreCase) || - graphResponse.StatusCode == System.Net.HttpStatusCode.Forbidden || - graphResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized; - - if (isAuthorizationError) - { - _logger.LogError("Failed to configure Graph inheritable permissions: {Status} - {Error}", - graphResponse.StatusCode, error); - _logger.LogError(""); - _logger.LogError("=== INSUFFICIENT PERMISSIONS DETECTED ==="); - _logger.LogError(""); - _logger.LogError("The current user account does not have sufficient privileges to configure inheritable permissions."); - _logger.LogError(""); - foreach (var scope in inheritableScopes) - { - _logger.LogError(" - {Scope}", scope); - } - _logger.LogError(" 5. Click 'Grant admin consent'"); - _logger.LogError(""); - - generatedConfig["inheritanceConfigured"] = false; - generatedConfig["graphInheritanceError"] = error; - } - else - { - _logger.LogError("Failed to configure Graph inheritable permissions: {Status} - {Error}", - graphResponse.StatusCode, error); - generatedConfig["inheritanceConfigured"] = false; - generatedConfig["graphInheritanceError"] = error; - } - } - } - else - { - _logger.LogInformation("Successfully configured Graph inheritable permissions"); - _logger.LogInformation(" - Resource: Microsoft Graph"); - _logger.LogInformation(" - Scopes: {Scopes}", string.Join(", ", inheritableScopes)); - generatedConfig["graphInheritanceConfigured"] = true; - } - - // =================================================================== - // Step 2: Configure Connectivity inheritable permissions - // =================================================================== - var connectivityUrl = $"https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/inheritablePermissions"; - - _logger.LogInformation(""); - _logger.LogInformation("Configuring Connectivity inheritable permissions"); - _logger.LogDebug(" - Request URL: {Url}", connectivityUrl); - - var connectivityBody = new JsonObject - { - ["resourceAppId"] = ConnectivityResourceAppId, - ["inheritableScopes"] = new JsonObject - { - ["@odata.type"] = "microsoft.graph.enumeratedScopes", - ["scopes"] = new JsonArray { "Connectivity.Connections.Read" } - } - }; - - _logger.LogDebug(" - Request body: {Body}", connectivityBody.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); - - var connectivityResponse = await httpClient.PostAsync( - connectivityUrl, - new StringContent(connectivityBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - - if (!connectivityResponse.IsSuccessStatusCode) - { - var error = await connectivityResponse.Content.ReadAsStringAsync(ct); - - bool isAlreadyConfigured = - (error.Contains("already exists", StringComparison.OrdinalIgnoreCase) || - error.Contains("duplicate", StringComparison.OrdinalIgnoreCase)) || - connectivityResponse.StatusCode == System.Net.HttpStatusCode.Conflict; - - if (isAlreadyConfigured) - { - _logger.LogInformation(" - Connectivity inheritable permissions already configured (idempotent)"); - } - else - { - // Check if this is an authorization error - bool isAuthorizationError = - error.Contains("Authorization_RequestDenied", StringComparison.OrdinalIgnoreCase) || - error.Contains("Insufficient privileges", StringComparison.OrdinalIgnoreCase) || - connectivityResponse.StatusCode == System.Net.HttpStatusCode.Forbidden || - connectivityResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized; - - if (isAuthorizationError) - { - _logger.LogError("Failed to configure Connectivity inheritable permissions: {Status} - {Error}", - connectivityResponse.StatusCode, error); - _logger.LogError("See the troubleshooting steps above for resolving permission issues."); - generatedConfig["connectivityInheritanceError"] = error; - } - else - { - _logger.LogError("Failed to configure Connectivity inheritable permissions: {Status} - {Error}", - connectivityResponse.StatusCode, error); - generatedConfig["connectivityInheritanceError"] = error; - } - } - } - else - { - _logger.LogInformation("Successfully configured Connectivity inheritable permissions"); - _logger.LogInformation(" - Resource: Connectivity Service"); - _logger.LogInformation(" - Scope: Connectivity.Connections.Read"); - generatedConfig["connectivityInheritanceConfigured"] = true; - } - - // Set overall inheritance configured status - var bothSucceeded = - (generatedConfig["graphInheritanceConfigured"]?.GetValue() ?? false) && - (generatedConfig["connectivityInheritanceConfigured"]?.GetValue() ?? false); - - generatedConfig["inheritanceConfigured"] = bothSucceeded; - - if (!bothSucceeded) - { - _logger.LogWarning("One or more inheritable permissions failed to configure"); - _logger.LogWarning("You may need to configure these manually in Azure Portal"); - } - else - { - _logger.LogInformation(""); - _logger.LogInformation("All inheritable permissions configured successfully!"); - } - } - else - { - // 3P: Not supported yet - _logger.LogWarning("Inheritable permissions configuration is not supported for 3P agent blueprints. Skipping."); - // TODO: Implement 3P logic if/when supported - } - } - - /// - /// Read inheritable scopes from a365.config.json - /// Looks for 'agentIdentityScopes' property, falls back to hardcoded defaults - /// - private List ReadInheritableScopesFromConfig(JsonObject setupConfig) - { - var inheritableScopes = new List(); - - try - { - _logger.LogInformation("Reading inheritable scopes from a365.config.json"); - - // Try to read from agentIdentityScopes property in the setupConfig - if (setupConfig.TryGetPropertyValue("agentIdentityScopes", out var agentIdentityScopesNode) && - agentIdentityScopesNode is JsonArray agentIdentityScopesArr) - { - _logger.LogInformation(" Found 'agentIdentityScopes' property in config"); - - foreach (var scopeItem in agentIdentityScopesArr) - { - var scope = scopeItem?.GetValue(); - if (!string.IsNullOrWhiteSpace(scope) && !inheritableScopes.Contains(scope)) - { - inheritableScopes.Add(scope); - _logger.LogInformation(" Found inheritable scope: {Scope}", scope); - } - } - } - else - { - _logger.LogInformation(" 'agentIdentityScopes' property not found in config, using hardcoded defaults"); - - // Use hardcoded defaults from ConfigConstants - inheritableScopes.AddRange(ConfigConstants.DefaultAgentIdentityScopes); - - _logger.LogInformation(" Using {Count} default scopes: {Scopes}", - inheritableScopes.Count, string.Join(", ", inheritableScopes)); - } - - if (inheritableScopes.Count > 0) - { - _logger.LogInformation("Total inheritable scopes configured: {Count}", inheritableScopes.Count); - } - else - { - _logger.LogWarning("No inheritable scopes available - this should not happen"); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to read inheritable scopes from configuration, using defaults"); - - // Fallback to defaults on any error - inheritableScopes.AddRange(ConfigConstants.DefaultAgentIdentityScopes); - _logger.LogInformation("Using {Count} default scopes as fallback", inheritableScopes.Count); - } - - return inheritableScopes; - } - - /// - /// Creates and authenticates a GraphServiceClient using InteractiveGraphAuthService. - /// This common method consolidates the authentication logic used across multiple methods. - /// - private async Task GetAuthenticatedGraphClientAsync(string tenantId, CancellationToken ct) - { - _logger.LogInformation("Authenticating to Microsoft Graph using interactive browser authentication..."); - _logger.LogInformation("IMPORTANT: Agent Blueprint operations require Application.ReadWrite.All permission."); - _logger.LogInformation("This will open a browser window for interactive authentication."); - _logger.LogInformation("Please sign in with a Global Administrator account."); - _logger.LogInformation(""); - - // Use InteractiveGraphAuthService to get proper authentication - var interactiveAuth = new InteractiveGraphAuthService( - LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger()); - - try - { - var graphClient = await interactiveAuth.GetAuthenticatedGraphClientAsync(tenantId, ct); - _logger.LogInformation("Successfully authenticated to Microsoft Graph"); - return graphClient; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to authenticate to Microsoft Graph: {Message}", ex.Message); - _logger.LogError(""); - _logger.LogError("TROUBLESHOOTING:"); - _logger.LogError("1. Ensure you are a Global Administrator or have Application.ReadWrite.All permission"); - _logger.LogError("2. The account must have already consented to these permissions"); - _logger.LogError(""); - throw new InvalidOperationException($"Microsoft Graph authentication failed: {ex.Message}", ex); - } - } - - private static string Short(string? text) - => string.IsNullOrWhiteSpace(text) ? string.Empty : (text.Length <= 180 ? text.Trim() : text[..177] + "..."); - - private async Task GetTokenFromGraphClient(GraphServiceClient graphClient, string tenantId) - { - // Use Azure.Identity to get the token directly with specific scopes - var credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions - { - TenantId = tenantId, - ClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" // Microsoft Graph PowerShell app ID - }); - - var tokenRequestContext = new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" }); - var token = await credential.GetTokenAsync(tokenRequestContext, CancellationToken.None); - ValidateGraphToken(token.Token); - - return token.Token; - } - - /// - /// Ensures delegated consent with retry logic (3 attempts with 5-second delays) - /// Matches the PowerShell script's retry behavior for DelegatedAgentApplicationCreateConsent.ps1 - /// - private async Task EnsureDelegatedConsentWithRetriesAsync( - string tenantId, - CancellationToken cancellationToken) - { - const int maxRetries = 3; - const int retryDelaySeconds = 5; - - for (int attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - if (attempt > 1) - { - _logger.LogInformation("Retry attempt {Attempt} of {MaxRetries} for delegated consent", attempt, maxRetries); - await Task.Delay(TimeSpan.FromSeconds(retryDelaySeconds), cancellationToken); - } - - var success = await _delegatedConsentService.EnsureAgentApplicationCreateConsentAsync( - MicrosoftGraphCommandLineToolsAppId, - tenantId, - cancellationToken); - - if (success) - { - _logger.LogInformation("Successfully ensured delegated application consent on attempt {Attempt}", attempt); - return true; - } - - _logger.LogWarning("Consent attempt {Attempt} returned false", attempt); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Consent attempt {Attempt} failed: {Message}", attempt, ex.Message); - - if (attempt == maxRetries) - { - _logger.LogError("All retry attempts exhausted for delegated consent"); - _logger.LogError("Common causes:"); - _logger.LogError(" 1. Insufficient permissions - You need Application.ReadWrite.All and DelegatedPermissionGrant.ReadWrite.All"); - _logger.LogError(" 2. Not a Global Administrator or similar privileged role"); - _logger.LogError(" 3. Azure CLI authentication expired - Run 'az login' and retry"); - _logger.LogError(" 4. Network connectivity issues"); - return false; - } - } - } - - 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 - /// - private static string GetRuntimeForPlatform(Models.ProjectPlatform platform) - { - return platform switch - { - Models.ProjectPlatform.Python => "PYTHON:3.11", - Models.ProjectPlatform.NodeJs => "NODE:20-lts", - Models.ProjectPlatform.DotNet => "DOTNETCORE:8.0", - _ => "DOTNETCORE:8.0" // Default fallback - }; - } - - /// - /// Get the Azure Web App Linux FX Version string based on the detected platform - /// - private static string GetLinuxFxVersionForPlatform(Models.ProjectPlatform platform) - { - return platform switch - { - Models.ProjectPlatform.Python => "PYTHON|3.11", - Models.ProjectPlatform.NodeJs => "NODE|20-lts", - Models.ProjectPlatform.DotNet => "DOTNETCORE|8.0", - _ => "DOTNETCORE|8.0" // Default fallback - }; - } -} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs index 24f43b53..7a0a5632 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs @@ -119,12 +119,14 @@ public async Task CreateEndpointWithAgentBlueprintAsync( if (!response.IsSuccessStatusCode) { _logger.LogError("Failed to call create endpoint. Status: {Status}", response.StatusCode); + var errorContent = await response.Content.ReadAsStringAsync(); if (errorContent.Contains("Failed to provision bot resource via Azure Management API. Status: BadRequest", StringComparison.OrdinalIgnoreCase)) { _logger.LogError("Please ensure that the Agent 365 CLI is supported in the selected region ('{Location}') and that your web app name ('{EndpointName}') is globally unique.", location, endpointName); return false; } + _logger.LogError("Error response: {Error}", errorContent); return false; } @@ -240,8 +242,10 @@ public async Task DeleteEndpointWithAgentBlueprintAsync( if (!response.IsSuccessStatusCode) { _logger.LogError("Failed to call delete endpoint. Status: {Status}", response.StatusCode); + var errorContent = await response.Content.ReadAsStringAsync(); _logger.LogError("Error response: {Error}", errorContent); + return false; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs index 27cd914d..b2a3efd1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs @@ -325,6 +325,7 @@ public async Task LoadAsync( // Log warnings if any if (validationResult.Warnings.Count > 0) + if (validationResult.Warnings.Count > 0) { foreach (var warning in validationResult.Warnings) { @@ -678,13 +679,23 @@ private string GetJsonPropertyName(PropertyInfo prop) var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; if (underlyingType == typeof(string)) - return element.GetString(); + return element.ValueKind == JsonValueKind.String + ? element.GetString() + : element.GetRawText(); // fallback: convert any other JSON type to string if (underlyingType == typeof(int)) return element.GetInt32(); if (underlyingType == typeof(bool)) + { + if (element.ValueKind == JsonValueKind.True) return true; + if (element.ValueKind == JsonValueKind.False) return false; + if (element.ValueKind == JsonValueKind.String && + bool.TryParse(element.GetString(), out var result)) + return result; + return element.GetBoolean(); + } if (underlyingType == typeof(DateTime)) return element.GetDateTime(); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/InfrastructureSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/InfrastructureSubcommandTests.cs new file mode 100644 index 00000000..74e17503 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/InfrastructureSubcommandTests.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +public class InfrastructureSubcommandTests +{ + private readonly ILogger _logger; + private readonly CommandExecutor _commandExecutor; + + public InfrastructureSubcommandTests() + { + _logger = Substitute.For(); + _commandExecutor = Substitute.For(Substitute.For>()); + } + + [Fact] + public async Task EnsureAppServicePlanExists_WhenQuotaLimitExceeded_ThrowsInvalidOperationException() + { + // Arrange + var subscriptionId = "test-sub-id"; + var resourceGroup = "test-rg"; + var planName = "test-plan"; + var planSku = "B1"; + + // Mock app service plan doesn't exist (initial check) + _commandExecutor.ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)), + captureOutput: true, + suppressErrorLogging: true) + .Returns(new CommandResult { ExitCode = 1, StandardError = "Plan not found" }); + + // Mock app service plan creation fails with quota error + _commandExecutor.ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan create") && s.Contains(planName)), + suppressErrorLogging: true) + .Returns(new CommandResult + { + ExitCode = 1, + StandardError = "ERROR: Operation cannot be completed without additional quota.\n\nAdditional details - Location:\n\nCurrent Limit (Basic VMs): 0\n\nCurrent Usage: 0\n\nAmount required for this deployment (Basic VMs): 1" + }); + + // Act & Assert - The method should throw because verification fails + var exception = await Assert.ThrowsAsync( + async () => await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync(_commandExecutor, _logger, resourceGroup, planName, planSku, subscriptionId)); + + exception.Message.Should().Contain($"Failed to create App Service plan '{planName}'"); + } + + [Fact] + public async Task EnsureAppServicePlanExists_WhenPlanAlreadyExists_SkipsCreation() + { + // Arrange + var subscriptionId = "test-sub-id"; + var resourceGroup = "test-rg"; + var planName = "existing-plan"; + var planSku = "B1"; + + // Mock app service plan already exists + _commandExecutor.ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)), + captureOutput: true, + suppressErrorLogging: true) + .Returns(new CommandResult + { + ExitCode = 0, + StandardOutput = "{\"name\": \"existing-plan\", \"sku\": {\"name\": \"B1\"}}" + }); + + // Act + await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync(_commandExecutor, _logger, resourceGroup, planName, planSku, subscriptionId); + + // Assert - Verify creation command was never called + await _commandExecutor.DidNotReceive().ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan create")), + suppressErrorLogging: true); + } + + [Fact] + public async Task EnsureAppServicePlanExists_WhenCreationSucceeds_VerifiesExistence() + { + // Arrange + var subscriptionId = "test-sub-id"; + var resourceGroup = "test-rg"; + var planName = "new-plan"; + var planSku = "B1"; + + // Mock app service plan doesn't exist initially, then exists after creation + var planShowCallCount = 0; + _commandExecutor.ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)), + captureOutput: true, + suppressErrorLogging: true) + .Returns(callInfo => + { + planShowCallCount++; + // First call: plan doesn't exist, second call (after creation): plan exists + return planShowCallCount == 1 + ? new CommandResult { ExitCode = 1, StandardError = "Plan not found" } + : new CommandResult { ExitCode = 0, StandardOutput = "{\"name\": \"new-plan\"}" }; + }); + + // Mock app service plan creation succeeds + _commandExecutor.ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan create") && s.Contains(planName)), + suppressErrorLogging: true) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = "Plan created" }); + + // Act + await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync(_commandExecutor, _logger, resourceGroup, planName, planSku, subscriptionId); + + // Assert - Verify the plan creation was called + await _commandExecutor.Received(1).ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan create") && s.Contains(planName)), + suppressErrorLogging: true); + + // Verify the plan was checked twice (before creation and verification after) + await _commandExecutor.Received(2).ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)), + captureOutput: true, + suppressErrorLogging: true); + } + + [Fact] + public async Task EnsureAppServicePlanExists_WhenCreationFailsSilently_ThrowsInvalidOperationException() + { + // Arrange - Tests the scenario where Azure CLI returns success but the plan doesn't actually exist + var subscriptionId = "test-sub-id"; + var resourceGroup = "test-rg"; + var planName = "failed-plan"; + var planSku = "B1"; + + // Mock app service plan doesn't exist before and after creation attempt + _commandExecutor.ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)), + captureOutput: true, + suppressErrorLogging: true) + .Returns(new CommandResult { ExitCode = 1, StandardError = "Plan not found" }); + + // Mock plan creation appears to succeed but doesn't actually create the plan + _commandExecutor.ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan create") && s.Contains(planName)), + suppressErrorLogging: true) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = "" }); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync(_commandExecutor, _logger, resourceGroup, planName, planSku, subscriptionId)); + + exception.Message.Should().Contain($"Failed to create App Service plan '{planName}'"); + } + + [Fact] + public async Task EnsureAppServicePlanExists_WhenPermissionDenied_ThrowsInvalidOperationException() + { + // Arrange + var subscriptionId = "test-sub-id"; + var resourceGroup = "test-rg"; + var planName = "test-plan"; + var planSku = "B1"; + + // Mock app service plan doesn't exist + _commandExecutor.ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)), + captureOutput: true, + suppressErrorLogging: true) + .Returns(new CommandResult { ExitCode = 1, StandardError = "Plan not found" }); + + // Mock app service plan creation fails with permission error + _commandExecutor.ExecuteAsync("az", + Arg.Is(s => s.Contains("appservice plan create") && s.Contains(planName)), + suppressErrorLogging: true) + .Returns(new CommandResult + { + ExitCode = 1, + StandardError = "ERROR: The client does not have authorization to perform action" + }); + + // Act & Assert - The method should throw because verification fails + var exception = await Assert.ThrowsAsync( + async () => await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync(_commandExecutor, _logger, resourceGroup, planName, planSku, subscriptionId)); + + exception.Message.Should().Contain($"Failed to create App Service plan '{planName}'"); + } +} \ No newline at end of file diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs index 5d410b3b..e896aa08 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs @@ -1,403 +1,358 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using FluentAssertions; -using Microsoft.Agents.A365.DevTools.Cli.Commands; -using Microsoft.Agents.A365.DevTools.Cli.Models; -using Microsoft.Agents.A365.DevTools.Cli.Services; -using Microsoft.Extensions.Logging; -using NSubstitute; -using System.CommandLine; -using System.CommandLine.Builder; -using System.CommandLine.IO; -using System.CommandLine.Parsing; -using System.IO; -using System.Reflection; -using System.Threading.Tasks; -using Xunit; - -namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; - -/// -/// Functional tests for SetupCommand execution -/// -public class SetupCommandTests -{ - private readonly ILogger _mockLogger; - private readonly IConfigService _mockConfigService; - private readonly CommandExecutor _mockExecutor; - private readonly DeploymentService _mockDeploymentService; - private readonly IBotConfigurator _mockBotConfigurator; - private readonly IAzureValidator _mockAzureValidator; - private readonly AzureWebAppCreator _mockWebAppCreator; - private readonly PlatformDetector _mockPlatformDetector; - private readonly GraphApiService _mockGraphApiService; - - public SetupCommandTests() - { - _mockLogger = Substitute.For>(); - _mockConfigService = Substitute.For(); - var mockExecutorLogger = Substitute.For>(); - _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); - var mockDeployLogger = Substitute.For>(); - var mockPlatformDetectorLogger = Substitute.For>(); - _mockPlatformDetector = Substitute.ForPartsOf(mockPlatformDetectorLogger); - var mockDotNetLogger = Substitute.For>(); - var mockNodeLogger = Substitute.For>(); - var mockPythonLogger = Substitute.For>(); - _mockDeploymentService = Substitute.ForPartsOf( - mockDeployLogger, - _mockExecutor, - _mockPlatformDetector, - mockDotNetLogger, - mockNodeLogger, - mockPythonLogger); - _mockBotConfigurator = Substitute.For(); - _mockAzureValidator = Substitute.For(); - _mockWebAppCreator = Substitute.ForPartsOf(Substitute.For>()); - _mockGraphApiService = Substitute.For(); - - // Prevent the real setup runner from running during tests by short-circuiting it - SetupCommand.SetupRunnerInvoker = (setupPath, generatedPath, exec, webApp) => Task.FromResult(true); - } - - [Fact] - public async Task SetupCommand_DryRun_ValidConfig_OnlyValidatesConfig() - { - // Arrange - var config = new Agent365Config { TenantId = "tenant", SubscriptionId = "sub", ResourceGroup = "rg", Location = "loc", AppServicePlanName = "plan", WebAppName = "web", AgentIdentityDisplayName = "agent", DeploymentProjectPath = "." }; - _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); - var command = SetupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockExecutor, _mockDeploymentService, _mockBotConfigurator, _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService); - var parser = new CommandLineBuilder(command).Build(); - var testConsole = new TestConsole(); - - // Act - var result = await parser.InvokeAsync("--dry-run", testConsole); - - // Assert - Assert.Equal(0, result); - - // Dry-run should load config but must not call Azure/Bot services - await _mockConfigService.Received(1).LoadAsync(Arg.Any(), Arg.Any()); - await _mockAzureValidator.DidNotReceiveWithAnyArgs().ValidateAllAsync(default!); - await _mockBotConfigurator.DidNotReceiveWithAnyArgs().CreateEndpointWithAgentBlueprintAsync(default!, default!, default!, default!, default!); - } - - [Fact] - public async Task SetupCommand_McpPermissionFailure_DoesNotThrowUnhandledException() - { - // Arrange - var config = new Agent365Config - { - TenantId = "tenant", - SubscriptionId = "sub", - ResourceGroup = "rg", - Location = "eastus", - AppServicePlanName = "plan", - WebAppName = "web", - AgentIdentityDisplayName = "agent", - DeploymentProjectPath = ".", - AgentBlueprintId = "blueprint-app-id", - Environment = "prod" - }; - - _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); - _mockAzureValidator.ValidateAllAsync(Arg.Any()).Returns(Task.FromResult(true)); - - // Simulate MCP permission failure by setting up a failing mock - SetupCommand.SetupRunnerInvoker = async (setupPath, generatedPath, exec, webApp) => - { - // Simulate blueprint creation success but write minimal generated config - var generatedConfig = new - { - agentBlueprintId = "test-blueprint-id", - agentBlueprintObjectId = "test-object-id", - tenantId = "tenant" - }; - - await File.WriteAllTextAsync(generatedPath, System.Text.Json.JsonSerializer.Serialize(generatedConfig)); - return true; - }; - - var command = SetupCommand.CreateCommand( - _mockLogger, - _mockConfigService, - _mockExecutor, - _mockDeploymentService, - _mockBotConfigurator, - _mockAzureValidator, - _mockWebAppCreator, - _mockPlatformDetector, - _mockGraphApiService); - - var parser = new CommandLineBuilder(command).Build(); - var testConsole = new TestConsole(); - - // Act - Even if MCP permissions fail, setup should not throw unhandled exception - var result = await parser.InvokeAsync("setup", testConsole); - - // Assert - The command should complete without unhandled exceptions - // It may log errors but should not crash - result.Should().BeOneOf(0, 1); // May return 0 (success) or 1 (partial failure) but should not throw - } - - [Fact] - public void SetupCommand_ErrorMessages_ShouldBeInformativeAndActionable() - { - // Arrange - var mockLogger = Substitute.For>(); - - // Act - Verify that error messages are being logged with sufficient detail - // This is a placeholder for ensuring error messages follow best practices - - // Assert - Error messages should: - // 1. Explain what failed - mockLogger.ReceivedCalls().Should().NotBeNull(); - - // 2. Provide context (e.g., which resource, which permission) - // 3. Suggest remediation steps - // 4. Not contain emojis or special characters - } - - [Fact] - public async Task SetupCommand_BlueprintCreationSuccess_LogsAtInfoLevel() - { - // Arrange - var config = new Agent365Config - { - TenantId = "tenant", - SubscriptionId = "sub", - ResourceGroup = "rg", - Location = "eastus", - AppServicePlanName = "plan", - WebAppName = "web", - AgentIdentityDisplayName = "agent", - DeploymentProjectPath = ".", - AgentBlueprintId = "blueprint-app-id" - }; - - _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); - _mockAzureValidator.ValidateAllAsync(Arg.Any()).Returns(Task.FromResult(true)); - - SetupCommand.SetupRunnerInvoker = async (setupPath, generatedPath, exec, webApp) => - { - var generatedConfig = new - { - agentBlueprintId = "test-blueprint-id", - agentBlueprintObjectId = "test-object-id", - tenantId = "tenant", - completed = true - }; - - await File.WriteAllTextAsync(generatedPath, System.Text.Json.JsonSerializer.Serialize(generatedConfig)); - return true; - }; - - var command = SetupCommand.CreateCommand( - _mockLogger, - _mockConfigService, - _mockExecutor, - _mockDeploymentService, - _mockBotConfigurator, - _mockAzureValidator, - _mockWebAppCreator, - _mockPlatformDetector, - _mockGraphApiService); - - var parser = new CommandLineBuilder(command).Build(); - var testConsole = new TestConsole(); - - // Act - var result = await parser.InvokeAsync("setup", testConsole); - - // Assert - Blueprint creation success should be logged at Info level - _mockLogger.ReceivedCalls().Should().NotBeEmpty(); - } - - [Fact] - public async Task SetupCommand_GeneratedConfigPath_LoggedAtDebugLevel() - { - // Arrange - var config = new Agent365Config - { - TenantId = "tenant", - SubscriptionId = "sub", - ResourceGroup = "rg", - Location = "eastus", - AppServicePlanName = "plan", - WebAppName = "web", - AgentIdentityDisplayName = "agent", - DeploymentProjectPath = ".", - AgentBlueprintId = "blueprint-app-id" - }; - - _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); - _mockAzureValidator.ValidateAllAsync(Arg.Any()).Returns(Task.FromResult(true)); - - SetupCommand.SetupRunnerInvoker = async (setupPath, generatedPath, exec, webApp) => - { - var generatedConfig = new - { - agentBlueprintId = "test-blueprint-id" - }; - - await File.WriteAllTextAsync(generatedPath, System.Text.Json.JsonSerializer.Serialize(generatedConfig)); - return true; - }; - - var command = SetupCommand.CreateCommand( - _mockLogger, - _mockConfigService, - _mockExecutor, - _mockDeploymentService, - _mockBotConfigurator, - _mockAzureValidator, - _mockWebAppCreator, - _mockPlatformDetector, - _mockGraphApiService); - - var parser = new CommandLineBuilder(command).Build(); - var testConsole = new TestConsole(); - - // Act - await parser.InvokeAsync("setup", testConsole); - - // Assert - Generated config path should be logged at Debug level, not Info - // This test verifies that implementation detail messages are not shown to users by default - _mockLogger.Received().Log( - LogLevel.Debug, - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any>()); - } - - [Fact] - public async Task SetupCommand_PartialFailure_DisplaysComprehensiveSummary() - { - // Arrange - var config = new Agent365Config - { - TenantId = "tenant", - SubscriptionId = "sub", - ResourceGroup = "rg", - Location = "eastus", - AppServicePlanName = "plan", - WebAppName = "web", - AgentIdentityDisplayName = "agent", - DeploymentProjectPath = ".", - AgentBlueprintId = "blueprint-app-id", - Environment = "prod" - }; - - _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); - _mockAzureValidator.ValidateAllAsync(Arg.Any()).Returns(Task.FromResult(true)); - - SetupCommand.SetupRunnerInvoker = async (setupPath, generatedPath, exec, webApp) => - { - var generatedConfig = new - { - agentBlueprintId = "test-blueprint-id", - agentBlueprintObjectId = "test-object-id", - tenantId = "tenant" - }; - - await File.WriteAllTextAsync(generatedPath, System.Text.Json.JsonSerializer.Serialize(generatedConfig)); - return true; - }; - - var command = SetupCommand.CreateCommand( - _mockLogger, - _mockConfigService, - _mockExecutor, - _mockDeploymentService, - _mockBotConfigurator, - _mockAzureValidator, - _mockWebAppCreator, - _mockPlatformDetector, - _mockGraphApiService); - - var parser = new CommandLineBuilder(command).Build(); - var testConsole = new TestConsole(); - - // Act - var result = await parser.InvokeAsync("setup", testConsole); - - // Assert - Setup should display a comprehensive summary with multiple info log calls - var infoLogCount = _mockLogger.ReceivedCalls() - .Count(call => - { - var args = call.GetArguments(); - return call.GetMethodInfo().Name == "Log" && - args.Length > 0 && - args[0] is LogLevel level && - level == LogLevel.Information; - }); - infoLogCount.Should().BeGreaterThan(3, "Setup should log summary, completed steps, and other informational messages"); - } - - [Fact] - public async Task SetupCommand_AllStepsSucceed_ShowsSuccessfulSummary() - { - // Arrange - var config = new Agent365Config - { - TenantId = "tenant", - SubscriptionId = "sub", - ResourceGroup = "rg", - Location = "eastus", - AppServicePlanName = "plan", - WebAppName = "web", - AgentIdentityDisplayName = "agent", - DeploymentProjectPath = ".", - AgentBlueprintId = "blueprint-app-id", - Environment = "prod" - }; - - _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); - _mockAzureValidator.ValidateAllAsync(Arg.Any()).Returns(Task.FromResult(true)); - - SetupCommand.SetupRunnerInvoker = async (setupPath, generatedPath, exec, webApp) => - { - var generatedConfig = new - { - agentBlueprintId = "test-blueprint-id", - agentBlueprintObjectId = "test-object-id", - tenantId = "tenant" - }; - - await File.WriteAllTextAsync(generatedPath, System.Text.Json.JsonSerializer.Serialize(generatedConfig)); - return true; - }; - - var command = SetupCommand.CreateCommand( - _mockLogger, - _mockConfigService, - _mockExecutor, - _mockDeploymentService, - _mockBotConfigurator, - _mockAzureValidator, - _mockWebAppCreator, - _mockPlatformDetector, - _mockGraphApiService); - - var parser = new CommandLineBuilder(command).Build(); - var testConsole = new TestConsole(); - - // Act - await parser.InvokeAsync("setup", testConsole); - - // Assert - When all steps succeed, should log success at Information level - var infoLogCount = _mockLogger.ReceivedCalls() - .Count(call => - { - var args = call.GetArguments(); - return call.GetMethodInfo().Name == "Log" && - args.Length > 0 && - args[0] is LogLevel level && - level == LogLevel.Information; - }); - infoLogCount.Should().BeGreaterThan(0, "Setup should show success message when all steps complete"); - } -} - +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.IO; +using System.CommandLine.Parsing; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Functional tests for SetupCommand execution +/// +public class SetupCommandTests +{ + private readonly ILogger _mockLogger; + private readonly IConfigService _mockConfigService; + private readonly CommandExecutor _mockExecutor; + private readonly DeploymentService _mockDeploymentService; + private readonly IBotConfigurator _mockBotConfigurator; + private readonly IAzureValidator _mockAzureValidator; + private readonly AzureWebAppCreator _mockWebAppCreator; + private readonly PlatformDetector _mockPlatformDetector; + private readonly GraphApiService _mockGraphApiService; + + public SetupCommandTests() + { + _mockLogger = Substitute.For>(); + _mockConfigService = Substitute.For(); + var mockExecutorLogger = Substitute.For>(); + _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); + var mockDeployLogger = Substitute.For>(); + var mockPlatformDetectorLogger = Substitute.For>(); + _mockPlatformDetector = Substitute.ForPartsOf(mockPlatformDetectorLogger); + var mockDotNetLogger = Substitute.For>(); + var mockNodeLogger = Substitute.For>(); + var mockPythonLogger = Substitute.For>(); + _mockDeploymentService = Substitute.ForPartsOf( + mockDeployLogger, + _mockExecutor, + _mockPlatformDetector, + mockDotNetLogger, + mockNodeLogger, + mockPythonLogger); + _mockBotConfigurator = Substitute.For(); + _mockAzureValidator = Substitute.For(); + _mockWebAppCreator = Substitute.ForPartsOf(Substitute.For>()); + _mockGraphApiService = Substitute.For(); + } + + [Fact] + public async Task SetupAllCommand_DryRun_ValidConfig_OnlyValidatesConfig() + { + // Arrange + var config = new Agent365Config + { + TenantId = "tenant", + SubscriptionId = "sub", + ResourceGroup = "rg", + Location = "loc", + AppServicePlanName = "plan", + WebAppName = "web", + AgentIdentityDisplayName = "agent", + DeploymentProjectPath = ".", + AgentBlueprintDisplayName = "TestBlueprint" + }; + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); + + var command = SetupCommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockDeploymentService, + _mockBotConfigurator, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector, + _mockGraphApiService); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync("all --dry-run", testConsole); + + // Assert + Assert.Equal(0, result); + + // Dry-run mode does not load config or call Azure/Bot services - it just displays what would be done + await _mockConfigService.DidNotReceiveWithAnyArgs().LoadAsync(Arg.Any(), Arg.Any()); + await _mockAzureValidator.DidNotReceiveWithAnyArgs().ValidateAllAsync(default!); + await _mockBotConfigurator.DidNotReceiveWithAnyArgs().CreateEndpointWithAgentBlueprintAsync(default!, default!, default!, default!, default!); + } + + [Fact] + public async Task SetupAllCommand_SkipInfrastructure_SkipsInfrastructureStep() + { + // Arrange + var config = new Agent365Config + { + TenantId = "tenant", + SubscriptionId = "sub", + ResourceGroup = "rg", + Location = "eastus", + AppServicePlanName = "plan", + WebAppName = "web", + AgentIdentityDisplayName = "agent", + DeploymentProjectPath = ".", + AgentBlueprintId = "blueprint-app-id", + AgentBlueprintDisplayName = "TestBlueprint", + Environment = "prod" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); + + var command = SetupCommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockDeploymentService, + _mockBotConfigurator, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector, + _mockGraphApiService); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync("all --dry-run --skip-infrastructure", testConsole); + + // Assert + Assert.Equal(0, result); + + // Dry-run mode does not load config - it just displays what would be done (with infrastructure skipped) + await _mockConfigService.DidNotReceiveWithAnyArgs().LoadAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void SetupCommand_HasRequiredSubcommands() + { + // Arrange & Act + var command = SetupCommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockDeploymentService, + _mockBotConfigurator, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector, + _mockGraphApiService); + + // Assert - Verify all required subcommands exist + var subcommandNames = command.Subcommands.Select(c => c.Name).ToList(); + + subcommandNames.Should().Contain("infrastructure", "Setup should have infrastructure subcommand"); + subcommandNames.Should().Contain("blueprint", "Setup should have blueprint subcommand"); + subcommandNames.Should().Contain("permissions", "Setup should have permissions subcommand"); + subcommandNames.Should().Contain("endpoint", "Setup should have endpoint subcommand"); + subcommandNames.Should().Contain("all", "Setup should have all subcommand"); + } + + [Fact] + public void SetupCommand_PermissionsSubcommand_HasMcpAndBotSubcommands() + { + // Arrange & Act + var command = SetupCommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockDeploymentService, + _mockBotConfigurator, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector, + _mockGraphApiService); + + var permissionsCmd = command.Subcommands.FirstOrDefault(c => c.Name == "permissions"); + + // Assert + permissionsCmd.Should().NotBeNull("Permissions subcommand should exist"); + + var permissionsSubcommandNames = permissionsCmd!.Subcommands.Select(c => c.Name).ToList(); + permissionsSubcommandNames.Should().Contain("mcp", "Permissions should have mcp subcommand"); + permissionsSubcommandNames.Should().Contain("bot", "Permissions should have bot subcommand"); + } + + [Fact] + public void SetupCommand_ErrorMessages_ShouldBeInformativeAndActionable() + { + // Arrange + var mockLogger = Substitute.For>(); + + // Act - Verify that command can be created without errors + var command = SetupCommand.CreateCommand( + mockLogger, + _mockConfigService, + _mockExecutor, + _mockDeploymentService, + _mockBotConfigurator, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector, + _mockGraphApiService); + + // Assert - Command structure should support clear error messaging + command.Should().NotBeNull(); + command.Description.Should().NotBeNullOrEmpty("Setup command should have helpful description"); + + // Error messages should: + // 1. Explain what failed - verified through command descriptions + // 2. Provide context (e.g., which resource, which permission) - verified through subcommand descriptions + // 3. Suggest remediation steps - verified through command help text + // 4. Not contain emojis or special characters - verified through clean descriptions + + foreach (var subcommand in command.Subcommands) + { + subcommand.Description.Should().NotBeNullOrEmpty($"Subcommand {subcommand.Name} should have description"); + } + } + + [Fact] + public async Task InfrastructureSubcommand_DryRun_CompletesSuccessfully() + { + // Arrange + var config = new Agent365Config + { + TenantId = "tenant", + SubscriptionId = "sub", + ResourceGroup = "rg", + Location = "eastus", + AppServicePlanName = "plan", + WebAppName = "web", + AgentIdentityDisplayName = "agent", + DeploymentProjectPath = ".", + AppServicePlanSku = "B1" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); + + var command = SetupCommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockDeploymentService, + _mockBotConfigurator, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector, + _mockGraphApiService); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync("infrastructure --dry-run", testConsole); + + // Assert + Assert.Equal(0, result); + + // Verify config was loaded in dry-run mode + await _mockConfigService.Received(1).LoadAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task BlueprintSubcommand_DryRun_CompletesSuccessfully() + { + // Arrange + var config = new Agent365Config + { + TenantId = "tenant", + SubscriptionId = "sub", + ResourceGroup = "rg", + Location = "eastus", + AppServicePlanName = "plan", + WebAppName = "web", + AgentIdentityDisplayName = "agent", + DeploymentProjectPath = ".", + AgentBlueprintDisplayName = "TestBlueprint" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); + + var command = SetupCommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockDeploymentService, + _mockBotConfigurator, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector, + _mockGraphApiService); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync("blueprint --dry-run", testConsole); + + // Assert + Assert.Equal(0, result); + + // Verify config was loaded in dry-run mode + await _mockConfigService.Received(1).LoadAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task EndpointSubcommand_DryRun_CompletesSuccessfully() + { + // Arrange + var config = new Agent365Config + { + TenantId = "tenant", + SubscriptionId = "sub", + ResourceGroup = "rg", + Location = "eastus", + AppServicePlanName = "plan", + WebAppName = "web", + AgentIdentityDisplayName = "agent", + DeploymentProjectPath = ".", + AgentBlueprintId = "blueprint-id" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); + + var command = SetupCommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockDeploymentService, + _mockBotConfigurator, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector, + _mockGraphApiService); + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync("endpoint --dry-run", testConsole); + + // Assert + Assert.Equal(0, result); + + // Verify config was loaded in dry-run mode + await _mockConfigService.Received(1).LoadAsync(Arg.Any(), Arg.Any()); + } +} +