@@ -14,6 +14,9 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands;
1414
1515public 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 ,
0 commit comments