Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 46 additions & 16 deletions src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public static Command CreateCommand(
ILogger<CleanupCommand> 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");

Expand All @@ -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));

Expand All @@ -47,7 +48,8 @@ public static Command CreateCommand(
private static Command CreateBlueprintCleanupCommand(
ILogger<CleanupCommand> logger,
IConfigService configService,
CommandExecutor executor)
CommandExecutor executor,
GraphApiService graphApiService)
{
var command = new Command("blueprint", "Remove Entra ID blueprint application and service principal");

Expand Down Expand Up @@ -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): ");
Expand All @@ -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;
Expand All @@ -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)
{
Expand Down Expand Up @@ -359,6 +377,7 @@ private static async Task ExecuteAllCleanupAsync(
IConfigService configService,
IBotConfigurator botConfigurator,
CommandExecutor executor,
GraphApiService graphApiService,
FileInfo? configFile)
{
try
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ static async Task<int> Main(string[] args)
var manifestTemplateService = serviceProvider.GetRequiredService<ManifestTemplateService>();
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,77 @@ private async Task AssignAppRoleAsync(

#endregion

/// <summary>
/// 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.
/// </summary>
/// <param name="tenantId">The tenant ID for authentication</param>
/// <param name="blueprintId">The blueprint application ID (object ID or app ID)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if deletion succeeded or resource not found; false otherwise</returns>
public async Task<bool> 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<bool> EnsureGraphHeadersAsync(string tenantId, CancellationToken ct = default, IEnumerable<string>? scopes = null)
{
var token = (scopes != null && _tokenProvider != null) ? await _tokenProvider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, ct) : await GetGraphAccessTokenAsync(tenantId, ct);
Expand Down Expand Up @@ -678,9 +749,10 @@ public async Task<bool> GraphDeleteAsync(
string tenantId,
string relativePath,
CancellationToken ct = default,
bool treatNotFoundAsSuccess = true)
bool treatNotFoundAsSuccess = true,
IEnumerable<string>? 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -30,6 +32,21 @@ public CleanupCommandTests()
_mockExecutor.ExecuteAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }));
_mockBotConfigurator = Substitute.For<IBotConfigurator>();

// Create a mock token provider for GraphApiService
_mockTokenProvider = Substitute.For<IMicrosoftGraphTokenProvider>();

// Configure token provider to return a test token
_mockTokenProvider.GetMgGraphAccessTokenAsync(
Arg.Any<string>(),
Arg.Any<IEnumerable<string>>(),
Arg.Any<bool>(),
Arg.Any<CancellationToken>())
.Returns("test-token");

// Create a real GraphApiService instance with mocked dependencies
var mockGraphLogger = Substitute.For<ILogger<GraphApiService>>();
_graphApiService = new GraphApiService(mockGraphLogger, _mockExecutor, null, _mockTokenProvider);
}

[Fact(Skip = "Test requires interactive confirmation - cleanup commands now enforce user confirmation instead of --force")]
Expand All @@ -39,7 +56,7 @@ public async Task CleanupAzure_WithValidConfig_ShouldExecuteResourceDeleteComman
var config = CreateValidConfig();
_mockConfigService.LoadAsync(Arg.Any<string>(), Arg.Any<string>()).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
Expand Down Expand Up @@ -68,7 +85,7 @@ public async Task CleanupInstance_WithValidConfig_ShouldReturnSuccess()
_mockConfigService.LoadAsync(Arg.Any<string>(), Arg.Any<string>()).Returns(config);
_mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.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;
Expand Down Expand Up @@ -99,7 +116,7 @@ public async Task Cleanup_WithoutSubcommand_ShouldExecuteCompleteCleanup()
var config = CreateValidConfig();
_mockConfigService.LoadAsync(Arg.Any<string>(), Arg.Any<string>()).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
Expand Down Expand Up @@ -129,7 +146,7 @@ public async Task CleanupAzure_WithMissingWebAppName_ShouldStillExecuteCommand()
var config = CreateConfigWithMissingWebApp(); // Create config without web app name
_mockConfigService.LoadAsync(Arg.Any<string>(), Arg.Any<string>()).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
Expand All @@ -156,7 +173,7 @@ public async Task CleanupCommand_WithInvalidConfigFile_ShouldReturnError()
_mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.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
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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
Expand All @@ -225,7 +242,7 @@ public async Task CleanupBlueprint_WithValidConfig_ShouldReturnSuccess()
var config = CreateValidConfig();
_mockConfigService.LoadAsync(Arg.Any<string>(), Arg.Any<string>()).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
Expand Down