Skip to content

Commit 72d0a47

Browse files
authored
Idempotent setup: blueprint/infrastructure discovery (#144)
* Idempotent setup: blueprint/infrastructure discovery Implement complete idempotency for a365 setup commands to handle repeated executions gracefully and provide accurate user feedback. Changes: - Add BlueprintLookupService for dual-path blueprint discovery (objectId primary, displayName fallback for migration scenarios) - Add FederatedCredentialService for FIC existence checks and creation - Persist blueprint objectIds (app + service principal) to config for authoritative future lookups - Track idempotency flags: InfrastructureAlreadyExisted, BlueprintAlreadyExisted, EndpointAlreadyExisted - Update setup summary to display "configured (already exists)" vs "created" Fixes: - Move endpoint error log after idempotency check (no more false ERR) - Convert verbose internal logs to LogDebug ("Saved dynamic state", auth checks) - Fix SaveStateAsync usage to prevent service principal ID overwrites Testing: - Add BlueprintLookupServiceTests (8 tests) - Add FederatedCredentialServiceTests (11 tests) - All 777/777 tests passing Wire new services through DI container and all setup subcommands. * Improve federated credential and consent handling in CLI - Cleanup now deletes federated credentials before blueprint removal, using new FederatedCredentialService methods with endpoint fallback. - FederatedCredentialService enhanced for robust listing, creation, deletion, and error handling; skips malformed entries and supports propagation retries. - Blueprint setup refactored to consistently handle federated credential creation/validation and admin consent, with consent checked before prompting. - Added AdminConsentHelper.CheckConsentExistsAsync to verify existing grants. - Improved idempotency tracking and summary logging for permissions. - Updated CleanupCommand and tests to require/use FederatedCredentialService. - Expanded tests for federated credential and consent logic. - Refined bot endpoint deletion to avoid false positives. - Improved logging and user feedback throughout blueprint and cleanup flows. * Refactor: move model classes to dedicated files Moved blueprint, federated credential, and password credential model classes from service classes to new files in the Models namespace for better organization. Updated services to use the new models. Improved logging for messaging endpoint setup and Azure CLI Python bitness detection. * Refactor FIC naming and logging; clean up retry logic - Update FIC naming to use display name only (no objectId), as uniqueness is per app, not tenant-wide. - Improve error logging for missing blueprint identifiers by removing null-coalescing fallback. - Remove unused variable assignment from federated credential retry logic.
1 parent 13a720f commit 72d0a47

29 files changed

+2786
-297
lines changed

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

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ public static Command CreateCommand(
1818
IBotConfigurator botConfigurator,
1919
CommandExecutor executor,
2020
AgentBlueprintService agentBlueprintService,
21-
IConfirmationProvider confirmationProvider)
21+
IConfirmationProvider confirmationProvider,
22+
FederatedCredentialService federatedCredentialService)
2223
{
2324
var cleanupCommand = new Command("cleanup", "Clean up ALL resources (blueprint, instance, Azure) - use subcommands for granular cleanup");
2425

@@ -40,11 +41,11 @@ public static Command CreateCommand(
4041
// Set default handler for 'a365 cleanup' (without subcommand) - cleans up everything
4142
cleanupCommand.SetHandler(async (configFile, verbose) =>
4243
{
43-
await ExecuteAllCleanupAsync(logger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, configFile);
44+
await ExecuteAllCleanupAsync(logger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, configFile);
4445
}, configOption, verboseOption);
4546

4647
// Add subcommands for granular control
47-
cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService));
48+
cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, federatedCredentialService));
4849
cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, executor));
4950
cleanupCommand.AddCommand(CreateInstanceCleanupCommand(logger, configService, executor));
5051

