Skip to content

Commit 538f309

Browse files
committed
Merge with main.
2 parents ac390bf + 03d78a6 commit 538f309

8 files changed

Lines changed: 844 additions & 149 deletions

File tree

‎src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs‎

Lines changed: 146 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands;
1414

1515
public class CleanupCommand
1616
{
17+
private const string AgenticUsersKey = "agentic users";
18+
private const string IdentitySpsKey = "identity SPs";
19+
1720
public static Command CreateCommand(
1821
ILogger<CleanupCommand> logger,
1922
IConfigService configService,
@@ -51,7 +54,7 @@ public static Command CreateCommand(
5154
}, configOption, verboseOption);
5255

5356
// Add subcommands for granular control
54-
cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, federatedCredentialService));
57+
cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService));
5558
cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, executor));
5659
cleanupCommand.AddCommand(CreateInstanceCleanupCommand(logger, configService, executor));
5760

@@ -64,7 +67,9 @@ private static Command CreateBlueprintCleanupCommand(
6467
IBotConfigurator botConfigurator,
6568
CommandExecutor executor,
6669
AgentBlueprintService agentBlueprintService,
67-
FederatedCredentialService federatedCredentialService)
70+
IConfirmationProvider confirmationProvider,
71+
FederatedCredentialService federatedCredentialService,
72+
string? correlationId = null)
6873
{
6974
var command = new Command("blueprint", "Remove Entra ID blueprint application and service principal");
7075

@@ -111,41 +116,114 @@ private static Command CreateBlueprintCleanupCommand(
111116
return;
112117
}
113118

114-
// Full blueprint cleanup (original behavior)
119+
// Full blueprint cleanup with cascade instance deletion
115120
logger.LogInformation("Starting blueprint cleanup...");
116121

117-
// Check if there's actually a blueprint to clean up
118122
if (string.IsNullOrWhiteSpace(config.AgentBlueprintId))
119123
{
120124
logger.LogInformation("No blueprint application found to clean up");
121125
return;
122126
}
123127

128+
// Query for agent instances linked to this blueprint before showing preview
129+
logger.LogInformation("Querying for agent instances linked to blueprint...");
130+
List<AgentInstanceInfo> instances;
131+
try
132+
{
133+
instances = (await agentBlueprintService.GetAgentInstancesForBlueprintAsync(
134+
config.TenantId,
135+
config.AgentBlueprintId))?.ToList() ?? new List<AgentInstanceInfo>();
136+
}
137+
catch (Exception ex)
138+
{
139+
logger.LogError(ex, "Failed to query agent instances for blueprint {BlueprintId}. Aborting cleanup.", config.AgentBlueprintId);
140+
return;
141+
}
142+
143+
// Show preview
124144
logger.LogInformation("");
125145
logger.LogInformation("Blueprint Cleanup Preview:");
126146
logger.LogInformation("=============================");
127147
logger.LogInformation("Will delete Entra ID application: {BlueprintId}", config.AgentBlueprintId);
128148
logger.LogInformation(" Name: {DisplayName}", config.AgentBlueprintDisplayName);
149+
150+
if (instances.Count > 0)
151+
{
152+
logger.LogInformation("");
153+
logger.LogInformation("Will also delete {Count} agent instance(s) linked to this blueprint:", instances.Count);
154+
foreach (var instance in instances)
155+
{
156+
logger.LogInformation(" Instance: {DisplayName} (SP: {SpId})", instance.DisplayName ?? "(unnamed)", instance.IdentitySpId);
157+
if (!string.IsNullOrWhiteSpace(instance.AgentUserId))
158+
logger.LogInformation(" Agentic user: {UserId}", instance.AgentUserId);
159+
}
160+
}
161+
129162
logger.LogInformation("");
130163

131-
Console.Write("Continue with blueprint cleanup? (y/N): ");
132-
var response = Console.ReadLine()?.Trim().ToLowerInvariant();
133-
if (response != "y" && response != "yes")
164+
if (!await confirmationProvider.ConfirmAsync("Continue with blueprint cleanup? (y/N): "))
134165
{
135166
logger.LogInformation("Cleanup cancelled by user");
136167
return;
137168
}
138169

170+
// Delete instances first (warn and continue on failure)
171+
var failedResources = new Dictionary<string, List<string>>
172+
{
173+
[AgenticUsersKey] = new List<string>(),
174+
[IdentitySpsKey] = new List<string>()
175+
};
176+
177+
foreach (var instance in instances)
178+
{
179+
// Delete agentic user before identity SP
180+
if (!string.IsNullOrWhiteSpace(instance.AgentUserId))
181+
{
182+
logger.LogInformation("Deleting agentic user {UserId} for instance {DisplayName}...",
183+
instance.AgentUserId, instance.DisplayName ?? instance.IdentitySpId);
184+
185+
var userDeleted = await agentBlueprintService.DeleteAgentUserAsync(
186+
config.TenantId,
187+
instance.AgentUserId);
188+
189+
if (!userDeleted)
190+
{
191+
logger.LogWarning("Failed to delete agentic user {UserId} -- will continue", instance.AgentUserId);
192+
failedResources[AgenticUsersKey].Add(instance.AgentUserId!);
193+
}
194+
else
195+
{
196+
logger.LogInformation("Agentic user deleted");
197+
}
198+
}
199+
200+
// Delete identity SP
201+
logger.LogInformation("Deleting agent identity SP {SpId} for instance {DisplayName}...",
202+
instance.IdentitySpId, instance.DisplayName ?? instance.IdentitySpId);
203+
204+
var spDeleted = await agentBlueprintService.DeleteAgentIdentityAsync(
205+
config.TenantId,
206+
instance.IdentitySpId);
207+
208+
if (!spDeleted)
209+
{
210+
logger.LogWarning("Failed to delete agent identity SP {SpId} -- will continue", instance.IdentitySpId);
211+
failedResources[IdentitySpsKey].Add(instance.IdentitySpId);
212+
}
213+
else
214+
{
215+
logger.LogInformation("Agent identity SP deleted");
216+
}
217+
}
218+
139219
// Delete federated credentials first before deleting the blueprint
140220
logger.LogInformation("");
141221
logger.LogInformation("Deleting federated credentials from blueprint...");
142-
222+
143223
// Configure FederatedCredentialService with custom client app ID if available
144224
if (!string.IsNullOrWhiteSpace(config.ClientAppId))
145-
{
146225
federatedCredentialService.CustomClientAppId = config.ClientAppId;
147-
}
148-
226+
149227
var ficsDeleted = await federatedCredentialService.DeleteAllFederatedCredentialsAsync(
150228
config.TenantId,
151229
config.AgentBlueprintId);
@@ -160,25 +238,39 @@ private static Command CreateBlueprintCleanupCommand(
160238
logger.LogInformation("Federated credentials deleted successfully");
161239
}
162240

163-
// Delete the agent blueprint using the special Graph API endpoint
241+
// Delete the agent blueprint
164242
logger.LogInformation("");
165243
logger.LogInformation("Deleting agent blueprint application...");
166244
var deleted = await agentBlueprintService.DeleteAgentBlueprintAsync(
167245
config.TenantId,
168246
config.AgentBlueprintId);
169-
247+
170248
if (!deleted)
171249
{
172250
logger.LogWarning("");
173-
logger.LogWarning("Blueprint deletion failed.");
251+
logger.LogWarning("Blueprint deletion failed. The blueprint still exists in Entra ID.");
252+
PrintOrphanSummary(logger, failedResources);
253+
if (!HasOrphanedResources(failedResources))
254+
{
255+
logger.LogWarning("All agent instances were deleted. Retry 'a365 cleanup blueprint' or delete the blueprint manually via the Entra portal or Graph API.");
256+
}
174257
return;
175258
}
176259

177-
// Blueprint deleted successfully
178260
logger.LogInformation("Agent blueprint application deleted successfully");
179261

180-
// Handle endpoint deletion if needed using shared helper
181-
if (!await DeleteMessagingEndpointAsync(logger, config, botConfigurator, correlationId: correlationId))
262+
bool endpointDeleted = false;
263+
try
264+
{
265+
endpointDeleted = await DeleteMessagingEndpointAsync(logger, config, botConfigurator, correlationId: correlationId);
266+
}
267+
finally
268+
{
269+
// Always emit orphan summary before returning, regardless of endpoint deletion outcome
270+
PrintOrphanSummary(logger, failedResources);
271+
}
272+
273+
if (!endpointDeleted)
182274
{
183275
return;
184276
}
@@ -193,8 +285,12 @@ private static Command CreateBlueprintCleanupCommand(
193285

194286
await configService.SaveStateAsync(config);
195287
logger.LogInformation("Local configuration cleared");
196-
logger.LogInformation("");
197-
logger.LogInformation("Blueprint cleanup completed successfully!");
288+
289+
if (!HasOrphanedResources(failedResources))
290+
{
291+
logger.LogInformation("");
292+
logger.LogInformation("Blueprint cleanup completed successfully!");
293+
}
198294
}
199295
catch (Exception ex)
200296
{
@@ -823,6 +919,37 @@ private static async Task ExecuteEndpointOnlyCleanupAsync(
823919
logger.LogInformation("");
824920
}
825921

922+
/// <summary>
923+
/// Checks whether any instance deletions were recorded as failures.
924+
/// </summary>
925+
private static bool HasOrphanedResources(Dictionary<string, List<string>> failedResources)
926+
{
927+
return failedResources[AgenticUsersKey].Count + failedResources[IdentitySpsKey].Count > 0;
928+
}
929+
930+
/// <summary>
931+
/// Prints a summary of orphaned Entra ID resources that could not be deleted.
932+
/// This should be called whenever instance deletions have failed, regardless of
933+
/// whether the blueprint deletion itself succeeded or failed.
934+
/// </summary>
935+
private static void PrintOrphanSummary(
936+
ILogger<CleanupCommand> logger,
937+
Dictionary<string, List<string>> failedResources)
938+
{
939+
if (!HasOrphanedResources(failedResources))
940+
{
941+
return;
942+
}
943+
944+
logger.LogWarning("Blueprint cleanup encountered warnings.");
945+
logger.LogWarning("The following resources could not be deleted and remain orphaned in Entra ID:");
946+
foreach (var userId in failedResources[AgenticUsersKey])
947+
logger.LogWarning(" Orphaned agentic user: {ResourceId}", userId);
948+
foreach (var spId in failedResources[IdentitySpsKey])
949+
logger.LogWarning(" Orphaned identity SP: {ResourceId}", spId);
950+
logger.LogWarning("Delete them manually via the Entra portal or Graph API.");
951+
}
952+
826953
private static async Task<Agent365Config?> LoadConfigAsync(
827954
FileInfo? configFile,
828955
ILogger<CleanupCommand> logger,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Microsoft.Agents.A365.DevTools.Cli.Models;
5+
6+
/// <summary>
7+
/// Represents an agent instance linked to a blueprint, consisting of an agent identity
8+
/// service principal and an optional agentic user.
9+
/// </summary>
10+
public sealed record AgentInstanceInfo
11+
{
12+
/// <summary>Graph object ID of the agent identity service principal.</summary>
13+
public required string IdentitySpId { get; init; }
14+
15+
/// <summary>Display name of the identity service principal, shown in cleanup preview.</summary>
16+
public string? DisplayName { get; init; }
17+
18+
/// <summary>Graph object ID of the linked agentic user, if one exists.</summary>
19+
public string? AgentUserId { get; init; }
20+
}

0 commit comments

Comments
 (0)