Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
152 changes: 134 additions & 18 deletions src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands;

public class CleanupCommand
{
private const string AgenticUsersKey = "agentic users";
private const string IdentitySpsKey = "identity SPs";

public static Command CreateCommand(
ILogger<CleanupCommand> logger,
IConfigService configService,
Expand Down Expand Up @@ -49,7 +52,7 @@ public static Command CreateCommand(
}, configOption, verboseOption);

// Add subcommands for granular control
cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, federatedCredentialService, correlationId: correlationId));
cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, correlationId: correlationId));
cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, executor));
cleanupCommand.AddCommand(CreateInstanceCleanupCommand(logger, configService, executor));

Expand All @@ -62,6 +65,7 @@ private static Command CreateBlueprintCleanupCommand(
IBotConfigurator botConfigurator,
CommandExecutor executor,
AgentBlueprintService agentBlueprintService,
IConfirmationProvider confirmationProvider,
FederatedCredentialService federatedCredentialService,
string? correlationId = null)
{
Expand Down Expand Up @@ -106,41 +110,114 @@ private static Command CreateBlueprintCleanupCommand(
return;
}

// Full blueprint cleanup (original behavior)
// Full blueprint cleanup with cascade instance deletion
logger.LogInformation("Starting blueprint cleanup...");

// Check if there's actually a blueprint to clean up
if (string.IsNullOrWhiteSpace(config.AgentBlueprintId))
{
logger.LogInformation("No blueprint application found to clean up");
return;
}

// Query for agent instances linked to this blueprint before showing preview
logger.LogInformation("Querying for agent instances linked to blueprint...");
List<AgentInstanceInfo> instances;
try
{
instances = (await agentBlueprintService.GetAgentInstancesForBlueprintAsync(
config.TenantId,
config.AgentBlueprintId))?.ToList() ?? new List<AgentInstanceInfo>();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to query agent instances for blueprint {BlueprintId}. Aborting cleanup.", config.AgentBlueprintId);
return;
}

// Show preview
logger.LogInformation("");
logger.LogInformation("Blueprint Cleanup Preview:");
logger.LogInformation("=============================");
logger.LogInformation("Will delete Entra ID application: {BlueprintId}", config.AgentBlueprintId);
logger.LogInformation(" Name: {DisplayName}", config.AgentBlueprintDisplayName);

if (instances.Count > 0)
{
logger.LogInformation("");
logger.LogInformation("Will also delete {Count} agent instance(s) linked to this blueprint:", instances.Count);
foreach (var instance in instances)
{
logger.LogInformation(" Instance: {DisplayName} (SP: {SpId})", instance.DisplayName ?? "(unnamed)", instance.IdentitySpId);
if (!string.IsNullOrWhiteSpace(instance.AgentUserId))
logger.LogInformation(" Agentic user: {UserId}", instance.AgentUserId);
}
}

logger.LogInformation("");

Console.Write("Continue with blueprint cleanup? (y/N): ");
var response = Console.ReadLine()?.Trim().ToLowerInvariant();
if (response != "y" && response != "yes")
if (!await confirmationProvider.ConfirmAsync("Continue with blueprint cleanup? (y/N): "))
{
logger.LogInformation("Cleanup cancelled by user");
return;
}

// Delete instances first (warn and continue on failure)
var failedResources = new Dictionary<string, List<string>>
{
[AgenticUsersKey] = new List<string>(),
[IdentitySpsKey] = new List<string>()
};

foreach (var instance in instances)
{
// Delete agentic user before identity SP
if (!string.IsNullOrWhiteSpace(instance.AgentUserId))
{
logger.LogInformation("Deleting agentic user {UserId} for instance {DisplayName}...",
instance.AgentUserId, instance.DisplayName ?? instance.IdentitySpId);

var userDeleted = await agentBlueprintService.DeleteAgentUserAsync(
config.TenantId,
instance.AgentUserId);

if (!userDeleted)
{
logger.LogWarning("Failed to delete agentic user {UserId} -- will continue", instance.AgentUserId);
failedResources[AgenticUsersKey].Add(instance.AgentUserId!);
}
else
{
logger.LogInformation("Agentic user deleted");
}
}

// Delete identity SP
logger.LogInformation("Deleting agent identity SP {SpId} for instance {DisplayName}...",
instance.IdentitySpId, instance.DisplayName ?? instance.IdentitySpId);

var spDeleted = await agentBlueprintService.DeleteAgentIdentityAsync(
config.TenantId,
instance.IdentitySpId);

if (!spDeleted)
{
logger.LogWarning("Failed to delete agent identity SP {SpId} -- will continue", instance.IdentitySpId);
failedResources[IdentitySpsKey].Add(instance.IdentitySpId);
}
else
{
logger.LogInformation("Agent identity SP deleted");
}
}

// Delete federated credentials first before deleting the blueprint
logger.LogInformation("");
logger.LogInformation("Deleting federated credentials from blueprint...");

// Configure FederatedCredentialService with custom client app ID if available
if (!string.IsNullOrWhiteSpace(config.ClientAppId))
{
federatedCredentialService.CustomClientAppId = config.ClientAppId;
}


var ficsDeleted = await federatedCredentialService.DeleteAllFederatedCredentialsAsync(
config.TenantId,
config.AgentBlueprintId);
Expand All @@ -155,28 +232,30 @@ private static Command CreateBlueprintCleanupCommand(
logger.LogInformation("Federated credentials deleted successfully");
}

// Delete the agent blueprint using the special Graph API endpoint
// Delete the agent blueprint
logger.LogInformation("");
logger.LogInformation("Deleting agent blueprint application...");
var deleted = await agentBlueprintService.DeleteAgentBlueprintAsync(
config.TenantId,
config.AgentBlueprintId);

if (!deleted)
{
logger.LogWarning("");
logger.LogWarning("Blueprint deletion failed.");
logger.LogWarning("Blueprint deletion failed. The blueprint still exists in Entra ID.");
PrintOrphanSummary(logger, failedResources);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1ES PT Branch Validation blocks official NuGet publish for GitHub‑hosted pipeline.

Scenario: one instance existed, its SP and agentic user were deleted successfully, but
then the blueprint deletion itself failed. Result: failedResources is empty, so
PrintOrphanSummary returns early. The user sees only "Blueprint deletion failed." with
no further guidance. The instances are already gone (their SP objects were deleted), but
the blueprint still exists in Entra ID. This is a partial-success state requiring explicit
user action.

  The test `CleanupBlueprint_WhenBlueprintDeletionFailsWithInstances_LogsWarning` (diff line
  ~739) exercises this exact path and only asserts that "Blueprint deletion failed." was
  logged, missing the guidance gap.

  Suggested fix: emit actionable guidance whenever blueprint deletion fails, regardless of
  orphaned resources:

  if (!deleted)
  {
      logger.LogWarning("");
      logger.LogWarning("Blueprint deletion failed. The blueprint still exists in Entra ID.");
      PrintOrphanSummary(logger, failedResources);
      if (!HasOrphanedResources(failedResources))
      {
          logger.LogWarning("All agent instances were deleted. Retry 'a365 cleanup blueprint' " +
              "or delete the blueprint manually via the Entra portal or Graph API.");
      }
      return;
  }      Scenario: one instance existed, its SP and agentic user were deleted successfully, but
  then the blueprint deletion itself failed. Result: `failedResources` is empty, so
  `PrintOrphanSummary` returns early. The user sees only "Blueprint deletion failed." with
  no further guidance. The instances are already gone (their SP objects were deleted), but
  the blueprint still exists in Entra ID. This is a partial-success state requiring explicit
  user action.


  Suggested fix: emit actionable guidance whenever blueprint deletion fails, regardless of
  orphaned resources:

  if (!deleted)
  {
      logger.LogWarning("");
      logger.LogWarning("Blueprint deletion failed. The blueprint still exists in Entra ID.");
      PrintOrphanSummary(logger, failedResources);
      if (!HasOrphanedResources(failedResources))
      {
          logger.LogWarning("All agent instances were deleted. Retry 'a365 cleanup blueprint' " +
              "or delete the blueprint manually via the Entra portal or Graph API.");
      }
      return;
  }

  Update `CleanupBlueprint_WhenBlueprintDeletionFailsWithInstances_LogsWarning` to assert
  that the retry guidance message is also emitted.

if (!HasOrphanedResources(failedResources))
{
logger.LogWarning("All agent instances were deleted. Retry 'a365 cleanup blueprint' or delete the blueprint manually via the Entra portal or Graph API.");
}
return;
}

// Blueprint deleted successfully
logger.LogInformation("Agent blueprint application deleted successfully");

// Handle endpoint deletion if needed using shared helper
if (!await DeleteMessagingEndpointAsync(logger, config, botConfigurator, correlationId: correlationId))
{
return;
Comment on lines 256 to 258
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If DeleteMessagingEndpointAsync fails, the handler returns immediately and skips PrintOrphanSummary. That can hide failed instance deletions (orphaned agentic users / identity SPs) from the user even though the blueprint was already deleted. Consider always printing the orphan summary before returning (or moving the summary emission into a finally-like block that runs regardless of endpoint deletion outcome).

Copilot uses AI. Check for mistakes.
}

Comment on lines 256 to 259
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If messaging endpoint deletion fails, the handler returns early, so any previously recorded instance deletion failures won't be summarized for the user. Consider calling PrintOrphanSummary before returning (or moving orphan reporting into a finally) so orphaned instance resources are always reported regardless of endpoint deletion outcome.

Copilot uses AI. Check for mistakes.
// Clear configuration after successful blueprint deletion
logger.LogInformation("");
Expand All @@ -188,8 +267,14 @@ private static Command CreateBlueprintCleanupCommand(

await configService.SaveStateAsync(config);
logger.LogInformation("Local configuration cleared");
logger.LogInformation("");
logger.LogInformation("Blueprint cleanup completed successfully!");

// Emit orphan summary if any instance deletions failed (PrintOrphanSummary returns early if none)
PrintOrphanSummary(logger, failedResources);
if (!HasOrphanedResources(failedResources))
{
logger.LogInformation("");
logger.LogInformation("Blueprint cleanup completed successfully!");
}
}
catch (Exception ex)
{
Expand Down Expand Up @@ -808,6 +893,37 @@ private static async Task ExecuteEndpointOnlyCleanupAsync(
logger.LogInformation("");
}

/// <summary>
/// Checks whether any instance deletions were recorded as failures.
/// </summary>
private static bool HasOrphanedResources(Dictionary<string, List<string>> failedResources)
{
return failedResources[AgenticUsersKey].Count + failedResources[IdentitySpsKey].Count > 0;
}

/// <summary>
/// Prints a summary of orphaned Entra ID resources that could not be deleted.
/// This should be called whenever instance deletions have failed, regardless of
/// whether the blueprint deletion itself succeeded or failed.
/// </summary>
private static void PrintOrphanSummary(
ILogger<CleanupCommand> logger,
Dictionary<string, List<string>> failedResources)
{
if (!HasOrphanedResources(failedResources))
{
return;
}

logger.LogWarning("Blueprint cleanup completed with warnings.");
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PrintOrphanSummary logs "Blueprint cleanup completed with warnings." but it's also called on the blueprint-deletion-failed path. In that case cleanup did not complete and the message is misleading. Consider rewording the message (e.g., "Blueprint cleanup encountered warnings"), or pass in a flag indicating whether the blueprint deletion succeeded so the summary text matches the actual state.

Suggested change
logger.LogWarning("Blueprint cleanup completed with warnings.");
logger.LogWarning("Blueprint cleanup encountered warnings.");

Copilot uses AI. Check for mistakes.
logger.LogWarning("The following resources could not be deleted and remain orphaned in Entra ID:");
foreach (var userId in failedResources[AgenticUsersKey])
logger.LogWarning(" Orphaned agentic user: {ResourceId}", userId);
foreach (var spId in failedResources[IdentitySpsKey])
logger.LogWarning(" Orphaned identity SP: {ResourceId}", spId);
logger.LogWarning("Delete them manually via the Entra portal or Graph API.");
}

private static async Task<Agent365Config?> LoadConfigAsync(
FileInfo? configFile,
ILogger<CleanupCommand> logger,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Microsoft.Agents.A365.DevTools.Cli.Models;

/// <summary>
/// Represents an agent instance linked to a blueprint, consisting of an agent identity
/// service principal and an optional agentic user.
/// </summary>
public sealed record AgentInstanceInfo
{
/// <summary>Graph object ID of the agent identity service principal.</summary>
public required string IdentitySpId { get; init; }

/// <summary>Display name of the identity service principal, shown in cleanup preview.</summary>
public string? DisplayName { get; init; }

/// <summary>Graph object ID of the linked agentic user, if one exists.</summary>
public string? AgentUserId { get; init; }
}
Loading