diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index e293a8e5..d3cb65f5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -287,6 +287,7 @@ public static Command CreateCommand( blueprintService, blueprintLookupService, federatedCredentialService, + skipEndpointRegistration: false, correlationId: correlationId ); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index 461b3a8b..30a92107 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -136,20 +136,56 @@ public static Command CreateCommand( "--endpoint-only", description: "Register messaging endpoint only (requires existing blueprint)"); + var updateEndpointOption = new Option( + "--update-endpoint", + description: "Delete the existing messaging endpoint and register a new one with the specified URL"); + command.AddOption(configOption); command.AddOption(verboseOption); command.AddOption(dryRunOption); command.AddOption(skipEndpointRegistrationOption); command.AddOption(endpointOnlyOption); + command.AddOption(updateEndpointOption); - command.SetHandler(async (config, verbose, dryRun, skipEndpointRegistration, endpointOnly) => + command.SetHandler(async (config, verbose, dryRun, skipEndpointRegistration, endpointOnly, updateEndpoint) => { // Generate correlation ID at workflow entry point var correlationId = HttpClientFactory.GenerateCorrelationId(); logger.LogInformation("Starting blueprint setup (CorrelationId: {CorrelationId})", correlationId); + // Validate mutually exclusive options + if (!ValidateMutuallyExclusiveOptions( + updateEndpoint: updateEndpoint, + endpointOnly: endpointOnly, + skipEndpointRegistration: skipEndpointRegistration, + logger: logger)) + { + Environment.Exit(1); + } + var setupConfig = await configService.LoadAsync(config.FullName); + // Handle --update-endpoint flag + if (!string.IsNullOrWhiteSpace(updateEndpoint)) + { + try + { + await UpdateEndpointAsync( + configPath: config.FullName, + newEndpointUrl: updateEndpoint, + logger: logger, + configService: configService, + botConfigurator: botConfigurator, + platformDetector: platformDetector); + } + catch (Exception ex) + { + logger.LogError(ex, "Endpoint update failed: {Message}", ex.Message); + Environment.Exit(1); + } + return; + } + if (dryRun) { logger.LogInformation("DRY RUN: Create Agent Blueprint"); @@ -216,11 +252,51 @@ await CreateBlueprintImplementationAsync( correlationId: correlationId ); - }, configOption, verboseOption, dryRunOption, skipEndpointRegistrationOption, endpointOnlyOption); + }, configOption, verboseOption, dryRunOption, skipEndpointRegistrationOption, endpointOnlyOption, updateEndpointOption); return command; } + /// + /// Validates that mutually exclusive command options are not used together. + /// + /// True if validation passes, false if conflicting options are detected. + internal static bool ValidateMutuallyExclusiveOptions( + string? updateEndpoint, + bool endpointOnly, + bool skipEndpointRegistration, + ILogger logger) + { + var hasUpdateEndpoint = !string.IsNullOrWhiteSpace(updateEndpoint); + + // --update-endpoint cannot be used with --endpoint-only or --no-endpoint + if (hasUpdateEndpoint) + { + if (endpointOnly) + { + logger.LogError("Options --update-endpoint and --endpoint-only cannot be used together."); + logger.LogError("Use --update-endpoint if the endpoint URL needs to be updated, otherwise use --endpoint-only to register a new endpoint."); + return false; + } + if (skipEndpointRegistration) + { + logger.LogError("Options --update-endpoint and --no-endpoint cannot be used together."); + logger.LogError("--update-endpoint updates an endpoint, which conflicts with --no-endpoint."); + return false; + } + } + + // --endpoint-only cannot be used with --no-endpoint + if (endpointOnly && skipEndpointRegistration) + { + logger.LogError("Options --endpoint-only and --no-endpoint cannot be used together."); + logger.LogError("--endpoint-only registers an endpoint, which conflicts with --no-endpoint."); + return false; + } + + return true; + } + public static async Task CreateBlueprintImplementationAsync( Models.Agent365Config setupConfig, FileInfo config, @@ -1556,7 +1632,7 @@ private static async Task ValidateClientSecretAsync( Environment.Exit(1); } - // Only validate webAppName if needDeployment is true + // Validate webAppName if needDeployment is true if (setupConfig.NeedDeployment && string.IsNullOrWhiteSpace(setupConfig.WebAppName)) { logger.LogError("Web App Name not found. Run 'a365 setup infrastructure' first."); @@ -1620,6 +1696,121 @@ await ProjectSettingsSyncHelper.ExecuteAsync( return (endpointRegistered, endpointAlreadyExisted); } + /// + /// Updates the messaging endpoint by deleting the existing one and registering a new one. + /// + /// Path to the configuration file + /// The new messaging endpoint URL + /// Logger instance + /// Configuration service + /// Bot configurator service + /// Platform detector service + public static async Task UpdateEndpointAsync( + string configPath, + string newEndpointUrl, + ILogger logger, + IConfigService configService, + IBotConfigurator botConfigurator, + PlatformDetector platformDetector) + { + var setupConfig = await configService.LoadAsync(configPath); + + // Validate blueprint ID exists + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) + { + logger.LogError("Blueprint ID not found. Please confirm agent blueprint id is in config file."); + throw new Exceptions.SetupValidationException("Agent Blueprint ID is required for endpoint update."); + } + + // Validate new endpoint URL + if (!Uri.TryCreate(newEndpointUrl, UriKind.Absolute, out var newUri) || + newUri.Scheme != Uri.UriSchemeHttps) + { + logger.LogError("New endpoint must be a valid HTTPS URL. Current value: {Endpoint}", newEndpointUrl); + throw new Exceptions.SetupValidationException("New endpoint must be a valid HTTPS URL."); + } + + logger.LogInformation("Updating messaging endpoint..."); + logger.LogInformation(""); + + // Step 1: Delete existing endpoint if it exists + if (!string.IsNullOrWhiteSpace(setupConfig.BotName)) + { + logger.LogInformation("Deleting existing messaging endpoint..."); + if (string.IsNullOrWhiteSpace(setupConfig.Location)) + { + logger.LogError("Location not found. Please confirm location is in the config file."); + throw new Exceptions.SetupValidationException("Location is required to delete the existing messaging endpoint."); + } + var endpointName = Services.Helpers.EndpointHelper.GetEndpointName(setupConfig.BotName); + var normalizedLocation = setupConfig.Location.Replace(" ", "").ToLowerInvariant(); + + var deleted = await botConfigurator.DeleteEndpointWithAgentBlueprintAsync( + endpointName, + normalizedLocation, + setupConfig.AgentBlueprintId); + + if (!deleted) + { + logger.LogError("Failed to delete existing messaging endpoint."); + throw new Exceptions.SetupValidationException("Failed to delete existing messaging endpoint. Cannot proceed with update."); + } + + logger.LogInformation("Existing endpoint deleted successfully."); + } + else + { + logger.LogInformation("No existing endpoint found. Proceeding with registration."); + } + + // Step 2: Register new endpoint with the provided URL + logger.LogInformation(""); + logger.LogInformation("Registering new messaging endpoint..."); + + var (endpointRegistered, _) = await SetupHelpers.RegisterBlueprintMessagingEndpointAsync( + setupConfig, logger, botConfigurator, newEndpointUrl); + + if (!endpointRegistered) + { + throw new Exceptions.SetupValidationException("Failed to register new messaging endpoint."); + } + + // Step 3: Save updated configuration + setupConfig.Completed = true; + setupConfig.CompletedAt = DateTime.UtcNow; + + await configService.SaveStateAsync(setupConfig); + + // Step 4: Sync to project settings + logger.LogInformation(""); + logger.LogInformation("Syncing configuration to project settings..."); + + var configFileInfo = new FileInfo(configPath); + var generatedConfigPath = Path.Combine( + configFileInfo.DirectoryName ?? Environment.CurrentDirectory, + "a365.generated.config.json"); + + try + { + await ProjectSettingsSyncHelper.ExecuteAsync( + a365ConfigPath: configPath, + a365GeneratedPath: generatedConfigPath, + configService: configService, + platformDetector: platformDetector, + logger: logger); + + logger.LogInformation("Configuration synced to project settings successfully"); + } + catch (Exception syncEx) + { + logger.LogWarning(syncEx, "Project settings sync failed (non-blocking). Please sync settings manually if needed."); + } + + logger.LogInformation(""); + logger.LogInformation("Endpoint update completed successfully!"); + logger.LogInformation("New endpoint: {Endpoint}", newEndpointUrl); + } + #region Private Helper Methods private static async Task CreateFederatedIdentityCredentialAsync( diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs index f52122c6..632b0cbb 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -424,10 +424,16 @@ public static async Task EnsureResourcePermissionsAsync( /// Register blueprint messaging endpoint /// Returns (success, alreadyExisted) /// + /// Agent365 configuration + /// Logger instance + /// Bot configurator service + /// Optional endpoint URL override (used by --update-endpoint to specify a new URL) + /// Optional correlation ID for tracing public static async Task<(bool success, bool alreadyExisted)> RegisterBlueprintMessagingEndpointAsync( Agent365Config setupConfig, ILogger logger, IBotConfigurator botConfigurator, + string? overrideEndpointUrl = null, string? correlationId = null) { // Validate required configuration @@ -453,7 +459,37 @@ public static async Task EnsureResourcePermissionsAsync( string messagingEndpoint; string endpointName; - if (setupConfig.NeedDeployment) + + // If override endpoint URL is provided (from --update-endpoint), use it + if (!string.IsNullOrWhiteSpace(overrideEndpointUrl)) + { + if (!Uri.TryCreate(overrideEndpointUrl, UriKind.Absolute, out var overrideUri) || + overrideUri.Scheme != Uri.UriSchemeHttps) + { + logger.LogError("Custom endpoint must be a valid HTTPS URL. Current value: {Endpoint}", overrideEndpointUrl); + throw new SetupValidationException("Custom endpoint must be a valid HTTPS URL."); + } + + messagingEndpoint = overrideEndpointUrl; + + // Derive endpoint name based on deployment mode + if (setupConfig.NeedDeployment && !string.IsNullOrWhiteSpace(setupConfig.WebAppName)) + { + // Azure deployment: use WebAppName for endpoint name + var baseEndpointName = $"{setupConfig.WebAppName}-endpoint"; + endpointName = EndpointHelper.GetEndpointName(baseEndpointName); + } + else + { + // Non-Azure hosting: derive from override endpoint host + var hostPart = overrideUri.Host.Replace('.', '-'); + var baseEndpointName = $"{hostPart}-endpoint"; + endpointName = EndpointHelper.GetEndpointName(baseEndpointName); + } + + logger.LogInformation(" - Using override endpoint URL"); + } + else if (setupConfig.NeedDeployment) { if (string.IsNullOrEmpty(setupConfig.WebAppName)) { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs index 52d9cb6c..9a13c2f4 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs @@ -1291,8 +1291,289 @@ public void DocumentParameterOrder_EnsureDelegatedConsentWithRetriesAsync() } #endregion -} + #region Update Endpoint Option Tests + + [Fact] + public void CreateCommand_ShouldHaveUpdateEndpointOption() + { + // Act + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector, + _mockBotConfigurator, + _mockGraphApiService, _mockBlueprintService, _mockClientAppValidator, _mockBlueprintLookupService, _mockFederatedCredentialService); + + // Assert + var updateEndpointOption = command.Options.FirstOrDefault(o => o.Name == "update-endpoint"); + updateEndpointOption.Should().NotBeNull(); + updateEndpointOption!.Aliases.Should().Contain("--update-endpoint"); + } + + [Fact] + public async Task UpdateEndpointAsync_WithValidUrl_ShouldDeleteAndRegisterEndpoint() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "blueprint-123", + WebAppName = "test-webapp", + Location = "eastus", + DeploymentProjectPath = Path.GetTempPath() + }; + + // Set BotName via reflection since it's a computed property + // We need to test with WebAppName set which derives BotName + var newEndpointUrl = "https://newhost.example.com/api/messages"; + var testId = Guid.NewGuid().ToString(); + var configPath = Path.Combine(Path.GetTempPath(), $"test-config-{testId}.json"); + var generatedPath = Path.Combine(Path.GetTempPath(), $"a365.generated.config-{testId}.json"); + + await File.WriteAllTextAsync(generatedPath, "{}"); + + try + { + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + _mockConfigService.SaveStateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + _mockBotConfigurator.CreateEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(EndpointRegistrationResult.Created); + + // Act + await BlueprintSubcommand.UpdateEndpointAsync( + configPath, + newEndpointUrl, + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert - Should call delete then create + await _mockBotConfigurator.Received(1).DeleteEndpointWithAgentBlueprintAsync( + Arg.Any(), + Arg.Any(), + config.AgentBlueprintId); + + await _mockBotConfigurator.Received(1).CreateEndpointWithAgentBlueprintAsync( + Arg.Any(), + Arg.Any(), + newEndpointUrl, + Arg.Any(), + config.AgentBlueprintId); + } + finally + { + if (File.Exists(generatedPath)) File.Delete(generatedPath); + if (File.Exists(configPath)) File.Delete(configPath); + } + } + + [Fact] + public async Task UpdateEndpointAsync_WithInvalidUrl_ShouldThrowException() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "blueprint-123", + WebAppName = "test-webapp", + Location = "eastus", + DeploymentProjectPath = Path.GetTempPath() + }; + + var invalidUrl = "http://not-https.example.com/api/messages"; // HTTP not HTTPS + var testId = Guid.NewGuid().ToString(); + var configPath = Path.Combine(Path.GetTempPath(), $"test-config-{testId}.json"); + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await BlueprintSubcommand.UpdateEndpointAsync( + configPath, + invalidUrl, + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector)); + + exception.Message.Should().Contain("HTTPS"); + } + + [Fact] + public async Task UpdateEndpointAsync_WhenDeleteFails_ShouldThrowAndNotRegister() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "blueprint-123", + WebAppName = "test-webapp", + Location = "eastus", + DeploymentProjectPath = Path.GetTempPath() + }; + + var newEndpointUrl = "https://newhost.example.com/api/messages"; + var testId = Guid.NewGuid().ToString(); + var configPath = Path.Combine(Path.GetTempPath(), $"test-config-{testId}.json"); + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); // Delete fails + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await BlueprintSubcommand.UpdateEndpointAsync( + configPath, + newEndpointUrl, + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector)); + + exception.Message.Should().Contain("delete"); + + // Should NOT attempt to register new endpoint + await _mockBotConfigurator.DidNotReceive().CreateEndpointWithAgentBlueprintAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task UpdateEndpointAsync_WithNoExistingEndpoint_ShouldSkipDeleteAndRegister() + { + // Arrange - Config without BotName (no existing endpoint) + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "blueprint-123", + // WebAppName not set, so BotName will be empty + Location = "eastus", + DeploymentProjectPath = Path.GetTempPath(), + NeedDeployment = false // Non-Azure hosting + }; + + var newEndpointUrl = "https://newhost.example.com/api/messages"; + var testId = Guid.NewGuid().ToString(); + var configPath = Path.Combine(Path.GetTempPath(), $"test-config-{testId}.json"); + var generatedPath = Path.Combine(Path.GetTempPath(), $"a365.generated.config-{testId}.json"); + + await File.WriteAllTextAsync(generatedPath, "{}"); + + try + { + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + _mockConfigService.SaveStateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + _mockBotConfigurator.CreateEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(EndpointRegistrationResult.Created); + // Act + await BlueprintSubcommand.UpdateEndpointAsync( + configPath, + newEndpointUrl, + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + // Assert - Should NOT call delete (no existing endpoint) + await _mockBotConfigurator.DidNotReceive().DeleteEndpointWithAgentBlueprintAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + + // Should still register the new endpoint + await _mockBotConfigurator.Received(1).CreateEndpointWithAgentBlueprintAsync( + Arg.Any(), + Arg.Any(), + newEndpointUrl, + Arg.Any(), + config.AgentBlueprintId); + } + finally + { + if (File.Exists(generatedPath)) File.Delete(generatedPath); + if (File.Exists(configPath)) File.Delete(configPath); + } + } + #endregion + + #region Mutually Exclusive Options Tests + + [Theory] + [InlineData("https://example.com", true, false)] // --update-endpoint with --endpoint-only + [InlineData("https://example.com", false, true)] // --update-endpoint with --no-endpoint + [InlineData(null, true, true)] // --endpoint-only with --no-endpoint + public void ValidateMutuallyExclusiveOptions_WithConflictingOptions_ShouldReturnFalseAndLogError( + string? updateEndpoint, bool endpointOnly, bool skipEndpointRegistration) + { + // Act + var result = BlueprintSubcommand.ValidateMutuallyExclusiveOptions( + updateEndpoint, + endpointOnly, + skipEndpointRegistration, + _mockLogger); + + // Assert + result.Should().BeFalse(); + _mockLogger.Received().Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("cannot be used together")), + Arg.Any(), + Arg.Any>()); + } + + [Theory] + [InlineData(null, true, false)] // --endpoint-only only + [InlineData(null, false, true)] // --no-endpoint only + [InlineData("https://example.com", false, false)] // --update-endpoint only + [InlineData(null, false, false)] // no options + public void ValidateMutuallyExclusiveOptions_WithCompatibleOptions_ShouldReturnTrue( + string? updateEndpoint, bool endpointOnly, bool skipEndpointRegistration) + { + // Act + var result = BlueprintSubcommand.ValidateMutuallyExclusiveOptions( + updateEndpoint, + endpointOnly, + skipEndpointRegistration, + _mockLogger); + + // Assert + result.Should().BeTrue(); + _mockLogger.DidNotReceive().Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("cannot be used together")), + Arg.Any(), + Arg.Any>()); + } + + #endregion +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs index b96d3af8..ee4fcd94 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs @@ -409,5 +409,3 @@ public async Task RequirementsSubcommand_WithCategoryFilter_RunsFilteredChecks() await _mockConfigService.Received(1).LoadAsync(Arg.Any(), Arg.Any()); } } - -