@@ -56,7 +57,8 @@ private static Command CreateBlueprintCleanupCommand(
5657
IConfigService configService,
5758
IBotConfigurator botConfigurator,
5859
CommandExecutor executor,
59-
AgentBlueprintService agentBlueprintService)
60+
AgentBlueprintService agentBlueprintService,
61+
FederatedCredentialService federatedCredentialService)
6062
{
6163
var command = new Command("blueprint", "Remove Entra ID blueprint application and service principal");
6264

@@ -124,7 +126,32 @@ private static Command CreateBlueprintCleanupCommand(
124126
return;
125127
}
126128

129+
// Delete federated credentials first before deleting the blueprint
130+
logger.LogInformation("");
131+
logger.LogInformation("Deleting federated credentials from blueprint...");
132+
133+
// Configure FederatedCredentialService with custom client app ID if available
134+
if (!string.IsNullOrWhiteSpace(config.ClientAppId))
135+
{
136+
federatedCredentialService.CustomClientAppId = config.ClientAppId;
137+
}
138+
139+
var ficsDeleted = await federatedCredentialService.DeleteAllFederatedCredentialsAsync(
140+
config.TenantId,
141+
config.AgentBlueprintId);
142+
143+
if (!ficsDeleted)
144+
{
145+
logger.LogWarning("Some federated credentials may not have been deleted successfully");
146+
logger.LogWarning("Continuing with blueprint deletion...");
147+
}
148+
else
149+
{
150+
logger.LogInformation("Federated credentials deleted successfully");
151+
}
152+
127153
// Delete the agent blueprint using the special Graph API endpoint
154+
logger.LogInformation("");
128155
logger.LogInformation("Deleting agent blueprint application...");
129156
var deleted = await agentBlueprintService.DeleteAgentBlueprintAsync(
130157
config.TenantId,
@@ -395,6 +422,7 @@ private static async Task ExecuteAllCleanupAsync(
395422
CommandExecutor executor,
396423
AgentBlueprintService agentBlueprintService,
397424
IConfirmationProvider confirmationProvider,
425+
FederatedCredentialService federatedCredentialService,
398426
FileInfo? configFile)
399427
{
400428
var cleanupSucceeded = false;
@@ -447,7 +475,34 @@ private static async Task ExecuteAllCleanupAsync(
447475

448476
logger.LogInformation("Starting complete cleanup...");
449477

450-
// 1. Delete agent blueprint application
478+
// 1. Delete federated credentials from agent blueprint (if exists)
479+
if (!string.IsNullOrWhiteSpace(config.AgentBlueprintId))
480+
{
481+
logger.LogInformation("Deleting federated credentials from blueprint...");
482+
483+
// Configure FederatedCredentialService with custom client app ID if available
484+
if (!string.IsNullOrWhiteSpace(config.ClientAppId))
485+
{
486+
federatedCredentialService.CustomClientAppId = config.ClientAppId;
487+
}
488+
489+
var ficsDeleted = await federatedCredentialService.DeleteAllFederatedCredentialsAsync(
490+
config.TenantId,
491+
config.AgentBlueprintId);
492+
493+
if (!ficsDeleted)
494+
{
495+
logger.LogWarning("Some federated credentials may not have been deleted successfully");
496+
logger.LogWarning("Continuing with blueprint deletion...");
497+
hasFailures = true;
498+
}
499+
else
500+
{
501+
logger.LogInformation("Federated credentials deleted successfully");
502+
}
503+
}
504+
505+
// 2. Delete agent blueprint application
451506
if (!string.IsNullOrWhiteSpace(config.AgentBlueprintId))
452507
{
453508
logger.LogInformation("Deleting agent blueprint application...");
@@ -467,7 +522,7 @@ private static async Task ExecuteAllCleanupAsync(
467522
}
468523
}
469524

470-
// 2. Delete agent identity application
525+
// 3. Delete agent identity application
471526
if (!string.IsNullOrWhiteSpace(config.AgenticAppId))
472527
{
473528
logger.LogInformation("Deleting agent identity application...");
@@ -488,15 +543,15 @@ private static async Task ExecuteAllCleanupAsync(
488543
}
489544
}
490545

491-
// 3. Delete agent user
546+
// 4. Delete agent user
492547
if (!string.IsNullOrWhiteSpace(config.AgenticUserId))
493548
{
494549
logger.LogInformation("Deleting agent user...");
495550
await executor.ExecuteAsync("az", $"ad user delete --id {config.AgenticUserId}", null, true, false, CancellationToken.None);
496551
logger.LogInformation("Agent user deleted");
497552
}
498553

499-
// 4. Delete bot messaging endpoint using shared helper
554+
// 5. Delete bot messaging endpoint using shared helper
500555
if (!string.IsNullOrWhiteSpace(config.BotName))
501556
{
502557
var endpointDeleted = await DeleteMessagingEndpointAsync(logger, config, botConfigurator);
@@ -506,7 +561,7 @@ private static async Task ExecuteAllCleanupAsync(
506561
}
507562
}
508563

509-
// 5. Delete Azure resources (Web App and App Service Plan)
564+
// 6. Delete Azure resources (Web App and App Service Plan)
510565
if (!string.IsNullOrWhiteSpace(config.WebAppName) && !string.IsNullOrWhiteSpace(config.ResourceGroup))
511566
{
512567
logger.LogInformation("Deleting Azure resources...");

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ public static Command CreateCommand(
2525
PlatformDetector platformDetector,
2626
GraphApiService graphApiService,
2727
AgentBlueprintService blueprintService,
28+
BlueprintLookupService blueprintLookupService,
29+
FederatedCredentialService federatedCredentialService,
2830
IClientAppValidator clientAppValidator)
2931
{
3032
var command = new Command("setup",
@@ -47,13 +49,13 @@ public static Command CreateCommand(
4749
logger, configService, azureValidator, webAppCreator, platformDetector, executor));
4850

4951
command.AddCommand(BlueprintSubcommand.CreateCommand(
50-
logger, configService, executor, azureValidator, webAppCreator, platformDetector, botConfigurator, graphApiService, blueprintService, clientAppValidator));
52+
logger, configService, executor, azureValidator, webAppCreator, platformDetector, botConfigurator, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService));
5153

5254
command.AddCommand(PermissionsSubcommand.CreateCommand(
5355
logger, configService, executor, graphApiService, blueprintService));
5456

5557
command.AddCommand(AllSubcommand.CreateCommand(
56-
logger, configService, executor, botConfigurator, azureValidator, webAppCreator, platformDetector, graphApiService, blueprintService, clientAppValidator));
58+
logger, configService, executor, botConfigurator, azureValidator, webAppCreator, platformDetector, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService));
5759

5860
return command;
5961
}

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs

Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ public static Command CreateCommand(
2929
PlatformDetector platformDetector,
3030
GraphApiService graphApiService,
3131
AgentBlueprintService blueprintService,
32-
IClientAppValidator clientAppValidator)
32+
IClientAppValidator clientAppValidator,
33+
BlueprintLookupService blueprintLookupService,
34+
FederatedCredentialService federatedCredentialService)
3335
{
3436
var command = new Command("all",
3537
"Run complete Agent 365 setup (all steps in sequence)\n" +
@@ -134,9 +136,7 @@ public static Command CreateCommand(
134136
// PHASE 0: CHECK REQUIREMENTS (if not skipped)
135137
if (!skipRequirements)
136138
{
137-
logger.LogInformation("Step 0: Requirements Check");
138-
logger.LogInformation("Validating system prerequisites...");
139-
logger.LogInformation("");
139+
logger.LogDebug("Validating system prerequisites...");
140140

141141
try
142142
{
@@ -164,53 +164,51 @@ public static Command CreateCommand(
164164
}
165165
else
166166
{
167-
logger.LogInformation("Skipping requirements validation (--skip-requirements flag used)");
168-
logger.LogInformation("");
167+
logger.LogDebug("Skipping requirements validation (--skip-requirements flag used)");
169168
}
170169

171170
// PHASE 1: VALIDATE ALL PREREQUISITES UPFRONT
172-
logger.LogInformation("Validating all prerequisites...");
173-
logger.LogInformation("");
171+
logger.LogDebug("Validating all prerequisites...");
174172

175173
var allErrors = new List<string>();
176174

177175
// Validate Azure CLI authentication first
178-
logger.LogInformation("Validating Azure CLI authentication...");
176+
logger.LogDebug("Validating Azure CLI authentication...");
179177
if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId))
180178
{
181179
allErrors.Add("Azure CLI authentication failed or subscription not set correctly");
182180
logger.LogError("Azure CLI authentication validation failed");
183181
}
184182
else
185183
{
186-
logger.LogInformation("Azure CLI authentication: OK");
184+
logger.LogDebug("Azure CLI authentication: OK");
187185
}
188186

189187
// Validate Infrastructure prerequisites
190188
if (!skipInfrastructure && setupConfig.NeedDeployment)
191189
{
192-
logger.LogInformation("Validating Infrastructure prerequisites...");
190+
logger.LogDebug("Validating Infrastructure prerequisites...");
193191
var infraErrors = await InfrastructureSubcommand.ValidateAsync(setupConfig, azureValidator, CancellationToken.None);
194192
if (infraErrors.Count > 0)
195193
{
196194
allErrors.AddRange(infraErrors.Select(e => $"Infrastructure: {e}"));
197195
}
198196
else
199197
{
200-
logger.LogInformation("Infrastructure prerequisites: OK");
198+
logger.LogDebug("Infrastructure prerequisites: OK");
201199
}
202200
}
203201

204202
// Validate Blueprint prerequisites
205-
logger.LogInformation("Validating Blueprint prerequisites...");
203+
logger.LogDebug("Validating Blueprint prerequisites...");
206204
var blueprintErrors = await BlueprintSubcommand.ValidateAsync(setupConfig, azureValidator, clientAppValidator, CancellationToken.None);
207205
if (blueprintErrors.Count > 0)
208206
{
209207
allErrors.AddRange(blueprintErrors.Select(e => $"Blueprint: {e}"));
210208
}
211209
else
212210
{
213-
logger.LogInformation("Blueprint prerequisites: OK");
211+
logger.LogDebug("Blueprint prerequisites: OK");
214212
}
215213

216214
// Stop if any validation failed
@@ -229,9 +227,7 @@ public static Command CreateCommand(
229227
return;
230228
}
231229

232-
logger.LogInformation("");
233-
logger.LogInformation("All validations passed. Starting setup execution...");
234-
logger.LogInformation("");
230+
logger.LogDebug("All validations passed. Starting setup execution...");
235231

236232
var generatedConfigPath = Path.Combine(
237233
config.DirectoryName ?? Environment.CurrentDirectory,
@@ -240,10 +236,8 @@ public static Command CreateCommand(
240236
// Step 1: Infrastructure (optional)
241237
try
242238
{
243-
logger.LogInformation("Step 1:");
244-
logger.LogInformation("");
245239

246-
bool setupInfra = await InfrastructureSubcommand.CreateInfrastructureImplementationAsync(
240+
var (setupInfra, infraAlreadyExisted) = await InfrastructureSubcommand.CreateInfrastructureImplementationAsync(
247241
logger,
248242
config.FullName,
249243
generatedConfigPath,
@@ -254,6 +248,7 @@ public static Command CreateCommand(
254248
CancellationToken.None);
255249

256250
setupResults.InfrastructureCreated = skipInfrastructure ? false : setupInfra;
251+
setupResults.InfrastructureAlreadyExisted = infraAlreadyExisted;
257252
}
258253
catch (Agent365Exception infraEx)
259254
{
@@ -270,10 +265,6 @@ public static Command CreateCommand(
270265
}
271266

272267
// Step 2: Blueprint
273-
logger.LogInformation("");
274-
logger.LogInformation("Step 2:");
275-
logger.LogInformation("");
276-
277268
try
278269
{
279270
var result = await BlueprintSubcommand.CreateBlueprintImplementationAsync(
@@ -288,11 +279,15 @@ public static Command CreateCommand(
288279
botConfigurator,
289280
platformDetector,
290281
graphApiService,
291-
blueprintService
282+
blueprintService,
283+
blueprintLookupService,
284+
federatedCredentialService
292285
);
293286

294287
setupResults.BlueprintCreated = result.BlueprintCreated;
288+
setupResults.BlueprintAlreadyExisted = result.BlueprintAlreadyExisted;
295289
setupResults.MessagingEndpointRegistered = result.EndpointRegistered;
290+
setupResults.EndpointAlreadyExisted = result.EndpointAlreadyExisted;
296291

297292
if (result.EndpointAlreadyExisted)
298293
{
@@ -350,10 +345,6 @@ public static Command CreateCommand(
350345
}
351346

352347
// Step 3: MCP Permissions
353-
logger.LogInformation("");
354-
logger.LogInformation("Step 3:");
355-
logger.LogInformation("");
356-
357348
try
358349
{
359350
bool mcpPermissionSetup = await PermissionsSubcommand.ConfigureMcpPermissionsAsync(
@@ -381,11 +372,6 @@ public static Command CreateCommand(
381372
}
382373

383374
// Step 4: Bot API Permissions
384-
385-
logger.LogInformation("");
386-
logger.LogInformation("Step 4:");
387-
logger.LogInformation("");
388-
389375
try
390376
{
391377
bool botPermissionSetup = await PermissionsSubcommand.ConfigureBotPermissionsAsync(

0 commit comments

Comments
 (0)