diff --git a/.pipelines/templates/build-pipelines.yml b/.pipelines/templates/build-pipelines.yml index ae9c7c7684..cd604917ef 100644 --- a/.pipelines/templates/build-pipelines.yml +++ b/.pipelines/templates/build-pipelines.yml @@ -14,6 +14,22 @@ steps: # generate the prerelease nuget version. # We cannot set this in variables section above because $(isNugetRelease) # is not available at pipeline compilation time. +- task: PowerShell@2 + inputs: + targetType: 'inline' + script: | + [xml]$directoryBuildProps = Get-Content -Path $(Build.SourcesDirectory)/src/Directory.Build.props + # Get Version from Directory.Build.props + # When you access this XML property, it returns an array of elements (even if there's only one element with that name). + # To extract the actual value as a string, you need to access the first element of the array. + $version = $directoryBuildProps.Project.PropertyGroup.Version[0] + # Get Major and Minor version from the version extracted + $major = $version.Split([char]'.')[0] + $minor = $version.Split([char]'.')[1] + # store $major and $minor powershell variables into azure dev ops pipeline variables + Write-Host "##vso[task.setvariable variable=major]$major" + Write-Host "##vso[task.setvariable variable=minor]$minor" + - bash: | echo IsNugetRelease = $ISNUGETRELEASE echo IsReleaseCandidate = $ISRELEASECANDIDATE diff --git a/.pipelines/templates/variables.yml b/.pipelines/templates/variables.yml index 5f6ed4937c..f9c88132db 100644 --- a/.pipelines/templates/variables.yml +++ b/.pipelines/templates/variables.yml @@ -5,8 +5,6 @@ variables: solution: '**/*.sln' buildPlatform: 'Any CPU' buildConfiguration: 'Release' - major: 0 - minor: 11 # Maintain a separate patch value between CI and PR runs. # The counter is reset when the minor version is updated. patch: $[counter(format('{0}_{1}', variables['build.reason'], variables['minor']), 0)] diff --git a/src/Cli.Tests/EndToEndTests.cs b/src/Cli.Tests/EndToEndTests.cs index eb6e863c46..806c36237e 100644 --- a/src/Cli.Tests/EndToEndTests.cs +++ b/src/Cli.Tests/EndToEndTests.cs @@ -122,7 +122,7 @@ public void TestInitializingRestAndGraphQLGlobalSettings() replaceEnvVar: true)); SqlConnectionStringBuilder builder = new(runtimeConfig.DataSource.ConnectionString); - Assert.AreEqual(ProductInfo.GetDataApiBuilderApplicationName(), builder.ApplicationName); + Assert.AreEqual(ProductInfo.GetDataApiBuilderUserAgent(), builder.ApplicationName); Assert.IsNotNull(runtimeConfig); Assert.AreEqual(DatabaseType.MSSQL, runtimeConfig.DataSource.DatabaseType); @@ -771,22 +771,19 @@ public void TestMissingEntityFromCommand( /// /// Test to verify that help writer window generates output on the console. + /// Every test here validates that the first line of the output contains the product name and version. /// [DataTestMethod] [DataRow("", "", new string[] { "ERROR" }, DisplayName = "No flags provided.")] [DataRow("initialize", "", new string[] { "ERROR", "Verb 'initialize' is not recognized." }, DisplayName = "Wrong Command provided.")] - [DataRow("", "--version", new string[] { "Microsoft.DataApiBuilder 1.0.0" }, DisplayName = "Checking version.")] [DataRow("", "--help", new string[] { "init", "add", "update", "start" }, DisplayName = "Checking output for --help.")] public void TestHelpWriterOutput(string command, string flags, string[] expectedOutputArray) { - using Process process = ExecuteDabCommand( - command, - flags - ); + using Process process = ExecuteDabCommand(command, flags); string? output = process.StandardOutput.ReadToEnd(); Assert.IsNotNull(output); - StringAssert.Contains(output, $"{Program.PRODUCT_NAME} {ProductInfo.GetProductVersion()}", StringComparison.Ordinal); + StringAssert.Contains(output, $"{Program.PRODUCT_NAME} {ProductInfo.GetProductVersion(includeCommitHash: true)}", StringComparison.Ordinal); foreach (string expectedOutput in expectedOutputArray) { @@ -796,24 +793,25 @@ public void TestHelpWriterOutput(string command, string flags, string[] expected process.Kill(); } - [DataRow("", "--version", DisplayName = "Checking dab version with --version.")] - [DataTestMethod] - public void TestVersionHasBuildHash( - string command, - string options - ) + /// + /// When CLI is started via: dab --version, it should print the version number + /// which includes the commit hash. For example: + /// Microsoft.DataApiBuilder 0.12+2d181463e5dd46cf77fea31b7295c4e02e8ef031 + /// + [TestMethod] + public void TestVersionHasBuildHash() { _fileSystem!.File.WriteAllText(TEST_RUNTIME_CONFIG_FILE, INITIAL_CONFIG); using Process process = ExecuteDabCommand( - command: $"{command} ", - flags: $"--config {TEST_RUNTIME_CONFIG_FILE} {options}" + command: string.Empty, + flags: $"--config {TEST_RUNTIME_CONFIG_FILE} --version" ); string? output = process.StandardOutput.ReadLine(); Assert.IsNotNull(output); - // Check that build hash is returned as part of version number + // Check that the build hash is returned as part of the version number. string[] versionParts = output.Split('+'); Assert.AreEqual(2, versionParts.Length, "Build hash not returned as part of version number."); Assert.AreEqual(40, versionParts[1].Length, "Build hash is not of expected length."); @@ -822,21 +820,17 @@ string options } /// - /// Test to verify that the version info is logged for both correct/incorrect command, - /// and that the config name is displayed in the logs. + /// For valid CLI commands (Valid verbs and options) validate that the correct + /// version is logged (without commit hash) and that the config file name is printed to console. /// - [DataRow("", "--version", false, DisplayName = "Checking dab version with --version.")] - [DataRow("", "--help", false, DisplayName = "Checking version through --help option.")] - [DataRow("edit", "--new-option", false, DisplayName = "Version printed with invalid command edit.")] - [DataRow("init", "--database-type mssql", true, DisplayName = "Version printed with valid command init.")] - [DataRow("add", "MyEntity -s my_entity --permissions \"anonymous:*\"", true, DisplayName = "Version printed with valid command add.")] - [DataRow("update", "MyEntity -s my_entity", true, DisplayName = "Version printed with valid command update.")] - [DataRow("start", "", true, DisplayName = "Version printed with valid command start.")] + [DataRow("init", "--database-type mssql", DisplayName = "Version printed with valid command init.")] + [DataRow("add", "MyEntity -s my_entity --permissions \"anonymous:*\"", DisplayName = "Version printed with valid command add.")] + [DataRow("update", "MyEntity -s my_entity", DisplayName = "Version printed with valid command update.")] + [DataRow("start", "", DisplayName = "Version printed with valid command start.")] [DataTestMethod] - public void TestVersionInfoAndConfigIsCorrectlyDisplayedWithDifferentCommand( + public void ValidCliVerbsAndOptions_DisplayVersionAndConfigFileName( string command, - string options, - bool isParsableDabCommandName) + string options) { _fileSystem!.File.WriteAllText(TEST_RUNTIME_CONFIG_FILE, INITIAL_CONFIG); @@ -849,13 +843,45 @@ public void TestVersionInfoAndConfigIsCorrectlyDisplayedWithDifferentCommand( Assert.IsNotNull(output); // Version Info logged by dab irrespective of commands being parsed correctly. + // When DAB CLI (CommandLineParser) detects that usage of parsable verbs and options, CommandLineParser + // uses the options.Handler() method to display relevant info and process the command. + // All options.Handler() methods print the version without commit hash. StringAssert.Contains(output, $"{Program.PRODUCT_NAME} {ProductInfo.GetProductVersion()}", StringComparison.Ordinal); + output = process.StandardOutput.ReadLine(); + StringAssert.Contains(output, TEST_RUNTIME_CONFIG_FILE, StringComparison.Ordinal); - if (isParsableDabCommandName) - { - output = process.StandardOutput.ReadLine(); - StringAssert.Contains(output, TEST_RUNTIME_CONFIG_FILE, StringComparison.Ordinal); - } + process.Kill(); + } + + /// + /// Validate that the DAB CLI logs the correct version (with commit hash) to the console + /// when invalid verbs, '--version', or '--help' are used. + /// Console Output: + /// Microsoft.DataApiBuilder 0.12+5009b8720409ab321fd8cd19c716835528c8385b + /// + [DataRow("", "--version", DisplayName = "Checking dab version with --version.")] + [DataRow("", "--help", DisplayName = "Checking version through --help option.")] + [DataRow("edit", "--new-option", DisplayName = "Version printed with invalid command edit.")] + [DataTestMethod] + public void InvalidCliVerbsAndOptions_DisplayVersionWithCommitHashAndConfigFileName( + string command, + string options) + { + _fileSystem!.File.WriteAllText(TEST_RUNTIME_CONFIG_FILE, INITIAL_CONFIG); + + using Process process = ExecuteDabCommand( + command: $"{command} ", + flags: $"--config {TEST_RUNTIME_CONFIG_FILE} {options}" + ); + + string? output = process.StandardOutput.ReadLine(); + Assert.IsNotNull(output); + + // Version Info logged by dab irrespective of commands being parsed correctly. + // When DAB CLI (CommandLineParser) detects that usage includes --version, --help, or invalid verbs/options, + // CommandLineParser's default HelpWriter is used to display the version and help information. + // Because the HelpWriter uses fileVersionInfo.ProductVersion, the version includes the commit hash. + StringAssert.Contains(output, $"{Program.PRODUCT_NAME} {ProductInfo.GetProductVersion(includeCommitHash: true)}", StringComparison.Ordinal); process.Kill(); } @@ -882,7 +908,7 @@ public async Task TestExitOfRuntimeEngineWithInvalidConfig( ); string? output = await process.StandardOutput.ReadLineAsync(); Assert.IsNotNull(output); - StringAssert.Contains(output, $"{Program.PRODUCT_NAME} {ProductInfo.GetProductVersion()}", StringComparison.Ordinal); + StringAssert.Contains(output, $"{Program.PRODUCT_NAME} {ProductInfo.GetProductVersion(includeCommitHash: true)}", StringComparison.Ordinal); output = await process.StandardOutput.ReadLineAsync(); Assert.IsNotNull(output); diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index f56db4bfa5..57b92bedac 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -199,7 +199,7 @@ internal static string GetConnectionStringWithApplicationName(string connectionS return connectionString; } - string applicationName = ProductInfo.GetDataApiBuilderApplicationName(); + string applicationName = ProductInfo.GetDataApiBuilderUserAgent(); // Create a StringBuilder from the connection string. SqlConnectionStringBuilder connectionStringBuilder; diff --git a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs index ebe09223e4..eba3c07427 100644 --- a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs @@ -13,6 +13,7 @@ using Azure.DataApiBuilder.Core.Parsers; using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Core.Services.OpenAPI; +using Azure.DataApiBuilder.Product; using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Writers; @@ -30,7 +31,6 @@ public class OpenApiDocumentor : IOpenApiDocumentor private OpenApiResponses _defaultOpenApiResponses; private OpenApiDocument? _openApiDocument; - private const string DOCUMENTOR_VERSION = "PREVIEW"; private const string DOCUMENTOR_UI_TITLE = "Data API builder - REST Endpoint"; private const string GETALL_DESCRIPTION = "Returns entities."; private const string GETONE_DESCRIPTION = "Returns an entity."; @@ -132,8 +132,8 @@ public void CreateDocument() { Info = new OpenApiInfo { - Version = DOCUMENTOR_VERSION, - Title = DOCUMENTOR_UI_TITLE, + Version = ProductInfo.GetProductVersion(), + Title = DOCUMENTOR_UI_TITLE }, Servers = new List { diff --git a/src/Directory.Build.props b/src/Directory.Build.props index dbdcf4145b..a64ede0ce6 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,6 +2,7 @@ enable ..\out + 0.12 diff --git a/src/Product/ProductInfo.cs b/src/Product/ProductInfo.cs index bbce25aa95..781317b157 100644 --- a/src/Product/ProductInfo.cs +++ b/src/Product/ProductInfo.cs @@ -8,22 +8,39 @@ namespace Azure.DataApiBuilder.Product; public static class ProductInfo { - public const string DEFAULT_VERSION = "0.0.0"; public const string DAB_APP_NAME_ENV = "DAB_APP_NAME_ENV"; - public static readonly string DEFAULT_APP_NAME = $"dab_oss_{ProductInfo.GetProductVersion()}"; - public static readonly string ROLE_NAME = "DataApiBuilder"; + public static readonly string DAB_USER_AGENT = $"dab_oss_{GetProductVersion()}"; + public static readonly string CLOUD_ROLE_NAME = "DataApiBuilder"; /// - /// Reads the product version from the executing assembly's file version information. + /// Returns the Product version in Major.Minor.Patch format without a commit hash. + /// FileVersionInfo.ProductBuildPart is used to represent the Patch version. + /// FileVersionInfo is used to retrieve the version information from the executing assembly + /// set by the Version property in Directory.Build.props. + /// FileVersionInfo.ProductVersion includes the commit hash. /// - /// Product version if not null, default version 0.0.0 otherwise. - public static string GetProductVersion() + /// If true, returns the version string with the commit hash + /// Version string without commit hash: Major.Minor.Patch + /// Version string with commit hash: Major.Minor.Patch+COMMIT_ID" + public static string GetProductVersion(bool includeCommitHash = false) { Assembly assembly = Assembly.GetExecutingAssembly(); - FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(assembly.Location); - string? version = fileVersionInfo.ProductVersion; - - return version ?? DEFAULT_VERSION; + FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(fileName: assembly.Location); + + string versionString; + + // fileVersionInfo's ProductVersion is nullable, while PoductMajorPart, ProductMinorPart, and ProductBuildPart are not. + // if ProductVersion is null, the other properties will be 0 since they do not return null. + if (includeCommitHash && fileVersionInfo.ProductVersion is not null) + { + versionString = fileVersionInfo.ProductVersion; + } + else + { + versionString = fileVersionInfo.ProductMajorPart + "." + fileVersionInfo.ProductMinorPart + "." + fileVersionInfo.ProductBuildPart; + } + + return versionString; } /// @@ -31,21 +48,11 @@ public static string GetProductVersion() /// DAB_APP_NAME_ENV environment variable. If the environment variable is not set, /// it returns a default value indicating connections from open source. /// + /// Returns the value in the environment variable DAB_APP_NAME_ENV, when set. + /// Otherwise, returns user agent string: dab_oss_Major.Minor.Patch public static string GetDataApiBuilderUserAgent() { - return Environment.GetEnvironmentVariable(DAB_APP_NAME_ENV) ?? DEFAULT_APP_NAME; - } - - /// - /// Returns the application name to be used for database connections for the DataApiBuilder. - /// It strips the hash value from the user agent string to only return the application name and the version. - /// The method serves as a means of identifying the source of connections made through the DataApiBuilder. - /// - public static string GetDataApiBuilderApplicationName() - { - string dabVersion = ProductInfo.GetDataApiBuilderUserAgent(); - int hashStartPosition = dabVersion.LastIndexOf('+'); - return hashStartPosition != -1 ? dabVersion[..hashStartPosition] : dabVersion; + return Environment.GetEnvironmentVariable(DAB_APP_NAME_ENV) ?? DAB_USER_AGENT; } } diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index d1d7f313e0..5d69f83705 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -32,6 +32,7 @@ using Azure.DataApiBuilder.Product; using Azure.DataApiBuilder.Service.Controllers; using Azure.DataApiBuilder.Service.Exceptions; +using Azure.DataApiBuilder.Service.HealthCheck; using Azure.DataApiBuilder.Service.Tests.Authorization; using Azure.DataApiBuilder.Service.Tests.OpenApiIntegration; using Azure.DataApiBuilder.Service.Tests.SqlTests; @@ -552,64 +553,129 @@ public void TestCorrectSerializationOfSourceObject( } /// - /// Checks if the connection string provided in the config is correctly updated for MSSQL. - /// If the connection string already contains the `Application Name` property, it should append the DataApiBuilder Application Name to the existing value. - /// If not, it should append the property `Application Name` to the connection string. + /// Validates that DAB supplements the MSSQL database connection strings with the property "Application Name" and + /// 1. Adds the property/value "Application Name=dab_oss_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is not set. + /// 2. Adds the property/value "Application Name=dab_hosted_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is set to "dab_hosted". + /// (DAB_APP_NAME_ENV is set in hosted scenario or when user sets the value.) + /// NOTE: "#pragma warning disable format" is used here to avoid removing intentional, readability promoting spacing in DataRow display names. + /// + /// connection string provided in the config. + /// Updated connection string with Application Name. + /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.) + #pragma warning disable format + [DataTestMethod] + [DataRow("Data Source=<>;" , "Data Source=<>;Application Name=" , false, DisplayName = "[MSSQL]: DAB adds version 'dab_oss_major_minor_patch' to non-provided connection string property 'Application Name'.")] + [DataRow("Data Source=<>;Application Name=CustAppName;" , "Data Source=<>;Application Name=CustAppName," , false, DisplayName = "[MSSQL]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'Application Name' property.")] + [DataRow("Data Source=<>;App=CustAppName;" , "Data Source=<>;Application Name=CustAppName," , false, DisplayName = "[MSSQL]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")] + [DataRow("Data Source=<>;" , "Data Source=<>;Application Name=" , true , DisplayName = "[MSSQL]: DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'Application Name'.")] + [DataRow("Data Source=<>;Application Name=CustAppName;" , "Data Source=<>;Application Name=CustAppName," , true , DisplayName = "[MSSQL]: DAB appends DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'Application Name' property.")] + [DataRow("Data Source=<>;App=CustAppName;" , "Data Source=<>;Application Name=CustAppName," , true , DisplayName = "[MSSQL]: DAB appends version string 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")] + #pragma warning restore format + public void MsSqlConnStringSupplementedWithAppNameProperty( + string configProvidedConnString, + string expectedDabModifiedConnString, + bool dabEnvOverride) + { + // Explicitly set the DAB_APP_NAME_ENV to null to ensure that the DAB_APP_NAME_ENV is not set. + if (dabEnvOverride) + { + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted"); + } + else + { + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null); + } + + // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants. + string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent(); + expectedDabModifiedConnString += resolvedAssemblyVersion; + + RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(DatabaseType.MSSQL, configProvidedConnString); + + // Act + bool configParsed = RuntimeConfigLoader.TryParseConfig( + runtimeConfig.ToJson(), + out RuntimeConfig updatedRuntimeConfig, + replaceEnvVar: true); + + // Assert + Assert.AreEqual( + expected: true, + actual: configParsed, + message: "Runtime config unexpectedly failed parsing."); + Assert.AreEqual( + expected: expectedDabModifiedConnString, + actual: updatedRuntimeConfig.DataSource.ConnectionString, + message: "DAB did not properly set the 'Application Name' connection string property."); + } + + /// + /// Validates that DAB doesn't append nor modify + /// - the 'Application Name' or 'App' properties in MySQL database connection strings. + /// - the 'Application Name' property in + /// PostgreSQL, CosmosDB_PostgreSQl, CosmosDB_NoSQL database connection strings. + /// This test validates that this behavior holds true when the DAB_APP_NAME_ENV environment variable + /// - is set (dabEnvOverride==true) -> (DAB hosted) + /// - is not set (dabEnvOverride==false) -> (DAB OSS). /// /// database type. - /// connection string provided in the config. - /// Updated connection string with Application Name. - /// If Dab is hosted or OSS. + /// connection string provided in the config. + /// Updated connection string with Application Name. + /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.) + #pragma warning disable format [DataTestMethod] - [DataRow(DatabaseType.MSSQL, "Data Source=<>;", "Data Source=<>;Application Name=dab_oss_1.0.0", false, DisplayName = "[MSSQL]:Adding Application Name property to connectionString with dab_oss app name.")] - [DataRow(DatabaseType.MySQL, "Something;", "Something;", false, DisplayName = "[MYSQL]:No Change in connectionString without Application name for DAB oss.")] - [DataRow(DatabaseType.PostgreSQL, "Something;", "Something;", false, DisplayName = "[PGSQL]:No Change in connectionString without Application name for DAB oss.")] - [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;", "Something;", false, DisplayName = "[COSMOSDB_PGSQL]:No Change in connectionString without Application name for DAB oss.")] - [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;", "Something;", false, DisplayName = "[COSMOSDB_NOSQL]:No Change in connectionString without Application name for DAB oss.")] - [DataRow(DatabaseType.MSSQL, "Data Source=<>;Application Name=CustAppName;", "Data Source=<>;Application Name=CustAppName,dab_oss_1.0.0", false, DisplayName = "[MSSQL]:Updating connectionString containing customer Application name with dab_oss app name.")] - [DataRow(DatabaseType.MSSQL, "Data Source=<>;Application Name=CustAppName;User ID=<>", "Data Source=<>;User ID=<>;Application Name=CustAppName,dab_oss_1.0.0", false, DisplayName = "[MSSQL2]:Updating connectionString containing customer Application name with dab_oss app name.")] - [DataRow(DatabaseType.MySQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", false, DisplayName = "[MYSQL]:No Change in connectionString containing customer Application name for DAB oss.")] - [DataRow(DatabaseType.PostgreSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", false, DisplayName = "[PGSQL]:No Change in connectionString containing customer Application name for DAB oss.")] - [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", false, DisplayName = "[COSMOSDB_PGSQL]:No Change in connectionString containing customer Application name for DAB oss.")] - [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", false, DisplayName = "[COSMOSDB_NOSQL]:No Change in connectionString containg customer Application name for DAB oss.")] - [DataRow(DatabaseType.MSSQL, "Data Source=<>;", "Data Source=<>;Application Name=dab_hosted_1.0.0", true, DisplayName = "[MSSQL]:Adding Application Name property to connectionString with dab_hosted app.")] - [DataRow(DatabaseType.MySQL, "Something;", "Something;", true, DisplayName = "[MYSQL]:No Change in connectionString without Application name for DAB hosted.")] - [DataRow(DatabaseType.PostgreSQL, "Something;", "Something;", true, DisplayName = "[PGSQL]:No Change in connectionString without Application name for DAB hosted.")] - [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;", "Something;", true, DisplayName = "[COSMOSDB_PGSQL]:No Change in connectionString without Application name for DAB hosted.")] - [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;", "Something;", true, DisplayName = "[COSMOSDB_NOSQL]:No Change in connectionString without Application name for DAB hosted.")] - [DataRow(DatabaseType.MSSQL, "Data Source=<>;Application Name=CustAppName;", "Data Source=<>;Application Name=CustAppName,dab_hosted_1.0.0", true, DisplayName = "[MSSQL]:Updating connectionString containing customer Application name with dab_hosted app name.")] - [DataRow(DatabaseType.MySQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", true, DisplayName = "[MYSQL]:No Change in connectionString containing customer Application name for DAB hosted.")] - [DataRow(DatabaseType.PostgreSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", true, DisplayName = "[PGSQL]:No Change in connectionString containing customer Application name for DAB hosted.")] - [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", true, DisplayName = "[COSMOSDB_PGSQL]:No Change in connectionString containing customer Application name for DAB hosted.")] - [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", true, DisplayName = "[COSMOSDB_NOSQL]:No Change in connectionString containing customer Application name for DAB hosted.")] - [DataRow(DatabaseType.MSSQL, "Data Source=<>;App=CustAppName;User ID=<>", "Data Source=<>;User ID=<>;Application Name=CustAppName,dab_oss_1.0.0", false, DisplayName = "[MSSQL]:Updating connectionString containing `App` for customer Application name with dab_oss app name.")] - [DataRow(DatabaseType.MySQL, "Something1;App=CustAppName;Something2;", "Something1;App=CustAppName;Something2;", false, DisplayName = "[MySQL]:No updates for `App` preoperty in connectionString for DBs other than MSSQL.")] - [DataRow(DatabaseType.MySQL, "username=dabApp;App=CustAppName;Something2;", "username=dabApp;App=CustAppName;Something2;", false, DisplayName = "[MySQL]:No updates for other properties in connectionString containing `App`.")] + [DataRow(DatabaseType.MySQL, "Something;" , "Something;" , false, DisplayName = "[MYSQL|DAB OSS]:No addition of 'Application Name' or 'App' property to connection string.")] + [DataRow(DatabaseType.MySQL, "Something;Application Name=CustAppName;" , "Something;Application Name=CustAppName;" , false, DisplayName = "[MYSQL|DAB OSS]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.MySQL, "Something1;App=CustAppName;Something2;" , "Something1;App=CustAppName;Something2;" , false, DisplayName = "[MySQL|DAB OSS]:No modification of customer overridden 'App' property.")] + [DataRow(DatabaseType.MySQL, "Something;" , "Something;" , true , DisplayName = "[MYSQL|DAB hosted]:No addition of 'Application Name' or 'App' property to connection string.")] + [DataRow(DatabaseType.MySQL, "Something;Application Name=CustAppName;" , "Something;Application Name=CustAppName;" , true , DisplayName = "[MYSQL|DAB hosted]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.MySQL, "Something1;App=CustAppName;Something2;" , "Something1;App=CustAppName;Something2;" , true, DisplayName = "[MySQL|DAB hosted]:No modification of customer overridden 'App' property.")] + [DataRow(DatabaseType.PostgreSQL, "Something;" , "Something;" , false, DisplayName = "[PGSQL|DAB OSS]:No addition of 'Application Name' property to connection string.]")] + [DataRow(DatabaseType.PostgreSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", false, DisplayName = "[PGSQL|DAB OSS]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.PostgreSQL, "Something;" , "Something;" , true , DisplayName = "[PGSQL|DAB hosted]:No addition of 'Application Name' property to connection string.")] + [DataRow(DatabaseType.PostgreSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", true , DisplayName = "[PGSQL|DAB hosted]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;" , "Something;" , false, DisplayName = "[COSMOSDB_NOSQL|DAB OSS]:No addition of 'Application Name' property to connection string.")] + [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", false, DisplayName = "[COSMOSDB_NOSQL|DAB OSS]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;" , "Something;" , true , DisplayName = "[COSMOSDB_NOSQL|DAB hosted]:No addition of 'Application Name' property to connection string.")] + [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", true , DisplayName = "[COSMOSDB_NOSQL|DAB hosted]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;" , "Something;" , false, DisplayName = "[COSMOSDB_PGSQL|DAB OSS]:No addition of 'Application Name' property to connection string.")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", false, DisplayName = "[COSMOSDB_PGSQL|DAB OSS]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;" , "Something;" , true , DisplayName = "[COSMOSDB_PGSQL|DAB hosted]:No addition of 'Application Name' property to connection string.")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", true , DisplayName = "[COSMOSDB_PGSQL|DAB hosted]:No modification of customer overridden 'Application Name' property.")] + #pragma warning restore format public void TestConnectionStringIsCorrectlyUpdatedWithApplicationName( DatabaseType databaseType, - string providedConnectionString, - string expectedUpdatedConnectionString, - bool isHostedScenario) + string configProvidedConnString, + string expectedDabModifiedConnString, + bool dabEnvOverride) { - if (isHostedScenario) + // Explicitly set the DAB_APP_NAME_ENV to null to ensure that the DAB_APP_NAME_ENV is not set. + if (dabEnvOverride) { - Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted_1.0.0"); + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted"); } else { Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null); } - RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(databaseType, providedConnectionString); + RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(databaseType, configProvidedConnString); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig( + // Act + bool configParsed = RuntimeConfigLoader.TryParseConfig( runtimeConfig.ToJson(), out RuntimeConfig updatedRuntimeConfig, - replaceEnvVar: true)); - - string actualUpdatedConnectionString = updatedRuntimeConfig.DataSource.ConnectionString; + replaceEnvVar: true); - Assert.AreEqual(actualUpdatedConnectionString, expectedUpdatedConnectionString); + // Assert + Assert.AreEqual( + expected: true, + actual: configParsed, + message: "Runtime config unexpectedly failed parsing."); + Assert.AreEqual( + expected: expectedDabModifiedConnString, + actual: updatedRuntimeConfig.DataSource.ConnectionString, + message: "DAB did not properly set the 'Application Name' connection string property."); } [TestMethod("Validates that once the configuration is set, the config controller isn't reachable."), TestCategory(TestCategory.COSMOSDBNOSQL)] @@ -3196,6 +3262,99 @@ public async Task OpenApi_GlobalEntityRestPath(bool globalRestEnabled, bool expe } } + /// + /// Simulates a GET request to DAB's health check endpoint ('/') and validates the contents of the response. + /// The expected format of the response is: + /// { + /// "status": "Healthy", + /// "version": "0.12.0", + /// "appName": "dab_oss_0.12.0" + /// } + /// - the 'version' property format is 'major.minor.patch' + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task HealthEndpoint_ValidateContents() + { + // Arrange + // At least one entity is required in the runtime config for the engine to start. + // Even though this entity is not under test, it must be supplied enable successfull + // config file creation. + Entity requiredEntity = new( + Source: new("books", EntitySourceType.Table, null, null), + Rest: new(Enabled: false), + GraphQL: new("book", "books"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + Dictionary entityMap = new() + { + { "Book", requiredEntity } + }; + + CreateCustomConfigFile(globalRestEnabled: true, entityMap); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; + + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient(); + + // Setup and send GET request to root path. + HttpRequestMessage getHealthEndpointContents = new(HttpMethod.Get, $"/"); + + // Act - Exercise the health check endpoint code by requesting the health endpoint path '/'. + HttpResponseMessage response = await client.SendAsync(getHealthEndpointContents); + + // Assert - Process response body and validate contents. + // Validate HTTP return code. + string responseBody = await response.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + Assert.AreEqual(expected: HttpStatusCode.OK, actual: response.StatusCode, message: "Received unexpected HTTP code from health check endpoint."); + + // Validate value of 'status' property in reponse. + if (responseProperties.TryGetValue(key: "status", out JsonElement statusValue)) + { + Assert.AreEqual( + expected: "Healthy", + actual: statusValue.ToString(), + message: "Expected endpoint to report 'Healthy'."); + } + else + { + Assert.Fail(); + } + + // Validate value of 'version' property in response. + if (responseProperties.TryGetValue(key: DabHealthCheck.DAB_VERSION_KEY, out JsonElement versionValue)) + { + Assert.AreEqual( + expected: ProductInfo.GetProductVersion(), + actual: versionValue.ToString(), + message: "Unexpected or missing version value."); + } + else + { + Assert.Fail(); + } + + // Validate value of 'app-name' property in response. + if (responseProperties.TryGetValue(key: DabHealthCheck.DAB_APPNAME_KEY, out JsonElement appNameValue)) + { + Assert.AreEqual( + expected: ProductInfo.GetDataApiBuilderUserAgent(), + actual: appNameValue.ToString(), + message: "Unexpected or missing DAB user agent string."); + } + else + { + Assert.Fail(); + } + } + /// /// Validates the behavior of the OpenApiDocumentor when the runtime config has entities with /// REST endpoint enabled and disabled. diff --git a/src/Service.Tests/CosmosTests/CosmosClientTests.cs b/src/Service.Tests/CosmosTests/CosmosClientTests.cs index fb061c256c..6934d8a302 100644 --- a/src/Service.Tests/CosmosTests/CosmosClientTests.cs +++ b/src/Service.Tests/CosmosTests/CosmosClientTests.cs @@ -20,7 +20,7 @@ public void CosmosClientDefaultUserAgent() CosmosClientProvider cosmosClientProvider = _application.Services.GetService(); CosmosClient client = cosmosClientProvider.Clients[cosmosClientProvider.RuntimeConfigProvider.GetConfig().DefaultDataSourceName]; // Validate results - Assert.AreEqual(client.ClientOptions.ApplicationName, ProductInfo.DEFAULT_APP_NAME); + Assert.AreEqual(client.ClientOptions.ApplicationName, ProductInfo.DAB_USER_AGENT); } [TestMethod] diff --git a/src/Service/HealthCheck/DabHealthCheck.cs b/src/Service/HealthCheck/DabHealthCheck.cs new file mode 100644 index 0000000000..038b1d49a6 --- /dev/null +++ b/src/Service/HealthCheck/DabHealthCheck.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Product; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Azure.DataApiBuilder.Service.HealthCheck +{ + /// + /// Health check which returns the DAB engine's version and app name (User Agent string). + /// - version: Major.Minor.Patch + /// - app-name: dab_oss_Major.Minor.Patch + /// + internal class DabHealthCheck : IHealthCheck + { + public const string DAB_VERSION_KEY = "version"; + public const string DAB_APPNAME_KEY = "app-name"; + + /// + /// Method to check the health of the DAB engine which is executed by dotnet internals when registered as a health check + /// in startup.cs + /// + /// dotnet provided health check context. + /// cancellation token. + /// HealthCheckResult with version and appname/useragent string. + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + Dictionary dabVersionMetadata = new() + { + { DAB_VERSION_KEY, ProductInfo.GetProductVersion() }, + { DAB_APPNAME_KEY, ProductInfo.GetDataApiBuilderUserAgent() } + }; + + HealthCheckResult healthCheckResult = HealthCheckResult.Healthy( + description: "Healthy", + data: dabVersionMetadata); + + return Task.FromResult(healthCheckResult); + } + } +} diff --git a/src/Service/HealthCheck/HealthReportResponseWriter.cs b/src/Service/HealthCheck/HealthReportResponseWriter.cs new file mode 100644 index 0000000000..fc5da7783b --- /dev/null +++ b/src/Service/HealthCheck/HealthReportResponseWriter.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Service.HealthCheck +{ + /// + /// Creates a JSON response for the health check endpoint using the provided health report. + /// If the response has already been created, it will be reused. + /// + public class HealthReportResponseWriter + { + // Dependencies + private ILogger? _logger; + + // State + private byte[]? _responseBytes; + + // Constants + private const string JSON_CONTENT_TYPE = "application/json; charset=utf-8"; + + public HealthReportResponseWriter(ILogger? logger) + { + _logger = logger; + } + + /// + /// Function provided to the health check middleware to write the response. + /// + /// HttpContext for writing the response. + /// Result of health check(s). + /// Writes the http response to the http context. + public Task WriteResponse(HttpContext context, HealthReport healthReport) + { + + context.Response.ContentType = JSON_CONTENT_TYPE; + + if (_responseBytes is null) + { + _responseBytes = CreateResponse(healthReport); + } + + return context.Response.WriteAsync(Encoding.UTF8.GetString(_responseBytes)); + } + + /// + /// Using the provided health report, creates the JSON byte array to be returned and cached. + /// Currently, checks for the custom DabHealthCheck result and adds the version and app name to the response. + /// The result of the response returned for the health endpoint would be: + /// { + /// "status": "Healthy", + /// "version": "Major.Minor.Patch", + /// "appName": "dab_oss_Major.Minor.Patch" + /// } + /// + /// Collection of Health Check results calculated by dotnet HealthCheck endpoint. + /// Byte array with JSON response contents. + public byte[] CreateResponse(HealthReport healthReport) + { + JsonWriterOptions options = new() { Indented = true }; + + using MemoryStream memoryStream = new(); + using (Utf8JsonWriter jsonWriter = new(memoryStream, options)) + { + jsonWriter.WriteStartObject(); + jsonWriter.WriteString("status", healthReport.Status.ToString()); + + if (healthReport.Entries.TryGetValue(key: typeof(DabHealthCheck).Name, out HealthReportEntry healthReportEntry)) + { + if (healthReportEntry.Data.TryGetValue(DabHealthCheck.DAB_VERSION_KEY, out object? versionValue) && versionValue is string versionNumber) + { + jsonWriter.WriteString(DabHealthCheck.DAB_VERSION_KEY, versionNumber); + } + else + { + LogTrace("DabHealthCheck did not contain the version number in the HealthReport."); + } + + if (healthReportEntry.Data.TryGetValue(DabHealthCheck.DAB_APPNAME_KEY, out object? appNameValue) && appNameValue is string appName) + { + jsonWriter.WriteString(DabHealthCheck.DAB_APPNAME_KEY, appName); + } + else + { + LogTrace("DabHealthCheck did not contain the app name in the HealthReport."); + } + } + else + { + LogTrace("DabHealthCheck was not found in the HealthReport."); + } + + jsonWriter.WriteEndObject(); + } + + return memoryStream.ToArray(); + } + + /// + /// Logs a trace message if a logger is present and the logger is enabled for trace events. + /// + /// Message to emit. + private void LogTrace(string message) + { + if (_logger is not null && _logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace(message); + } + } + } +} diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 476fcd9736..eb89200d81 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -24,6 +24,7 @@ using Azure.DataApiBuilder.Core.Services.OpenAPI; using Azure.DataApiBuilder.Service.Controllers; using Azure.DataApiBuilder.Service.Exceptions; +using Azure.DataApiBuilder.Service.HealthCheck; using HotChocolate.AspNetCore; using HotChocolate.Types; using Microsoft.ApplicationInsights; @@ -33,6 +34,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -105,7 +107,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); - services.AddHealthChecks(); + services.AddHealthChecks() + .AddCheck("DabHealthCheck"); services.AddSingleton>(implementationFactory: (serviceProvider) => { @@ -139,6 +142,14 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + + // ILogger explicit creation required for logger to use --LogLevel startup argument specified. + services.AddSingleton>(implementationFactory: (serviceProvider) => + { + ILoggerFactory? loggerFactory = CreateLoggerFactoryForHostedAndNonHostedScenario(serviceProvider); + return loggerFactory.CreateLogger(); + }); services.AddSingleton>(implementationFactory: (serviceProvider) => { @@ -387,7 +398,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC Enable = false }); - endpoints.MapHealthChecks("/"); + endpoints.MapHealthChecks("/", new HealthCheckOptions + { + ResponseWriter = app.ApplicationServices.GetRequiredService().WriteResponse + }); }); } diff --git a/src/Service/Telemetry/AppInsightsTelemetryInitializer.cs b/src/Service/Telemetry/AppInsightsTelemetryInitializer.cs index 3531ff8c2a..bd73def78c 100644 --- a/src/Service/Telemetry/AppInsightsTelemetryInitializer.cs +++ b/src/Service/Telemetry/AppInsightsTelemetryInitializer.cs @@ -8,7 +8,7 @@ public class AppInsightsTelemetryInitializer : ITelemetryInitializer { public static readonly IReadOnlyDictionary GlobalProperties = new Dictionary { - { "ProductName", $"{ProductInfo.DEFAULT_APP_NAME}"}, + { "ProductName", $"{ProductInfo.DAB_USER_AGENT}"}, { "UserAgent", $"{ProductInfo.GetDataApiBuilderUserAgent()}" } // Add more custom properties here }; @@ -19,7 +19,7 @@ public class AppInsightsTelemetryInitializer : ITelemetryInitializer /// The telemetry object to initialize public void Initialize(ITelemetry telemetry) { - telemetry.Context.Cloud.RoleName = ProductInfo.ROLE_NAME; + telemetry.Context.Cloud.RoleName = ProductInfo.CLOUD_ROLE_NAME; telemetry.Context.Session.Id = Guid.NewGuid().ToString(); telemetry.Context.Component.Version = ProductInfo.GetProductVersion();