diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index cbb8013f3d..6640fa12ee 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -1085,7 +1085,7 @@ }, "description": "DEPRECATED. Use the 'fields' array and set 'primary-key: true' on each key field instead. List of fields to be used as primary keys." }, - "object-description": { + "description": { "type": "string", "description": "Human-readable description of the database object, used for MCP tool discovery and documentation." } diff --git a/src/Cli.Tests/AddEntityTests.cs b/src/Cli.Tests/AddEntityTests.cs index e96d131880..9feecd308c 100644 --- a/src/Cli.Tests/AddEntityTests.cs +++ b/src/Cli.Tests/AddEntityTests.cs @@ -43,6 +43,8 @@ public Task AddNewEntityWhenEntitiesEmpty() policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -80,6 +82,8 @@ public Task AddNewEntityWhenEntitiesNotEmpty() policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -120,6 +124,8 @@ public void AddDuplicateEntity() policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -164,6 +170,8 @@ public Task AddEntityWithAnExistingNameButWithDifferentCase() policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -203,6 +211,8 @@ public Task AddEntityWithCachingEnabled() policyDatabase: null, cacheEnabled: "true", cacheTtl: "1", + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -249,6 +259,8 @@ public Task AddEntityWithPolicyAndFieldProperties( config: TEST_RUNTIME_CONFIG_FILE, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, parametersNameCollection: null, @@ -290,6 +302,8 @@ public Task AddNewEntityWhenEntitiesWithSourceAsStoredProcedure() config: TEST_RUNTIME_CONFIG_FILE, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, parametersNameCollection: null, @@ -329,6 +343,8 @@ public Task TestAddStoredProcedureWithRestMethodsAndGraphQLOperations() policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: new string[] { "Post", "Put", "Patch" }, graphQLOperationForStoredProcedure: "Query", @@ -365,6 +381,8 @@ public void AddEntityWithDescriptionAndVerifyInConfig() policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -428,6 +446,8 @@ public void TestAddNewEntityWithSourceObjectHavingValidFields( policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -496,6 +516,8 @@ public Task TestAddNewSpWithDifferentRestAndGraphQLOptions( policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: restMethods, graphQLOperationForStoredProcedure: graphQLOperation, @@ -540,6 +562,8 @@ public void TestAddStoredProcedureWithConflictingRestGraphQLOptions( policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: restMethods, graphQLOperationForStoredProcedure: graphQLOperation, @@ -587,6 +611,8 @@ public void TestAddEntityPermissionWithInvalidOperation(IEnumerable perm policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -660,6 +686,8 @@ public Task AddTableEntityWithMcpDmlTools(string mcpDmlTools, string source, str policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -702,6 +730,8 @@ public Task AddStoredProcedureWithMcpCustomToolEnabled() policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -741,6 +771,8 @@ public Task AddStoredProcedureWithBothMcpProperties() policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -780,6 +812,8 @@ public Task AddStoredProcedureWithBothMcpPropertiesEnabled() policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -819,6 +853,8 @@ public void AddTableEntityWithInvalidMcpCustomTool() policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -865,6 +901,8 @@ public void AddEntityWithInvalidMcpOptions(string? mcpDmlTools, string? mcpCusto policyDatabase: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index 5824b2e054..4ae8cca371 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -1590,5 +1590,269 @@ public void TestUpdateUserDelegatedAuthDatabaseAudience() Assert.AreEqual(true, (bool?)userDelegatedAuthSection["enabled"]); Assert.AreEqual("EntraId", (string?)userDelegatedAuthSection["provider"]); } + + /// + /// Tests adding pagination options to a config that doesn't have a pagination section. + /// Command: dab configure --runtime.pagination.max-page-size 500 --runtime.pagination.default-page-size 50 --runtime.pagination.next-link-relative true + /// + [DataTestMethod] + [DataRow(500, 50, true, DisplayName = "Set all pagination options")] + [DataRow(1000, null, null, DisplayName = "Set only max-page-size")] + [DataRow(null, 25, null, DisplayName = "Set only default-page-size")] + [DataRow(null, null, true, DisplayName = "Set only next-link-relative")] + public void TestConfigurePaginationOptions(int? maxPageSize, int? defaultPageSize, bool? nextLinkRelative) + { + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + ConfigureOptions options = new( + runtimePaginationMaxPageSize: maxPageSize, + runtimePaginationDefaultPageSize: defaultPageSize, + runtimePaginationNextLinkRelative: nextLinkRelative, + config: TEST_RUNTIME_CONFIG_FILE + ); + + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); + Assert.IsNotNull(config.Runtime); + Assert.IsNotNull(config.Runtime.Pagination); + + if (maxPageSize.HasValue) + { + Assert.AreEqual(maxPageSize.Value, config.Runtime.Pagination.MaxPageSize); + } + + if (defaultPageSize.HasValue) + { + Assert.AreEqual(defaultPageSize.Value, config.Runtime.Pagination.DefaultPageSize); + } + + if (nextLinkRelative.HasValue) + { + Assert.AreEqual(nextLinkRelative.Value, config.Runtime.Pagination.NextLinkRelative); + } + } + + /// + /// Tests adding runtime health options to a config that doesn't have a health section. + /// The enabled flag must be explicitly set for health options to persist in config. + /// Command: dab configure --runtime.health.enabled true --runtime.health.cache-ttl-seconds 10 --runtime.health.max-query-parallelism 2 --runtime.health.roles "admin,monitor" + /// + [DataTestMethod] + [DataRow(true, 10, 2, new string[] { "admin", "monitor" }, DisplayName = "Set all runtime health options")] + [DataRow(false, null, null, null, DisplayName = "Disable runtime health")] + [DataRow(true, 30, null, null, DisplayName = "Enable health with custom cache-ttl")] + [DataRow(true, null, 8, null, DisplayName = "Enable health with custom max-query-parallelism")] + [DataRow(true, null, null, new string[] { "admin" }, DisplayName = "Enable health with roles")] + public void TestConfigureRuntimeHealthOptions(bool? enabled, int? cacheTtlSeconds, int? maxQueryParallelism, string[]? roles) + { + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + ConfigureOptions options = new( + runtimeHealthEnabled: enabled, + runtimeHealthCacheTtlSeconds: cacheTtlSeconds, + runtimeHealthMaxQueryParallelism: maxQueryParallelism, + runtimeHealthRoles: roles, + config: TEST_RUNTIME_CONFIG_FILE + ); + + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); + Assert.IsNotNull(config.Runtime); + Assert.IsNotNull(config.Runtime.Health); + + if (enabled.HasValue) + { + Assert.AreEqual(enabled.Value, config.Runtime.Health.Enabled); + } + + if (cacheTtlSeconds.HasValue) + { + Assert.AreEqual(cacheTtlSeconds.Value, config.Runtime.Health.CacheTtlSeconds); + } + + if (maxQueryParallelism.HasValue) + { + Assert.AreEqual(maxQueryParallelism.Value, config.Runtime.Health.MaxQueryParallelism); + } + + if (roles is not null) + { + Assert.IsNotNull(config.Runtime.Health.Roles); + CollectionAssert.AreEquivalent(roles, config.Runtime.Health.Roles.ToArray()); + } + } + + /// + /// Tests adding host max-response-size-mb to a config. + /// Command: dab configure --runtime.host.max-response-size-mb 50 + /// + [DataTestMethod] + [DataRow(50, DisplayName = "Set max-response-size-mb to 50")] + [DataRow(1, DisplayName = "Set max-response-size-mb to 1")] + [DataRow(-1, DisplayName = "Set max-response-size-mb to -1 (engine max)")] + public void TestConfigureHostMaxResponseSizeMb(int maxResponseSizeMb) + { + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + ConfigureOptions options = new( + runtimeHostMaxResponseSizeMb: maxResponseSizeMb, + config: TEST_RUNTIME_CONFIG_FILE + ); + + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); + Assert.IsNotNull(config.Runtime); + Assert.IsNotNull(config.Runtime.Host); + Assert.IsNotNull(config.Runtime.Host.MaxResponseSizeMB); + Assert.IsTrue(config.Runtime.Host.UserProvidedMaxResponseSizeMB); + } + + /// + /// Tests adding data-source-files to a config. + /// Command: dab configure --data-source-files "config1.json,config2.json" + /// + [TestMethod] + public void TestConfigureDataSourceFiles() + { + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + string[] files = new[] { "config1.json", "config2.json" }; + ConfigureOptions options = new( + dataSourceFiles: files, + config: TEST_RUNTIME_CONFIG_FILE + ); + + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); + Assert.IsNotNull(config.DataSourceFiles); + Assert.IsNotNull(config.DataSourceFiles.SourceFiles); + CollectionAssert.AreEquivalent(files, config.DataSourceFiles.SourceFiles.ToArray()); + } + + /// + /// Tests adding data-source.health.enabled and data-source.health.threshold-ms to a config. + /// Command: dab configure --data-source.health.enabled false --data-source.health.threshold-ms 2000 + /// + [DataTestMethod] + [DataRow(false, 2000, DisplayName = "Disable data source health with custom threshold")] + [DataRow(true, null, DisplayName = "Enable data source health with default threshold")] + [DataRow(null, 500, DisplayName = "Set only threshold-ms")] + public void TestConfigureDataSourceHealthOptions(bool? enabled, int? thresholdMs) + { + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + ConfigureOptions options = new( + dataSourceHealthEnabled: enabled, + dataSourceHealthThresholdMs: thresholdMs, + config: TEST_RUNTIME_CONFIG_FILE + ); + + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); + Assert.IsNotNull(config.DataSource); + Assert.IsNotNull(config.DataSource.Health); + + if (enabled.HasValue) + { + Assert.AreEqual(enabled.Value, config.DataSource.Health.Enabled); + } + + if (thresholdMs.HasValue) + { + Assert.AreEqual(thresholdMs.Value, config.DataSource.Health.ThresholdMs); + } + } + + /// + /// Tests adding data-source.user-delegated-auth.provider to a config. + /// Command: dab configure --data-source.user-delegated-auth.provider "EntraId" + /// + [TestMethod] + public void TestConfigureUserDelegatedAuthProvider() + { + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + ConfigureOptions options = new( + dataSourceUserDelegatedAuthEnabled: true, + dataSourceUserDelegatedAuthProvider: "EntraId", + config: TEST_RUNTIME_CONFIG_FILE + ); + + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); + Assert.IsNotNull(config.DataSource); + Assert.IsNotNull(config.DataSource.UserDelegatedAuth); + Assert.AreEqual("EntraId", config.DataSource.UserDelegatedAuth.Provider); + Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled); + } + + /// + /// Tests adding telemetry log-level configuration. + /// Command: dab configure --runtime.telemetry.log-level "Microsoft.AspNetCore:Warning,Default:Information" + /// + [DataTestMethod] + [DataRow(new string[] { "Microsoft.AspNetCore:Warning" }, DisplayName = "Set single log level")] + [DataRow(new string[] { "Microsoft.AspNetCore:Warning", "Default:Information" }, DisplayName = "Set multiple log levels")] + public void TestConfigureTelemetryLogLevel(string[] logLevels) + { + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + ConfigureOptions options = new( + runtimeTelemetryLogLevel: logLevels, + config: TEST_RUNTIME_CONFIG_FILE + ); + + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); + Assert.IsNotNull(config.Runtime); + Assert.IsNotNull(config.Runtime.Telemetry); + Assert.IsNotNull(config.Runtime.Telemetry.LoggerLevel); + + foreach (string entry in logLevels) + { + string[] parts = entry.Split(':'); + Assert.IsTrue(config.Runtime.Telemetry.LoggerLevel.ContainsKey(parts[0])); + } + } + + /// + /// Tests that an empty namespace in log-level input is rejected. + /// Command: dab configure --runtime.telemetry.log-level ":Warning" + /// + [TestMethod] + public void TestConfigureTelemetryLogLevelRejectsEmptyNamespace() + { + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + ConfigureOptions options = new( + runtimeTelemetryLogLevel: new string[] { ":Warning" }, + config: TEST_RUNTIME_CONFIG_FILE + ); + + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + Assert.IsFalse(isSuccess); + } } } diff --git a/src/Cli.Tests/UpdateEntityTests.cs b/src/Cli.Tests/UpdateEntityTests.cs index 42f78ab126..fa9cb55ef7 100644 --- a/src/Cli.Tests/UpdateEntityTests.cs +++ b/src/Cli.Tests/UpdateEntityTests.cs @@ -1158,6 +1158,8 @@ public void TestUpdateFieldDescriptionPrimaryKeyBehavior(bool? primaryKeyFlag, b map: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -1246,6 +1248,8 @@ private static UpdateOptions GenerateBaseUpdateOptions( string? graphQLOperationForStoredProcedure = null, string? cacheEnabled = null, string? cacheTtl = null, + string? cacheLevel = null, + string? healthEnabled = null, string? description = null, string? mcpDmlTools = null, string? mcpCustomTool = null @@ -1274,6 +1278,8 @@ private static UpdateOptions GenerateBaseUpdateOptions( map: map, cacheEnabled: cacheEnabled, cacheTtl: cacheTtl, + cacheLevel: cacheLevel, + healthEnabled: healthEnabled, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: restMethodsForStoredProcedure, graphQLOperationForStoredProcedure: graphQLOperationForStoredProcedure, @@ -1336,6 +1342,8 @@ public Task TestUpdateTableEntityWithMcpDmlTools(string newMcpDmlTools, string i map: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -1402,6 +1410,8 @@ public Task TestUpdateStoredProcedureWithMcpCustomToolEnabled() map: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -1471,6 +1481,8 @@ public Task TestUpdateStoredProcedureWithBothMcpProperties() map: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -1541,6 +1553,8 @@ public Task TestUpdateStoredProcedureWithBothMcpPropertiesEnabled() map: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -1610,6 +1624,8 @@ public void TestUpdateTableEntityWithInvalidMcpCustomTool() map: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -1678,6 +1694,8 @@ public void TestUpdateEntityWithInvalidMcpOptions(string? mcpDmlTools, string? m map: null, cacheEnabled: null, cacheTtl: null, + cacheLevel: null, + healthEnabled: null, config: TEST_RUNTIME_CONFIG_FILE, restMethodsForStoredProcedure: null, graphQLOperationForStoredProcedure: null, @@ -1715,5 +1733,87 @@ public void TestUpdateEntityWithInvalidMcpOptions(string? mcpDmlTools, string? m } #endregion MCP Entity Configuration Tests + + #region Entity Cache Level and Health Tests + + /// + /// Tests updating an entity's cache level. + /// Command: dab update MyEntity --cache.level L1L2 + /// + [DataTestMethod] + [DataRow("L1", DisplayName = "Set cache level to L1")] + [DataRow("L1L2", DisplayName = "Set cache level to L1L2")] + public void TestUpdateEntityCacheLevel(string level) + { + UpdateOptions options = GenerateBaseUpdateOptions( + source: "MyTable", + cacheEnabled: "true", + cacheLevel: level + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ], + ""cache"": { + ""enabled"": false, + ""ttl-seconds"": 5 + } + } + } + }"; + + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(initialConfig, out RuntimeConfig? runtimeConfig), "Parsed config file."); + Assert.IsTrue(TryUpdateExistingEntity(options, runtimeConfig!, out RuntimeConfig updatedRuntimeConfig), "Successfully updated entity in the config."); + + Entity updatedEntity = updatedRuntimeConfig.Entities["MyEntity"]; + Assert.IsNotNull(updatedEntity.Cache); + Assert.IsTrue(updatedEntity.Cache.Enabled); + Assert.AreEqual(Enum.Parse(level, ignoreCase: true), updatedEntity.Cache.Level); + } + + /// + /// Tests updating an entity's health enabled setting. + /// Command: dab update MyEntity --health.enabled true + /// + [DataTestMethod] + [DataRow("true", DisplayName = "Enable entity health")] + [DataRow("false", DisplayName = "Disable entity health")] + public void TestUpdateEntityHealthEnabled(string healthEnabled) + { + UpdateOptions options = GenerateBaseUpdateOptions( + source: "MyTable", + healthEnabled: healthEnabled + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ] + } + } + }"; + + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(initialConfig, out RuntimeConfig? runtimeConfig), "Parsed config file."); + Assert.IsTrue(TryUpdateExistingEntity(options, runtimeConfig!, out RuntimeConfig updatedRuntimeConfig), "Successfully updated entity in the config."); + + Entity updatedEntity = updatedRuntimeConfig.Entities["MyEntity"]; + Assert.IsNotNull(updatedEntity.Health); + Assert.AreEqual(bool.Parse(healthEnabled), updatedEntity.Health.Enabled); + } + + #endregion Entity Cache Level and Health Tests } } diff --git a/src/Cli/Commands/AddOptions.cs b/src/Cli/Commands/AddOptions.cs index e7e378d94b..01ba76eb30 100644 --- a/src/Cli/Commands/AddOptions.cs +++ b/src/Cli/Commands/AddOptions.cs @@ -34,6 +34,8 @@ public AddOptions( string? policyDatabase, string? cacheEnabled, string? cacheTtl, + string? cacheLevel, + string? healthEnabled, string? description, IEnumerable? parametersNameCollection, IEnumerable? parametersDescriptionCollection, @@ -62,6 +64,8 @@ public AddOptions( policyDatabase, cacheEnabled, cacheTtl, + cacheLevel, + healthEnabled, description, parametersNameCollection, parametersDescriptionCollection, diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index be076d7983..8557afc415 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -31,6 +31,10 @@ public ConfigureOptions( string? dataSourceHealthName = null, bool? dataSourceUserDelegatedAuthEnabled = null, string? dataSourceUserDelegatedAuthDatabaseAudience = null, + string? dataSourceUserDelegatedAuthProvider = null, + bool? dataSourceHealthEnabled = null, + int? dataSourceHealthThresholdMs = null, + IEnumerable? dataSourceFiles = null, int? depthLimit = null, bool? runtimeGraphQLEnabled = null, string? runtimeGraphQLPath = null, @@ -53,13 +57,21 @@ public ConfigureOptions( int? runtimeMcpDmlToolsAggregateRecordsQueryTimeout = null, bool? runtimeCacheEnabled = null, int? runtimeCacheTtl = null, + int? runtimePaginationMaxPageSize = null, + int? runtimePaginationDefaultPageSize = null, + bool? runtimePaginationNextLinkRelative = null, CompressionLevel? runtimeCompressionLevel = null, + bool? runtimeHealthEnabled = null, + int? runtimeHealthCacheTtlSeconds = null, + int? runtimeHealthMaxQueryParallelism = null, + IEnumerable? runtimeHealthRoles = null, HostMode? runtimeHostMode = null, IEnumerable? runtimeHostCorsOrigins = null, bool? runtimeHostCorsAllowCredentials = null, string? runtimeHostAuthenticationProvider = null, string? runtimeHostAuthenticationJwtAudience = null, string? runtimeHostAuthenticationJwtIssuer = null, + int? runtimeHostMaxResponseSizeMb = null, string? azureKeyVaultEndpoint = null, AKVRetryPolicyMode? azureKeyVaultRetryPolicyMode = null, int? azureKeyVaultRetryPolicyMaxCount = null, @@ -77,6 +89,7 @@ public ConfigureOptions( RollingInterval? fileSinkRollingInterval = null, int? fileSinkRetainedFileCountLimit = null, long? fileSinkFileSizeLimitBytes = null, + IEnumerable? runtimeTelemetryLogLevel = null, bool showEffectivePermissions = false, string? config = null) : base(config) @@ -91,6 +104,10 @@ public ConfigureOptions( DataSourceHealthName = dataSourceHealthName; DataSourceUserDelegatedAuthEnabled = dataSourceUserDelegatedAuthEnabled; DataSourceUserDelegatedAuthDatabaseAudience = dataSourceUserDelegatedAuthDatabaseAudience; + DataSourceUserDelegatedAuthProvider = dataSourceUserDelegatedAuthProvider; + DataSourceHealthEnabled = dataSourceHealthEnabled; + DataSourceHealthThresholdMs = dataSourceHealthThresholdMs; + DataSourceFiles = dataSourceFiles; // GraphQL DepthLimit = depthLimit; RuntimeGraphQLEnabled = runtimeGraphQLEnabled; @@ -117,8 +134,17 @@ public ConfigureOptions( // Cache RuntimeCacheEnabled = runtimeCacheEnabled; RuntimeCacheTTL = runtimeCacheTtl; + // Pagination + RuntimePaginationMaxPageSize = runtimePaginationMaxPageSize; + RuntimePaginationDefaultPageSize = runtimePaginationDefaultPageSize; + RuntimePaginationNextLinkRelative = runtimePaginationNextLinkRelative; // Compression RuntimeCompressionLevel = runtimeCompressionLevel; + // Health + RuntimeHealthEnabled = runtimeHealthEnabled; + RuntimeHealthCacheTtlSeconds = runtimeHealthCacheTtlSeconds; + RuntimeHealthMaxQueryParallelism = runtimeHealthMaxQueryParallelism; + RuntimeHealthRoles = runtimeHealthRoles; // Host RuntimeHostMode = runtimeHostMode; RuntimeHostCorsOrigins = runtimeHostCorsOrigins; @@ -126,6 +152,7 @@ public ConfigureOptions( RuntimeHostAuthenticationProvider = runtimeHostAuthenticationProvider; RuntimeHostAuthenticationJwtAudience = runtimeHostAuthenticationJwtAudience; RuntimeHostAuthenticationJwtIssuer = runtimeHostAuthenticationJwtIssuer; + RuntimeHostMaxResponseSizeMb = runtimeHostMaxResponseSizeMb; // Azure Key Vault AzureKeyVaultEndpoint = azureKeyVaultEndpoint; AzureKeyVaultRetryPolicyMode = azureKeyVaultRetryPolicyMode; @@ -146,10 +173,12 @@ public ConfigureOptions( FileSinkRollingInterval = fileSinkRollingInterval; FileSinkRetainedFileCountLimit = fileSinkRetainedFileCountLimit; FileSinkFileSizeLimitBytes = fileSinkFileSizeLimitBytes; + // Telemetry Log Level + RuntimeTelemetryLogLevel = runtimeTelemetryLogLevel; ShowEffectivePermissions = showEffectivePermissions; } - [Option("data-source.database-type", Required = false, HelpText = "Database type. Allowed values: MSSQL, PostgreSQL, CosmosDB_NoSQL, MySQL.")] + [Option("data-source.database-type", Required = false, HelpText = "Database type. Allowed values: MSSQL, PostgreSQL, CosmosDB_NoSQL, CosmosDB_PostgreSQL, MySQL.")] public string? DataSourceDatabaseType { get; } [Option("data-source.connection-string", Required = false, HelpText = "Connection string for the data source.")] @@ -176,6 +205,18 @@ public ConfigureOptions( [Option("data-source.user-delegated-auth.database-audience", Required = false, HelpText = "Database resource identifier for token acquisition (e.g., https://database.windows.net for Azure SQL).")] public string? DataSourceUserDelegatedAuthDatabaseAudience { get; } + [Option("data-source.user-delegated-auth.provider", Required = false, HelpText = "Authentication provider for user-delegated auth. Allowed values: EntraId. Default: EntraId.")] + public string? DataSourceUserDelegatedAuthProvider { get; } + + [Option("data-source.health.enabled", Required = false, HelpText = "Enable health check for this data source. Default: true (boolean).")] + public bool? DataSourceHealthEnabled { get; } + + [Option("data-source.health.threshold-ms", Required = false, HelpText = "Health check response time threshold in milliseconds. Default: 1000. Range: 1-2147483647.")] + public int? DataSourceHealthThresholdMs { get; } + + [Option("data-source-files", Required = false, Separator = ',', HelpText = "Comma-separated list of additional runtime config files for multi-database scenarios.")] + public IEnumerable? DataSourceFiles { get; } + [Option("runtime.graphql.depth-limit", Required = false, HelpText = "Max allowed depth of the nested query. Allowed values: (0,2147483647] inclusive. Default is infinity. Use -1 to remove limit.")] public int? DepthLimit { get; } @@ -242,9 +283,30 @@ public ConfigureOptions( [Option("runtime.cache.ttl-seconds", Required = false, HelpText = "Customize the DAB cache's global default time to live in seconds. Default: 5 seconds (Integer).")] public int? RuntimeCacheTTL { get; } + [Option("runtime.pagination.max-page-size", Required = false, HelpText = "Maximum page size for paginated results. Default: 100000. Minimum: 1.")] + public int? RuntimePaginationMaxPageSize { get; } + + [Option("runtime.pagination.default-page-size", Required = false, HelpText = "Default page size for paginated results. Default: 100. Minimum: 1.")] + public int? RuntimePaginationDefaultPageSize { get; } + + [Option("runtime.pagination.next-link-relative", Required = false, HelpText = "Use relative URLs for pagination next links. Default: false (boolean).")] + public bool? RuntimePaginationNextLinkRelative { get; } + [Option("runtime.compression.level", Required = false, HelpText = "Set the response compression level. Allowed values: optimal (default), fastest, none.")] public CompressionLevel? RuntimeCompressionLevel { get; } + [Option("runtime.health.enabled", Required = false, HelpText = "Enable runtime health checks. Default: true (boolean).")] + public bool? RuntimeHealthEnabled { get; } + + [Option("runtime.health.cache-ttl-seconds", Required = false, HelpText = "Time to live in seconds for cached health check results. Default: 5.")] + public int? RuntimeHealthCacheTtlSeconds { get; } + + [Option("runtime.health.max-query-parallelism", Required = false, HelpText = "Maximum number of parallel health check queries. Default: 4. Range: 1-8.")] + public int? RuntimeHealthMaxQueryParallelism { get; } + + [Option("runtime.health.roles", Required = false, Separator = ',', HelpText = "Comma-separated list of roles allowed to access health check details.")] + public IEnumerable? RuntimeHealthRoles { get; } + [Option("runtime.host.mode", Required = false, HelpText = "Set the host running mode of DAB in Development or Production. Default: Development.")] public HostMode? RuntimeHostMode { get; } @@ -263,6 +325,9 @@ public ConfigureOptions( [Option("runtime.host.authentication.jwt.issuer", Required = false, HelpText = "Configure the entity that issued the Jwt Token.")] public string? RuntimeHostAuthenticationJwtIssuer { get; } + [Option("runtime.host.max-response-size-mb", Required = false, HelpText = "Maximum response size in megabytes. Use -1 for maximum engine limit. Default: 158.")] + public int? RuntimeHostMaxResponseSizeMb { get; } + [Option("azure-key-vault.endpoint", Required = false, HelpText = "Configure the Azure Key Vault endpoint URL.")] public string? AzureKeyVaultEndpoint { get; } @@ -314,6 +379,9 @@ public ConfigureOptions( [Option("runtime.telemetry.file.file-size-limit-bytes", Required = false, HelpText = "Configure maximum file size limit in bytes. Default: 1048576")] public long? FileSinkFileSizeLimitBytes { get; } + [Option("runtime.telemetry.log-level", Required = false, Separator = ',', HelpText = "Configure log levels by namespace. Format: 'Namespace:Level,...'. Allowed levels: trace, debug, information, warning, error, critical, none.")] + public IEnumerable? RuntimeTelemetryLogLevel { get; } + [Option("show-effective-permissions", Required = false, HelpText = "Display effective permissions for all entities, including inherited permissions. Entities are listed in alphabetical order.")] public bool ShowEffectivePermissions { get; } diff --git a/src/Cli/Commands/EntityOptions.cs b/src/Cli/Commands/EntityOptions.cs index 3b2b77d9b2..1070750102 100644 --- a/src/Cli/Commands/EntityOptions.cs +++ b/src/Cli/Commands/EntityOptions.cs @@ -25,6 +25,8 @@ public EntityOptions( string? policyDatabase, string? cacheEnabled, string? cacheTtl, + string? cacheLevel, + string? healthEnabled, string? description, IEnumerable? parametersNameCollection, IEnumerable? parametersDescriptionCollection, @@ -54,6 +56,8 @@ public EntityOptions( PolicyDatabase = policyDatabase; CacheEnabled = cacheEnabled; CacheTtl = cacheTtl; + CacheLevel = cacheLevel; + HealthEnabled = healthEnabled; Description = description; ParametersNameCollection = parametersNameCollection; ParametersDescriptionCollection = parametersDescriptionCollection; @@ -107,9 +111,15 @@ public EntityOptions( [Option("cache.enabled", Required = false, HelpText = "Specify if caching is enabled for Entity, default value is false.")] public string? CacheEnabled { get; } - [Option("cache.ttl", Required = false, HelpText = "Specify time to live in seconds for cache entries for Entity.")] + [Option("cache.ttl-seconds", Required = false, HelpText = "Specify time to live in seconds for cache entries for Entity.")] public string? CacheTtl { get; } + [Option("cache.level", Required = false, HelpText = "Cache level for entity. Allowed values: L1, L1L2. Default: L1L2.")] + public string? CacheLevel { get; } + + [Option("health.enabled", Required = false, HelpText = "Enable health checks for this entity. Default: true (boolean).")] + public string? HealthEnabled { get; } + [Option("description", Required = false, HelpText = "Description of the entity.")] public string? Description { get; } diff --git a/src/Cli/Commands/UpdateOptions.cs b/src/Cli/Commands/UpdateOptions.cs index 050afa2ddb..afb424a527 100644 --- a/src/Cli/Commands/UpdateOptions.cs +++ b/src/Cli/Commands/UpdateOptions.cs @@ -42,6 +42,8 @@ public UpdateOptions( string? policyDatabase, string? cacheEnabled, string? cacheTtl, + string? cacheLevel, + string? healthEnabled, string? description, IEnumerable? parametersNameCollection, IEnumerable? parametersDescriptionCollection, @@ -68,6 +70,8 @@ public UpdateOptions( policyDatabase, cacheEnabled, cacheTtl, + cacheLevel, + healthEnabled, description, parametersNameCollection, parametersDescriptionCollection, diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 8e95eb6e5d..8285afbbd0 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -464,7 +464,8 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt EntityRestOptions restOptions = ConstructRestOptions(options.RestRoute, SupportedRestMethods, initialRuntimeConfig.DataSource.DatabaseType == DatabaseType.CosmosDB_NoSQL); EntityGraphQLOptions graphqlOptions = ConstructGraphQLTypeDetails(options.GraphQLType, graphQLOperationsForStoredProcedures); - EntityCacheOptions? cacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); + EntityCacheOptions? cacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl, options.CacheLevel); + EntityHealthCheckConfig? entityHealthOptions = ConstructEntityHealthOptions(options.HealthEnabled); EntityMcpOptions? mcpOptions = null; if (options.McpDmlTools is not null || options.McpCustomTool is not null) @@ -489,7 +490,8 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt Mappings: null, Cache: cacheOptions, Description: string.IsNullOrWhiteSpace(options.Description) ? null : options.Description, - Mcp: mcpOptions); + Mcp: mcpOptions, + Health: entityHealthOptions); // Add entity to existing runtime config. IDictionary entities = new Dictionary(initialRuntimeConfig.Entities.Entities) @@ -758,39 +760,41 @@ private static bool TryUpdateConfiguredDataSourceOptions( dbOptions.Add(namingPolicy.ConvertName(nameof(MsSqlOptions.SetSessionContext)), options.DataSourceOptionsSetSessionContext.Value); } - // Handle health.name option - if (options.DataSourceHealthName is not null) + // Handle health options (name, enabled, threshold-ms) + if (options.DataSourceHealthName is not null + || options.DataSourceHealthEnabled is not null + || options.DataSourceHealthThresholdMs is not null) { - // If there's no existing health config, create one with the name - // Note: Passing enabled: null results in Enabled = true at runtime (default behavior) - // but UserProvidedEnabled = false, so the enabled property won't be serialized to JSON. - // This ensures only the name property is written to the config file. if (datasourceHealthCheckConfig is null) { - datasourceHealthCheckConfig = new DatasourceHealthCheckConfig(enabled: null, name: options.DataSourceHealthName); + datasourceHealthCheckConfig = new DatasourceHealthCheckConfig( + enabled: options.DataSourceHealthEnabled, + name: options.DataSourceHealthName, + thresholdMs: options.DataSourceHealthThresholdMs); } else { - // Update the existing health config with the new name while preserving other settings. - // DatasourceHealthCheckConfig is a record (immutable), so we create a new instance. - // Preserve threshold only if it was explicitly set by the user - int? thresholdToPreserve = datasourceHealthCheckConfig.UserProvidedThresholdMs - ? datasourceHealthCheckConfig.ThresholdMs - : null; - // Preserve enabled only if it was explicitly set by the user - bool? enabledToPreserve = datasourceHealthCheckConfig.UserProvidedEnabled - ? datasourceHealthCheckConfig.Enabled - : null; + int? thresholdToPreserve = options.DataSourceHealthThresholdMs + ?? (datasourceHealthCheckConfig.UserProvidedThresholdMs + ? datasourceHealthCheckConfig.ThresholdMs + : null); + bool? enabledToPreserve = options.DataSourceHealthEnabled + ?? (datasourceHealthCheckConfig.UserProvidedEnabled + ? datasourceHealthCheckConfig.Enabled + : null); + string? nameToPreserve = options.DataSourceHealthName + ?? datasourceHealthCheckConfig.Name; datasourceHealthCheckConfig = new DatasourceHealthCheckConfig( enabled: enabledToPreserve, - name: options.DataSourceHealthName, + name: nameToPreserve, thresholdMs: thresholdToPreserve); } } // Handle user-delegated-auth options if (options.DataSourceUserDelegatedAuthEnabled is not null - || options.DataSourceUserDelegatedAuthDatabaseAudience is not null) + || options.DataSourceUserDelegatedAuthDatabaseAudience is not null + || options.DataSourceUserDelegatedAuthProvider is not null) { // Determine the enabled state: use new value if provided, otherwise preserve existing bool enabled = options.DataSourceUserDelegatedAuthEnabled @@ -808,8 +812,10 @@ private static bool TryUpdateConfiguredDataSourceOptions( string? databaseAudience = options.DataSourceUserDelegatedAuthDatabaseAudience ?? userDelegatedAuthConfig?.DatabaseAudience; - // Get provider: preserve existing or use default "EntraId" - string? provider = userDelegatedAuthConfig?.Provider ?? "EntraId"; + // Get provider: use new value if provided, otherwise preserve existing or use default "EntraId" + string? provider = options.DataSourceUserDelegatedAuthProvider + ?? userDelegatedAuthConfig?.Provider + ?? "EntraId"; // Create or update user-delegated-auth config userDelegatedAuthConfig = new UserDelegatedAuthOptions( @@ -825,6 +831,13 @@ private static bool TryUpdateConfiguredDataSourceOptions( }; runtimeConfig = runtimeConfig with { DataSource = dataSource }; + // Handle data-source-files + if (options.DataSourceFiles is not null && options.DataSourceFiles.Any()) + { + DataSourceFiles dataSourceFiles = new(options.DataSourceFiles); + runtimeConfig = runtimeConfig with { DataSourceFiles = dataSourceFiles }; + } + return runtimeConfig != null; } @@ -988,6 +1001,39 @@ private static bool TryUpdateConfiguredRuntimeOptions( } } + // Pagination: MaxPageSize, DefaultPageSize, NextLinkRelative + if (options.RuntimePaginationMaxPageSize != null || + options.RuntimePaginationDefaultPageSize != null || + options.RuntimePaginationNextLinkRelative != null) + { + PaginationOptions existing = runtimeConfig?.Runtime?.Pagination ?? new(); + int? maxPageSize = options.RuntimePaginationMaxPageSize ?? (existing.UserProvidedMaxPageSize ? existing.MaxPageSize : null); + int? defaultPageSize = options.RuntimePaginationDefaultPageSize ?? (existing.UserProvidedDefaultPageSize ? existing.DefaultPageSize : null); + bool? nextLinkRelative = options.RuntimePaginationNextLinkRelative ?? existing.NextLinkRelative; + + PaginationOptions updatedPaginationOptions = new( + MaxPageSize: maxPageSize, + DefaultPageSize: defaultPageSize, + NextLinkRelative: nextLinkRelative); + + if (options.RuntimePaginationMaxPageSize != null) + { + _logger.LogInformation("Updated RuntimeConfig with runtime.pagination.max-page-size as '{value}'", options.RuntimePaginationMaxPageSize); + } + + if (options.RuntimePaginationDefaultPageSize != null) + { + _logger.LogInformation("Updated RuntimeConfig with runtime.pagination.default-page-size as '{value}'", options.RuntimePaginationDefaultPageSize); + } + + if (options.RuntimePaginationNextLinkRelative != null) + { + _logger.LogInformation("Updated RuntimeConfig with runtime.pagination.next-link-relative as '{value}'", options.RuntimePaginationNextLinkRelative); + } + + runtimeConfig = runtimeConfig! with { Runtime = runtimeConfig.Runtime! with { Pagination = updatedPaginationOptions } }; + } + // Compression: Level if (options.RuntimeCompressionLevel != null) { @@ -1003,13 +1049,14 @@ private static bool TryUpdateConfiguredRuntimeOptions( } } - // Host: Mode, Cors.Origins, Cors.AllowCredentials, Authentication.Provider, Authentication.Jwt.Audience, Authentication.Jwt.Issuer + // Host: Mode, Cors.Origins, Cors.AllowCredentials, Authentication.Provider, Authentication.Jwt.Audience, Authentication.Jwt.Issuer, MaxResponseSizeMb if (options.RuntimeHostMode != null || options.RuntimeHostCorsOrigins != null || options.RuntimeHostCorsAllowCredentials != null || options.RuntimeHostAuthenticationProvider != null || options.RuntimeHostAuthenticationJwtAudience != null || - options.RuntimeHostAuthenticationJwtIssuer != null) + options.RuntimeHostAuthenticationJwtIssuer != null || + options.RuntimeHostMaxResponseSizeMb != null) { HostOptions? updatedHostOptions = runtimeConfig?.Runtime?.Host; bool status = TryUpdateConfiguredHostValues(options, ref updatedHostOptions); @@ -1023,6 +1070,30 @@ private static bool TryUpdateConfiguredRuntimeOptions( } } + // Health: Enabled, CacheTtlSeconds, MaxQueryParallelism, Roles + if (options.RuntimeHealthEnabled != null || + options.RuntimeHealthCacheTtlSeconds != null || + options.RuntimeHealthMaxQueryParallelism != null || + options.RuntimeHealthRoles != null) + { + RuntimeHealthCheckConfig existingHealth = runtimeConfig?.Runtime?.Health ?? new(); + // If any health sub-option is provided without --runtime.health.enabled, + // auto-set enabled=true so the section persists in serialized config. + bool? enabled = options.RuntimeHealthEnabled + ?? (existingHealth.UserProvidedEnabled ? existingHealth.Enabled : true); + int? cacheTtl = options.RuntimeHealthCacheTtlSeconds ?? (existingHealth.UserProvidedTtlOptions ? existingHealth.CacheTtlSeconds : null); + int? maxParallelism = options.RuntimeHealthMaxQueryParallelism ?? (existingHealth.UserProvidedMaxQueryParallelism ? existingHealth.MaxQueryParallelism : null); + HashSet? roles = options.RuntimeHealthRoles is not null ? new HashSet(options.RuntimeHealthRoles) : existingHealth.Roles; + + RuntimeHealthCheckConfig updatedHealthOptions = new( + enabled: enabled, + roles: roles, + cacheTtlSeconds: cacheTtl, + maxQueryParallelism: maxParallelism); + + runtimeConfig = runtimeConfig! with { Runtime = runtimeConfig.Runtime! with { Health = updatedHealthOptions } }; + } + // Telemetry: Azure Log Analytics if (options.AzureLogAnalyticsEnabled is not null || options.AzureLogAnalyticsDabIdentifier is not null || @@ -1062,6 +1133,55 @@ options.FileSinkRetainedFileCountLimit is not null || } } + // Telemetry: Log Level + if (options.RuntimeTelemetryLogLevel is not null && options.RuntimeTelemetryLogLevel.Any()) + { + Dictionary logLevels = new(); + foreach (string entry in options.RuntimeTelemetryLogLevel) + { + string[] parts = entry.Split(':', 2); + if (parts.Length != 2) + { + _logger.LogError("Invalid log-level format '{entry}'. Expected 'Namespace:Level'.", entry); + return false; + } + + string ns = parts[0].Trim(); + string levelStr = parts[1].Trim(); + + if (string.IsNullOrEmpty(ns)) + { + _logger.LogError("Invalid log-level entry: namespace cannot be empty. Use 'Default:Level' for the root filter."); + return false; + } + + if (!Enum.TryParse(levelStr, ignoreCase: true, out LogLevel level)) + { + _logger.LogError("Invalid log level '{level}'. Allowed values: trace, debug, information, warning, error, critical, none.", levelStr); + return false; + } + + logLevels[ns] = level; + } + + // Merge with existing log levels (create a new dictionary to avoid mutating the original) + Dictionary mergedLevels = new(runtimeConfig?.Runtime?.Telemetry?.LoggerLevel ?? new()); + foreach (KeyValuePair kvp in logLevels) + { + mergedLevels[kvp.Key] = kvp.Value; + } + + runtimeConfig = runtimeConfig! with + { + Runtime = runtimeConfig.Runtime! with + { + Telemetry = runtimeConfig.Runtime!.Telemetry is not null + ? runtimeConfig.Runtime!.Telemetry with { LoggerLevel = mergedLevels } + : new TelemetryOptions(LoggerLevel: mergedLevels) + } + }; + } + return runtimeConfig != null; } @@ -1562,6 +1682,13 @@ private static bool TryUpdateConfiguredHostValues( _logger.LogInformation("Updated RuntimeConfig with Runtime.Host.Authentication.Jwt.Issuer as '{updatedValue}'", updatedValue); } + // Runtime.Host.MaxResponseSizeMb + if (options?.RuntimeHostMaxResponseSizeMb != null) + { + updatedHostOptions = updatedHostOptions! with { MaxResponseSizeMB = options.RuntimeHostMaxResponseSizeMb, UserProvidedMaxResponseSizeMB = true }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Host.MaxResponseSizeMb as '{value}'", options.RuntimeHostMaxResponseSizeMb); + } + return true; } catch (Exception ex) @@ -1850,7 +1977,8 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig Dictionary? updatedMappings = entity.Mappings; EntityActionPolicy? updatedPolicy = GetPolicyForOperation(options.PolicyRequest, options.PolicyDatabase); EntityActionFields? updatedFields = GetFieldsForOperation(options.FieldsToInclude, options.FieldsToExclude); - EntityCacheOptions? updatedCacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); + EntityCacheOptions? updatedCacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl, options.CacheLevel) ?? entity.Cache; + EntityHealthCheckConfig? updatedEntityHealthOptions = ConstructEntityHealthOptions(options.HealthEnabled) ?? entity.Health; // Determine if the entity is or will be a stored procedure bool isStoredProcedureAfterUpdate = doOptionsRepresentStoredProcedure || (isCurrentEntityStoredProcedure && options.SourceType is null); @@ -2113,7 +2241,8 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig Mappings: updatedMappings, Cache: updatedCacheOptions, Description: string.IsNullOrWhiteSpace(options.Description) ? entity.Description : options.Description, - Mcp: updatedMcpOptions + Mcp: updatedMcpOptions, + Health: updatedEntityHealthOptions ); IDictionary entities = new Dictionary(initialConfig.Entities.Entities) { diff --git a/src/Cli/Utils.cs b/src/Cli/Utils.cs index c1ff7f2a99..fd53fcb54d 100644 --- a/src/Cli/Utils.cs +++ b/src/Cli/Utils.cs @@ -849,17 +849,16 @@ public static EntityGraphQLOptions ConstructGraphQLTypeDetails(string? graphQL, /// String value that defines if the cache is enabled. /// Int that gives time to live in seconds for cache. /// EntityCacheOption if values are provided for cacheEnabled or cacheTtl, null otherwise. - public static EntityCacheOptions? ConstructCacheOptions(string? cacheEnabled, string? cacheTtl) + public static EntityCacheOptions? ConstructCacheOptions(string? cacheEnabled, string? cacheTtl, string? cacheLevel = null) { - if (cacheEnabled is null && cacheTtl is null) + if (cacheEnabled is null && cacheTtl is null && cacheLevel is null) { return null; } - EntityCacheOptions cacheOptions = new(); bool isEnabled = false; - bool isCacheTtlUserProvided = false; int ttl = EntityCacheOptions.DEFAULT_TTL_SECONDS; + EntityCacheLevel? level = null; if (cacheEnabled is not null && !bool.TryParse(cacheEnabled, out isEnabled)) { @@ -868,29 +867,45 @@ public static EntityGraphQLOptions ConstructGraphQLTypeDetails(string? graphQL, if ((cacheTtl is not null && !int.TryParse(cacheTtl, out ttl)) || ttl < 0) { - _logger.LogError("Invalid format for --cache.ttl. Accepted values are any non-negative integer."); + _logger.LogError("Invalid format for --cache.ttl-seconds. Accepted values are any non-negative integer."); } - // This is needed so the cacheTtl is correctly written to config. - if (cacheTtl is not null) + if (cacheLevel is not null && !Enum.TryParse(cacheLevel, ignoreCase: true, out EntityCacheLevel _)) { - isCacheTtlUserProvided = true; + _logger.LogError("Invalid format for --cache.level. Accepted values are L1, L1L2."); + } + else if (cacheLevel is not null) + { + level = Enum.Parse(cacheLevel, ignoreCase: true); } - // Both cacheEnabled and cacheTtl can not be null here, so if either one - // is, the other is not, and we return the cacheOptions with just that other - // value. - if (cacheEnabled is null) + // Use the constructor so UserProvided* flags are set automatically + // when a non-null value is passed. + return new EntityCacheOptions( + Enabled: cacheEnabled is not null ? isEnabled : null, + TtlSeconds: cacheTtl is not null ? ttl : null, + Level: level); + } + + /// + /// Constructs EntityHealthCheckConfig for Add/Update. + /// + /// String value that defines if health check is enabled for the entity. + /// EntityHealthCheckConfig if a value is provided, null otherwise. + public static EntityHealthCheckConfig? ConstructEntityHealthOptions(string? healthEnabled) + { + if (healthEnabled is null) { - return cacheOptions with { TtlSeconds = ttl, UserProvidedTtlOptions = isCacheTtlUserProvided }; + return null; } - if (cacheTtl is null) + if (!bool.TryParse(healthEnabled, out bool isEnabled)) { - return cacheOptions with { Enabled = isEnabled }; + _logger.LogError("Invalid format for --health.enabled. Accepted values are true/false."); + return null; } - return cacheOptions with { Enabled = isEnabled, TtlSeconds = ttl, UserProvidedTtlOptions = isCacheTtlUserProvided }; + return new EntityHealthCheckConfig(enabled: isEnabled); } ///