diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index 53f102bf..1439da81 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -16,7 +16,8 @@ public static Command CreateCommand( ILogger logger, IConfigService configService, IBotConfigurator botConfigurator, - CommandExecutor executor) + CommandExecutor executor, + GraphApiService graphApiService) { var cleanupCommand = new Command("cleanup", "Clean up ALL resources (blueprint, instance, Azure) - use subcommands for granular cleanup"); @@ -33,11 +34,11 @@ public static Command CreateCommand( // Set default handler for 'a365 cleanup' (without subcommand) - cleans up everything cleanupCommand.SetHandler(async (configFile) => { - await ExecuteAllCleanupAsync(logger, configService, botConfigurator, executor, configFile); + await ExecuteAllCleanupAsync(logger, configService, botConfigurator, executor, graphApiService, configFile); }, configOption); // Add subcommands for granular control - cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, executor)); + cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, executor, graphApiService)); cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, botConfigurator, executor)); cleanupCommand.AddCommand(CreateInstanceCleanupCommand(logger, configService, executor)); @@ -47,7 +48,8 @@ public static Command CreateCommand( private static Command CreateBlueprintCleanupCommand( ILogger logger, IConfigService configService, - CommandExecutor executor) + CommandExecutor executor, + GraphApiService graphApiService) { var command = new Command("blueprint", "Remove Entra ID blueprint application and service principal"); @@ -80,6 +82,7 @@ private static Command CreateBlueprintCleanupCommand( logger.LogInformation("Blueprint Cleanup Preview:"); logger.LogInformation("============================="); logger.LogInformation("Will delete Entra ID application: {BlueprintId}", config.AgentBlueprintId); + logger.LogInformation(" Name: {DisplayName}", config.AgentBlueprintDisplayName); logger.LogInformation(""); Console.Write("Continue with blueprint cleanup? (y/N): "); @@ -90,14 +93,17 @@ private static Command CreateBlueprintCleanupCommand( return; } - // Delete the Entra ID application - logger.LogInformation("Deleting blueprint application..."); - var deleteCommand = $"az ad app delete --id {config.AgentBlueprintId}"; - await executor.ExecuteAsync("az", $"ad app delete --id {config.AgentBlueprintId}", null, true, false, CancellationToken.None); - - logger.LogInformation("Blueprint application deleted successfully"); + // Delete the agent blueprint using the special Graph API endpoint + logger.LogInformation("Deleting agent blueprint application..."); + var deleted = await graphApiService.DeleteAgentBlueprintAsync( + config.TenantId, + config.AgentBlueprintId); + + // Always clear blueprint data from config, even if deletion failed + // User can delete manually using Portal/PowerShell/Graph Explorer + logger.LogInformation(""); + logger.LogInformation("Clearing blueprint data from local configuration..."); - // Clear the blueprint data from generated config config.AgentBlueprintId = string.Empty; config.AgentBlueprintClientSecret = string.Empty; config.ConsentUrlGraph = string.Empty; @@ -106,7 +112,19 @@ private static Command CreateBlueprintCleanupCommand( config.Consent2Granted = false; await configService.SaveStateAsync(config); - logger.LogInformation("Configuration updated"); + logger.LogInformation("Local configuration cleared"); + + if (deleted) + { + logger.LogInformation(""); + logger.LogInformation("Blueprint cleanup completed successfully!"); + } + else + { + logger.LogWarning(""); + logger.LogWarning("Blueprint deletion failed, but local configuration has been cleared."); + logger.LogWarning("Please manually delete the blueprint application using the Azure Portal, PowerShell, or Microsoft Graph Explorer."); + } } catch (Exception ex) { @@ -359,6 +377,7 @@ private static async Task ExecuteAllCleanupAsync( IConfigService configService, IBotConfigurator botConfigurator, CommandExecutor executor, + GraphApiService graphApiService, FileInfo? configFile) { try @@ -405,12 +424,23 @@ private static async Task ExecuteAllCleanupAsync( logger.LogInformation("Starting complete cleanup..."); - // 1. Delete blueprint application + // 1. Delete agent blueprint application if (!string.IsNullOrEmpty(config.AgentBlueprintId)) { - logger.LogInformation("Deleting blueprint application..."); - await executor.ExecuteAsync("az", $"ad app delete --id {config.AgentBlueprintId}", null, true, false, CancellationToken.None); - logger.LogInformation("Blueprint application deleted"); + logger.LogInformation("Deleting agent blueprint application..."); + var deleted = await graphApiService.DeleteAgentBlueprintAsync( + config.TenantId, + config.AgentBlueprintId); + + if (deleted) + { + logger.LogInformation("Agent blueprint application deleted successfully"); + } + else + { + logger.LogWarning("Failed to delete agent blueprint application (will continue with other resources)"); + logger.LogWarning("Local configuration will still be cleared at the end"); + } } // 2. Delete agent identity application diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 5ae28e59..3ce0fb4f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -100,7 +100,7 @@ static async Task Main(string[] args) var manifestTemplateService = serviceProvider.GetRequiredService(); rootCommand.AddCommand(ConfigCommand.CreateCommand(configLogger, wizardService: wizardService)); rootCommand.AddCommand(QueryEntraCommand.CreateCommand(queryEntraLogger, configService, executor, graphApiService)); - rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, botConfigurator, executor)); + rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, botConfigurator, executor, graphApiService)); rootCommand.AddCommand(PublishCommand.CreateCommand(publishLogger, configService, graphApiService, manifestTemplateService)); // Wrap all command handlers with exception handling diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index e949343e..4bd61295 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -587,6 +587,77 @@ private async Task AssignAppRoleAsync( #endregion + /// + /// Delete an Agent Blueprint application using the special agentIdentityBlueprint endpoint. + /// + /// SPECIAL AUTHENTICATION REQUIREMENTS: + /// Agent Blueprint deletion requires the AgentIdentityBlueprint.ReadWrite.All delegated permission scope. + /// This scope is not available through Azure CLI tokens, so we use interactive authentication via + /// the token provider (same authentication method used during blueprint creation in the setup command). + /// + /// This method uses the GraphDeleteAsync helper but with special scopes - the duplication is intentional + /// because blueprint operations require elevated permissions that standard Graph operations don't need. + /// + /// The tenant ID for authentication + /// The blueprint application ID (object ID or app ID) + /// Cancellation token + /// True if deletion succeeded or resource not found; false otherwise + public async Task DeleteAgentBlueprintAsync( + string tenantId, + string blueprintId, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("Deleting agent blueprint application: {BlueprintId}", blueprintId); + + // Agent Blueprint deletion requires special delegated permission scope + var requiredScopes = new[] { "AgentIdentityBlueprint.ReadWrite.All" }; + + if (_tokenProvider == null) + { + _logger.LogError("Token provider is not configured. Agent Blueprint deletion requires interactive authentication."); + _logger.LogError("Please ensure the GraphApiService is initialized with a token provider."); + return false; + } + + _logger.LogInformation("Acquiring access token with AgentIdentityBlueprint.ReadWrite.All scope..."); + _logger.LogInformation("A browser window will open for authentication."); + + // Use the special agentIdentityBlueprint endpoint for deletion + var deletePath = $"/beta/applications/{blueprintId}/microsoft.graph.agentIdentityBlueprint"; + + // Use GraphDeleteAsync with the special scopes required for blueprint operations + var success = await GraphDeleteAsync( + tenantId, + deletePath, + cancellationToken, + treatNotFoundAsSuccess: true, + scopes: requiredScopes); + + if (success) + { + _logger.LogInformation("Agent blueprint application deleted successfully"); + } + else + { + _logger.LogError("Failed to delete agent blueprint application"); + } + + return success; + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception deleting agent blueprint application"); + return false; + } + finally + { + // Clear authorization header to avoid issues with other requests + _httpClient.DefaultRequestHeaders.Authorization = null; + } + } + private async Task EnsureGraphHeadersAsync(string tenantId, CancellationToken ct = default, IEnumerable? scopes = null) { var token = (scopes != null && _tokenProvider != null) ? await _tokenProvider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, ct) : await GetGraphAccessTokenAsync(tenantId, ct); @@ -678,9 +749,10 @@ public async Task GraphDeleteAsync( string tenantId, string relativePath, CancellationToken ct = default, - bool treatNotFoundAsSuccess = true) + bool treatNotFoundAsSuccess = true, + IEnumerable? scopes = null) { - if (!await EnsureGraphHeadersAsync(tenantId, ct)) return false; + if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes)) return false; var url = relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? relativePath diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs index 9b63030c..53ed6726 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs @@ -17,6 +17,8 @@ public class CleanupCommandTests private readonly IConfigService _mockConfigService; private readonly IBotConfigurator _mockBotConfigurator; private readonly CommandExecutor _mockExecutor; + private readonly GraphApiService _graphApiService; + private readonly IMicrosoftGraphTokenProvider _mockTokenProvider; public CleanupCommandTests() { @@ -30,6 +32,21 @@ public CleanupCommandTests() _mockExecutor.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty })); _mockBotConfigurator = Substitute.For(); + + // Create a mock token provider for GraphApiService + _mockTokenProvider = Substitute.For(); + + // Configure token provider to return a test token + _mockTokenProvider.GetMgGraphAccessTokenAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns("test-token"); + + // Create a real GraphApiService instance with mocked dependencies + var mockGraphLogger = Substitute.For>(); + _graphApiService = new GraphApiService(mockGraphLogger, _mockExecutor, null, _mockTokenProvider); } [Fact(Skip = "Test requires interactive confirmation - cleanup commands now enforce user confirmation instead of --force")] @@ -39,7 +56,7 @@ public async Task CleanupAzure_WithValidConfig_ShouldExecuteResourceDeleteComman var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _graphApiService); var args = new[] { "cleanup", "azure", "--config", "test.json" }; // Act @@ -68,7 +85,7 @@ public async Task CleanupInstance_WithValidConfig_ShouldReturnSuccess() _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _graphApiService); var args = new[] { "cleanup", "instance", "--config", "test.json" }; var originalIn = Console.In; @@ -99,7 +116,7 @@ public async Task Cleanup_WithoutSubcommand_ShouldExecuteCompleteCleanup() var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _graphApiService); var args = new[] { "cleanup", "--config", "test.json" }; // Act @@ -129,7 +146,7 @@ public async Task CleanupAzure_WithMissingWebAppName_ShouldStillExecuteCommand() var config = CreateConfigWithMissingWebApp(); // Create config without web app name _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _graphApiService); var args = new[] { "cleanup", "azure", "--config", "test.json" }; // Act @@ -156,7 +173,7 @@ public async Task CleanupCommand_WithInvalidConfigFile_ShouldReturnError() _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(false)); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _graphApiService); var args = new[] { "cleanup", "azure", "--config", "invalid.json" }; // Act @@ -176,7 +193,7 @@ await _mockExecutor.DidNotReceive().ExecuteAsync( public void CleanupCommand_ShouldHaveCorrectSubcommands() { // Arrange & Act - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _graphApiService); // Assert - Verify command structure (what users see) Assert.Equal("cleanup", command.Name); @@ -195,7 +212,7 @@ public void CleanupCommand_ShouldHaveCorrectSubcommands() public void CleanupCommand_ShouldHaveDefaultHandlerOptions() { // Arrange & Act - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _graphApiService); // Assert - Verify parent command has options for default handler var optionNames = command.Options.Select(opt => opt.Name).ToList(); @@ -208,7 +225,7 @@ public void CleanupCommand_ShouldHaveDefaultHandlerOptions() public void CleanupSubcommands_ShouldHaveRequiredOptions() { // Arrange & Act - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _graphApiService); var blueprintCommand = command.Subcommands.First(sc => sc.Name == "blueprint"); // Assert - Verify user-facing options @@ -225,7 +242,7 @@ public async Task CleanupBlueprint_WithValidConfig_ShouldReturnSuccess() var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _graphApiService); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act