diff --git a/Aspire.sln b/Aspire.sln index 597d40bc70..46422771a7 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -492,16 +491,32 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Azure.WebPub EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Valkey", "src\Aspire.Hosting.Valkey\Aspire.Hosting.Valkey.csproj", "{5CB63205-24F4-4388-A41B-BAF3BEA59866}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "withdockerfile", "withdockerfile", "{A6813855-E322-41EF-B2E6-7A44949EF962}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Elasticsearch", "src\Aspire.Hosting.Elasticsearch\Aspire.Hosting.Elasticsearch.csproj", "{9357EC71-823B-433A-9993-B7CB2FA082D1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Elastic.Clients.Elasticsearch", "src\Components\Aspire.Elastic.Clients.Elasticsearch\Aspire.Elastic.Clients.Elasticsearch.csproj", "{3F7B206E-5457-458F-AA81-9449FA3C1B5C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Elasticsearch", "Elasticsearch", "{6C71A90C-30AE-45D7-9347-D66F9B257CBE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elasticsearch.ApiService", "playground\Elasticsearch\Elasticsearch.ApiService\Elasticsearch.ApiService.csproj", "{F7F57331-5DDD-444A-A620-8639FC9362B2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elasticsearch.AppHost", "playground\Elasticsearch\Elasticsearch.AppHost\Elasticsearch.AppHost.csproj", "{569B8215-BDB1-445D-A7BA-DB255700A4AC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dockerfile", "dockerfile", "{A6813855-E322-41EF-B2E6-7A44949EF962}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "withdockerfile", "withdockerfile", "{5B5DC91B-5754-4CE5-B3C0-FA1584B916A0}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WithDockerfile.AppHost", "playground\withdockerfile\WithDockerfile.AppHost\WithDockerfile.AppHost.csproj", "{E6BE41D3-872C-47D2-B5B1-78C37AFAEAF9}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Containers.Tests", "tests\Aspire.Hosting.Containers.Tests\Aspire.Hosting.Containers.Tests.csproj", "{588CD2D7-EE70-43C1-8233-330854BDF53C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Elastic.Clients.Elasticsearch.Tests", "tests\Aspire.Elastic.Clients.Elasticsearch.Tests\Aspire.Elastic.Clients.Elasticsearch.Tests.csproj", "{D5B392A4-29CD-41F9-8847-0C211C832713}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "python", "python", "{7123AB7A-A4FD-4F64-8B05-D2DD0C3E2ABC}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Python.AppHost", "playground\python\Python.AppHost\Python.AppHost.csproj", "{173BDA6E-F175-4457-BF64-58CD184E9A81}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Elasticsearch.Tests", "tests\Aspire.Hosting.Elasticsearch.Tests\Aspire.Hosting.Elasticsearch.Tests.csproj", "{62D8C73C-DAB3-4B9E-A508-34C886C374F9}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Components", "Components", "{C424395C-1235-41A4-BF55-07880A04368C}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hosting", "Hosting", "{830A89EC-4029-4753-B25A-068BAE37DEC7}" @@ -1320,6 +1335,22 @@ Global {5CB63205-24F4-4388-A41B-BAF3BEA59866}.Debug|Any CPU.Build.0 = Debug|Any CPU {5CB63205-24F4-4388-A41B-BAF3BEA59866}.Release|Any CPU.ActiveCfg = Release|Any CPU {5CB63205-24F4-4388-A41B-BAF3BEA59866}.Release|Any CPU.Build.0 = Release|Any CPU + {9357EC71-823B-433A-9993-B7CB2FA082D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9357EC71-823B-433A-9993-B7CB2FA082D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9357EC71-823B-433A-9993-B7CB2FA082D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9357EC71-823B-433A-9993-B7CB2FA082D1}.Release|Any CPU.Build.0 = Release|Any CPU + {3F7B206E-5457-458F-AA81-9449FA3C1B5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F7B206E-5457-458F-AA81-9449FA3C1B5C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F7B206E-5457-458F-AA81-9449FA3C1B5C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F7B206E-5457-458F-AA81-9449FA3C1B5C}.Release|Any CPU.Build.0 = Release|Any CPU + {F7F57331-5DDD-444A-A620-8639FC9362B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F7F57331-5DDD-444A-A620-8639FC9362B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7F57331-5DDD-444A-A620-8639FC9362B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7F57331-5DDD-444A-A620-8639FC9362B2}.Release|Any CPU.Build.0 = Release|Any CPU + {569B8215-BDB1-445D-A7BA-DB255700A4AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {569B8215-BDB1-445D-A7BA-DB255700A4AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {569B8215-BDB1-445D-A7BA-DB255700A4AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {569B8215-BDB1-445D-A7BA-DB255700A4AC}.Release|Any CPU.Build.0 = Release|Any CPU {E6BE41D3-872C-47D2-B5B1-78C37AFAEAF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E6BE41D3-872C-47D2-B5B1-78C37AFAEAF9}.Debug|Any CPU.Build.0 = Debug|Any CPU {E6BE41D3-872C-47D2-B5B1-78C37AFAEAF9}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -1328,10 +1359,18 @@ Global {588CD2D7-EE70-43C1-8233-330854BDF53C}.Debug|Any CPU.Build.0 = Debug|Any CPU {588CD2D7-EE70-43C1-8233-330854BDF53C}.Release|Any CPU.ActiveCfg = Release|Any CPU {588CD2D7-EE70-43C1-8233-330854BDF53C}.Release|Any CPU.Build.0 = Release|Any CPU + {D5B392A4-29CD-41F9-8847-0C211C832713}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5B392A4-29CD-41F9-8847-0C211C832713}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5B392A4-29CD-41F9-8847-0C211C832713}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5B392A4-29CD-41F9-8847-0C211C832713}.Release|Any CPU.Build.0 = Release|Any CPU {173BDA6E-F175-4457-BF64-58CD184E9A81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {173BDA6E-F175-4457-BF64-58CD184E9A81}.Debug|Any CPU.Build.0 = Debug|Any CPU {173BDA6E-F175-4457-BF64-58CD184E9A81}.Release|Any CPU.ActiveCfg = Release|Any CPU {173BDA6E-F175-4457-BF64-58CD184E9A81}.Release|Any CPU.Build.0 = Release|Any CPU + {62D8C73C-DAB3-4B9E-A508-34C886C374F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {62D8C73C-DAB3-4B9E-A508-34C886C374F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {62D8C73C-DAB3-4B9E-A508-34C886C374F9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {62D8C73C-DAB3-4B9E-A508-34C886C374F9}.Release|Any CPU.Build.0 = Release|Any CPU {1BC02557-B78B-48CE-9D3C-488A6B7672F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1BC02557-B78B-48CE-9D3C-488A6B7672F4}.Debug|Any CPU.Build.0 = Debug|Any CPU {1BC02557-B78B-48CE-9D3C-488A6B7672F4}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -1591,11 +1630,18 @@ Global {9FAE1602-2C69-4D24-8655-A164489441E8} = {C424395C-1235-41A4-BF55-07880A04368C} {DF00FDA3-D3EC-4E07-B4EC-0EBB57A813A4} = {77CFE74A-32EE-400C-8930-5025E8555256} {5CB63205-24F4-4388-A41B-BAF3BEA59866} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47} + {9357EC71-823B-433A-9993-B7CB2FA082D1} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47} + {3F7B206E-5457-458F-AA81-9449FA3C1B5C} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} + {6C71A90C-30AE-45D7-9347-D66F9B257CBE} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} + {F7F57331-5DDD-444A-A620-8639FC9362B2} = {6C71A90C-30AE-45D7-9347-D66F9B257CBE} + {569B8215-BDB1-445D-A7BA-DB255700A4AC} = {6C71A90C-30AE-45D7-9347-D66F9B257CBE} {A6813855-E322-41EF-B2E6-7A44949EF962} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} {E6BE41D3-872C-47D2-B5B1-78C37AFAEAF9} = {A6813855-E322-41EF-B2E6-7A44949EF962} {588CD2D7-EE70-43C1-8233-330854BDF53C} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} + {D5B392A4-29CD-41F9-8847-0C211C832713} = {C424395C-1235-41A4-BF55-07880A04368C} {7123AB7A-A4FD-4F64-8B05-D2DD0C3E2ABC} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} {173BDA6E-F175-4457-BF64-58CD184E9A81} = {7123AB7A-A4FD-4F64-8B05-D2DD0C3E2ABC} + {62D8C73C-DAB3-4B9E-A508-34C886C374F9} = {830A89EC-4029-4753-B25A-068BAE37DEC7} {C424395C-1235-41A4-BF55-07880A04368C} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {830A89EC-4029-4753-B25A-068BAE37DEC7} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {1BC02557-B78B-48CE-9D3C-488A6B7672F4} = {830A89EC-4029-4753-B25A-068BAE37DEC7} diff --git a/Directory.Packages.props b/Directory.Packages.props index b55c0820e5..52a24f322f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -94,6 +94,7 @@ + @@ -154,7 +155,7 @@ - + diff --git a/playground/Elasticsearch/Elasticsearch.ApiService/Elasticsearch.ApiService.csproj b/playground/Elasticsearch/Elasticsearch.ApiService/Elasticsearch.ApiService.csproj new file mode 100644 index 0000000000..9d28cd91b4 --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.ApiService/Elasticsearch.ApiService.csproj @@ -0,0 +1,12 @@ + + + net8.0 + enable + enable + + + + + + + diff --git a/playground/Elasticsearch/Elasticsearch.ApiService/Models/Person.cs b/playground/Elasticsearch/Elasticsearch.ApiService/Models/Person.cs new file mode 100644 index 0000000000..b8fc7803bb --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.ApiService/Models/Person.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Elasticsearch.ApiService.Models; + +public class Person +{ + public required string FirstName { get; set; } + public required string LastName { get; set; } +} diff --git a/playground/Elasticsearch/Elasticsearch.ApiService/Program.cs b/playground/Elasticsearch/Elasticsearch.ApiService/Program.cs new file mode 100644 index 0000000000..c36a82d883 --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.ApiService/Program.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Elastic.Clients.Elasticsearch; +using Elasticsearch.ApiService.Models; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddElasticsearchClient("elasticsearch"); + +var app = builder.Build(); + +app.MapGet("/get", async (ElasticsearchClient elasticClient) => +{ + var response = await elasticClient.GetAsync("people", "1"); + return response; +}); + +app.MapGet("/create", async (ElasticsearchClient elasticClient) => +{ + var exist = await elasticClient.Indices.ExistsAsync("people"); + if (exist.Exists) + { + await elasticClient.Indices.DeleteAsync("people"); + } + + var person = new Person + { + FirstName = "Alireza", + LastName = "Baloochi" + }; + + var response = await elasticClient.IndexAsync(person, "people", "1"); + return response; +}); + +app.Run(); diff --git a/playground/Elasticsearch/Elasticsearch.ApiService/Properties/launchSettings.json b/playground/Elasticsearch/Elasticsearch.ApiService/Properties/launchSettings.json new file mode 100644 index 0000000000..c6bb44e0e5 --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.ApiService/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:4855", + "sslPort": 44376 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5053", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7158;http://localhost:5053", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/Elasticsearch/Elasticsearch.ApiService/appsettings.Development.json b/playground/Elasticsearch/Elasticsearch.ApiService/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/Elasticsearch/Elasticsearch.ApiService/appsettings.json b/playground/Elasticsearch/Elasticsearch.ApiService/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/playground/Elasticsearch/Elasticsearch.AppHost/Directory.Build.props b/playground/Elasticsearch/Elasticsearch.AppHost/Directory.Build.props new file mode 100644 index 0000000000..d9b2c324ac --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.AppHost/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/playground/Elasticsearch/Elasticsearch.AppHost/Directory.Build.targets b/playground/Elasticsearch/Elasticsearch.AppHost/Directory.Build.targets new file mode 100644 index 0000000000..466c472e8d --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.AppHost/Directory.Build.targets @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/playground/Elasticsearch/Elasticsearch.AppHost/Elasticsearch.AppHost.csproj b/playground/Elasticsearch/Elasticsearch.AppHost/Elasticsearch.AppHost.csproj new file mode 100644 index 0000000000..c707b72bac --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.AppHost/Elasticsearch.AppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + enable + enable + true + 76c9a20b-a2cc-4874-8d26-6fbe827d11bf + + + + + + + + + + + + + diff --git a/playground/Elasticsearch/Elasticsearch.AppHost/Program.cs b/playground/Elasticsearch/Elasticsearch.AppHost/Program.cs new file mode 100644 index 0000000000..b6d273b2ef --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.AppHost/Program.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); + +var elasticsearch = builder.AddElasticsearch("elasticsearch") + .WithDataVolume(); + +builder.AddProject("elasticsearch-apiservice") + .WithReference(elasticsearch); + +// This project is only added in playground projects to support development/debugging +// of the dashboard. It is not required in end developer code. Comment out this code +// to test end developer dashboard launch experience. Refer to Directory.Build.props +// for the path to the dashboard binary (defaults to the Aspire.Dashboard bin output +// in the artifacts dir). +builder.AddProject(KnownResourceNames.AspireDashboard); + +builder.Build().Run(); diff --git a/playground/Elasticsearch/Elasticsearch.AppHost/Properties/launchSettings.json b/playground/Elasticsearch/Elasticsearch.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..cf4b3df509 --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.AppHost/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17196;http://localhost:15122", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21146", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22194" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15122", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19117", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20104" + } + }, + "generate-manifest": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "applicationUrl": "http://localhost:15122", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16175" + } + } + } +} diff --git a/playground/Elasticsearch/Elasticsearch.AppHost/appsettings.Development.json b/playground/Elasticsearch/Elasticsearch.AppHost/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/Elasticsearch/Elasticsearch.AppHost/appsettings.json b/playground/Elasticsearch/Elasticsearch.AppHost/appsettings.json new file mode 100644 index 0000000000..31c092aa45 --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/playground/Elasticsearch/Elasticsearch.AppHost/aspire-manifest.json b/playground/Elasticsearch/Elasticsearch.AppHost/aspire-manifest.json new file mode 100644 index 0000000000..4739cf32ac --- /dev/null +++ b/playground/Elasticsearch/Elasticsearch.AppHost/aspire-manifest.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json.schemastore.org/aspire-8.0.json", + "resources": { + "elasticsearch": { + "type": "container.v0", + "connectionString": "http://elastic:{elasticsearch-password.value}@{elasticsearch.bindings.http.host}:{elasticsearch.bindings.http.port}", + "image": "docker.io/library/elasticsearch:8.14.0", + "volumes": [ + { + "name": "data", + "target": "/usr/share/elasticsearch/data", + "readOnly": false + } + ], + "env": { + "discovery.type": "single-node", + "xpack.security.enabled": "true", + "ELASTIC_PASSWORD": "{elasticsearch-password.value}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 9200 + }, + "internal": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 9300 + } + } + }, + "elasticsearch-apiservice": { + "type": "project.v0", + "path": "../Elasticsearch.ApiService/Elasticsearch.ApiService.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "ConnectionStrings__elasticsearch": "{elasticsearch.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + }, + "elasticsearch-password": { + "type": "parameter.v0", + "value": "{elasticsearch-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22 + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting.Elasticsearch/Aspire.Hosting.Elasticsearch.csproj b/src/Aspire.Hosting.Elasticsearch/Aspire.Hosting.Elasticsearch.csproj new file mode 100644 index 0000000000..d50c33041c --- /dev/null +++ b/src/Aspire.Hosting.Elasticsearch/Aspire.Hosting.Elasticsearch.csproj @@ -0,0 +1,28 @@ + + + + $(NetCurrent) + true + true + aspire hosting elasticsearch + Elasticsearch support for .NET Aspire. + $(SharedDir)Elastic_logo.png + + + + 80 + + + + + + + + + + + + + + + diff --git a/src/Aspire.Hosting.Elasticsearch/ElasticsearchBuilderExtensions.cs b/src/Aspire.Hosting.Elasticsearch/ElasticsearchBuilderExtensions.cs new file mode 100644 index 0000000000..e33af85e63 --- /dev/null +++ b/src/Aspire.Hosting.Elasticsearch/ElasticsearchBuilderExtensions.cs @@ -0,0 +1,110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Elasticsearch; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Elasticsearch resources to the application model. +/// +public static class ElasticsearchBuilderExtensions +{ + private const int ElasticsearchPort = 9200; + private const int ElasticsearchInternalPort = 9300; + + /// + /// Adds an Elasticsearch container resource to the application model. + /// + /// + /// The default image is "elasticsearch" and the tag is "8.14.0". + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The host port to bind the underlying container to. + /// The parameter used to provide the superuser password for the elasticsearch. If a random password will be generated. + /// A reference to the . + /// + /// Add an Elasticsearch container to the application model and reference it in a .NET project. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var elasticsearch = builder.AddElasticsearch("elasticsearch"); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(elasticsearch); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddElasticsearch( + this IDistributedApplicationBuilder builder, + string name, + IResourceBuilder? password = null, + int? port = null) + { + var passwordParameter = password?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-password"); + + var elasticsearch = new ElasticsearchResource(name, passwordParameter); + + return builder.AddResource(elasticsearch) + .WithImage(ElasticsearchContainerImageTags.Image, ElasticsearchContainerImageTags.Tag) + .WithImageRegistry(ElasticsearchContainerImageTags.Registry) + .WithHttpEndpoint(targetPort: ElasticsearchPort, port: port, name: ElasticsearchResource.PrimaryEndpointName) + .WithEndpoint(targetPort: ElasticsearchInternalPort, port: port, name: ElasticsearchResource.InternalEndpointName) + .WithEnvironment("discovery.type", "single-node") + .WithEnvironment("xpack.security.enabled", "true") + .WithEnvironment(context => + { + context.EnvironmentVariables["ELASTIC_PASSWORD"] = elasticsearch.PasswordParameter; + }); + } + + /// + /// Adds a named volume for the data folder to a Elasticseach container resource. + /// + /// The resource builder. + /// The name of the volume. Defaults to an auto-generated name based on the application and resource names. + /// The . + /// + /// Add an Elasticsearch container to the application model and reference it in a .NET project. Additionally, in this + /// example a data volume is added to the container to allow data to be persisted across container restarts. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var elasticsearch = builder.AddElasticsearch("elasticsearch") + /// .WithDataVolume(); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(elasticsearch); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder WithDataVolume(this IResourceBuilder builder, string? name = null) + => builder.WithVolume(name ?? VolumeNameGenerator.CreateVolumeName(builder, "data"), "/usr/share/elasticsearch/data"); + + /// + /// Adds a bind mount for the data folder to a Elasticseach container resource. + /// + /// The resource builder. + /// The source directory on the host to mount into the container. + /// The . + /// + /// Add an Elasticsearch container to the application model and reference it in a .NET project. Additionally, in this + /// example a bind mount is added to the container to allow data to be persisted across container restarts. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var elasticsearch = builder.AddElasticsearch("elasticsearch") + /// .WithDataBindMount("./data/elasticsearch/data"); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(elasticsearch); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder WithDataBindMount(this IResourceBuilder builder, string source) + => builder.WithBindMount(source, "/usr/share/elasticsearch/data"); + +} diff --git a/src/Aspire.Hosting.Elasticsearch/ElasticsearchContainerImageTags.cs b/src/Aspire.Hosting.Elasticsearch/ElasticsearchContainerImageTags.cs new file mode 100644 index 0000000000..4a43f9c7a3 --- /dev/null +++ b/src/Aspire.Hosting.Elasticsearch/ElasticsearchContainerImageTags.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Elasticsearch; + +internal static class ElasticsearchContainerImageTags +{ + public const string Registry = "docker.io"; + public const string Image = "library/elasticsearch"; + public const string Tag = "8.14.0"; +} + diff --git a/src/Aspire.Hosting.Elasticsearch/ElasticsearchResource.cs b/src/Aspire.Hosting.Elasticsearch/ElasticsearchResource.cs new file mode 100644 index 0000000000..bac9b49d20 --- /dev/null +++ b/src/Aspire.Hosting.Elasticsearch/ElasticsearchResource.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Elasticsearch +/// +public class ElasticsearchResource : ContainerResource, IResourceWithConnectionString +{ + /// + /// Gets the Elasticsearch container superuser name. + /// + private const string UserName = "elastic"; + + // this endpoint is used for all API calls over HTTP. + // This includes search and aggregations, monitoring and anything else that uses a HTTP request. + // All client libraries will use this port to talk to Elasticsearch + internal const string PrimaryEndpointName = "http"; + + //this endpoint is a custom binary protocol used for communications between nodes in a cluster. + //For things like cluster updates, master elections, nodes joining/leaving, shard allocation + internal const string InternalEndpointName = "internal"; + + /// The name of the resource. + /// A parameter that contains the Elasticsearch superuser password. + public ElasticsearchResource(string name, ParameterResource password) : base(name) + { + ArgumentNullException.ThrowIfNull(password); + PasswordParameter = password; + } + + private EndpointReference? _primaryEndpoint; + private EndpointReference? _internalEndpoint; + + /// + /// Gets the primary endpoint for the Elasticsearch. This endpoint is used for all API calls over HTTP. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); + + /// + /// Gets the internal endpoint for the Elasticsearch. This endpoint used for communications between nodes in a cluster + /// + public EndpointReference InternalEndpoint => _internalEndpoint ??= new(this, InternalEndpointName); + + /// + /// Gets the parameter that contains the Elasticsearch superuser password. + /// + public ParameterResource PasswordParameter { get; } + + /// + /// Gets the connection string expression for the Elasticsearch + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create($"http://{UserName}:{PasswordParameter}@{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}"); +} + diff --git a/src/Aspire.Hosting.Elasticsearch/PublicAPI.Shipped.txt b/src/Aspire.Hosting.Elasticsearch/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/Aspire.Hosting.Elasticsearch/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Aspire.Hosting.Elasticsearch/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.Elasticsearch/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..9f0832cefd --- /dev/null +++ b/src/Aspire.Hosting.Elasticsearch/PublicAPI.Unshipped.txt @@ -0,0 +1,10 @@ +Aspire.Hosting.ApplicationModel.ElasticsearchResource +Aspire.Hosting.ApplicationModel.ElasticsearchResource.ConnectionStringExpression.get -> Aspire.Hosting.ApplicationModel.ReferenceExpression! +Aspire.Hosting.ApplicationModel.ElasticsearchResource.ElasticsearchResource(string! name, Aspire.Hosting.ApplicationModel.ParameterResource! password) -> void +Aspire.Hosting.ApplicationModel.ElasticsearchResource.InternalEndpoint.get -> Aspire.Hosting.ApplicationModel.EndpointReference! +Aspire.Hosting.ApplicationModel.ElasticsearchResource.PasswordParameter.get -> Aspire.Hosting.ApplicationModel.ParameterResource! +Aspire.Hosting.ApplicationModel.ElasticsearchResource.PrimaryEndpoint.get -> Aspire.Hosting.ApplicationModel.EndpointReference! +Aspire.Hosting.ElasticsearchBuilderExtensions +static Aspire.Hosting.ElasticsearchBuilderExtensions.AddElasticsearch(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, Aspire.Hosting.ApplicationModel.IResourceBuilder? password = null, int? port = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.ElasticsearchBuilderExtensions.WithDataBindMount(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! source) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.ElasticsearchBuilderExtensions.WithDataVolume(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string? name = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! diff --git a/src/Components/Aspire.Elastic.Clients.Elasticsearch/Aspire.Elastic.Clients.Elasticsearch.csproj b/src/Components/Aspire.Elastic.Clients.Elasticsearch/Aspire.Elastic.Clients.Elasticsearch.csproj new file mode 100644 index 0000000000..2684b2952a --- /dev/null +++ b/src/Components/Aspire.Elastic.Clients.Elasticsearch/Aspire.Elastic.Clients.Elasticsearch.csproj @@ -0,0 +1,31 @@ + + + + $(NetCurrent) + true + true + $(ComponentCommonPackageTags) Elasticsearch + A Elasticsearch client that integrates with Aspire, including health checks, logging, and telemetry. + $(SharedDir)Elastic_logo.png + + false + + + + 80 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Components/Aspire.Elastic.Clients.Elasticsearch/AspireElasticClientsElasticsearchExtensions.cs b/src/Components/Aspire.Elastic.Clients.Elasticsearch/AspireElasticClientsElasticsearchExtensions.cs new file mode 100644 index 0000000000..1dd731cb0b --- /dev/null +++ b/src/Components/Aspire.Elastic.Clients.Elasticsearch/AspireElasticClientsElasticsearchExtensions.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using Aspire.Elastic.Clients.Elasticsearch; +using Elastic.Clients.Elasticsearch; +using Elastic.Transport; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods for connecting Elasticsearch with Elastic.Clients.Elasticsearch client. +/// +public static class AspireElasticClientsElasticsearchExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Elastic:Clients:Elasticsearch"; + private const string ActivityNameSource = "Elastic.Transport"; + /// + /// Registers instance for connecting to Elasticsearch with Elastic.Clients.Elasticsearch client. + /// + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional delegate that can be used for customizing options. It's invoked after the settings are read from the configuration. + /// An optional delegate that can be used for customizing ElasticsearchClientSettings. + /// If required ConnectionString is not provided in configuration section + public static void AddElasticsearchClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + Action? configureClientSettings = null + ) => builder.AddElasticsearchClient(DefaultConfigSectionName, configureSettings, configureClientSettings, connectionName, serviceKey: null); + + /// + /// Registers instance for connecting to Elasticsearch with Elastic.Clients.Elasticsearch client. + /// + /// The to read config from and add services to. + /// The name of the component, which is used as the of the service and also to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional delegate that can be used for customizing options. It's invoked after the settings are read from the configuration. + /// An optional delegate that can be used for customizing ElasticsearchClientSettings. + /// If required ConnectionString is not provided in configuration section + public static void AddKeyedElasticsearchClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null, + Action? configureClientSettings = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + builder.AddElasticsearchClient( + $"{DefaultConfigSectionName}:{name}", + configureSettings, + configureClientSettings, + connectionName: name, + serviceKey: name); + } + + private static void AddElasticsearchClient( + this IHostApplicationBuilder builder, + string configurationSectionName, + Action? configureSettings, + Action? configureClientSettings, + string connectionName, + object? serviceKey) + { + ArgumentNullException.ThrowIfNull(builder); + + var configSection = builder.Configuration.GetSection(configurationSectionName); + + ElasticClientsElasticsearchSettings settings = new(); + configSection.Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ParseConnectionString(connectionString); + } + + configureSettings?.Invoke(settings); + + if (serviceKey is null) + { + builder.Services.AddSingleton(CreateElasticsearchClient); + } + else + { + builder.Services.AddKeyedSingleton(serviceKey, (sp, key) => CreateElasticsearchClient(sp)); + } + + if (!settings.DisableTracing) + { + builder.Services + .AddOpenTelemetry() + .WithTracing(tracer => tracer.AddSource(ActivityNameSource)); + } + + if (!settings.DisableHealthChecks) + { + var healthCheckName = serviceKey is null ? "Elastic.Clients.Elasticsearch" : $"Elastic.Clients.Elasticsearch_{connectionName}"; + + builder.TryAddHealthCheck(new HealthCheckRegistration( + healthCheckName, + sp => new ElasticsearchHealthCheck(serviceKey is null ? + sp.GetRequiredService() : + sp.GetRequiredKeyedService(serviceKey)), + failureStatus: null, + tags: null, + timeout: settings.HealthCheckTimeout > 0 ? TimeSpan.FromMilliseconds(settings.HealthCheckTimeout.Value) : null + )); + } + + ElasticsearchClient CreateElasticsearchClient(IServiceProvider serviceProvider) + { + var elasticsearchClientSettings = CreateElasticsearchClientSettings(settings, connectionName, configurationSectionName); + + configureClientSettings?.Invoke(elasticsearchClientSettings); + + return new ElasticsearchClient(elasticsearchClientSettings); + } + } + + private static ElasticsearchClientSettings CreateElasticsearchClientSettings( + ElasticClientsElasticsearchSettings settings, + string connectionName, + string configurationSectionName) + { + if (settings.Endpoint is not null) + { + return new ElasticsearchClientSettings(settings.Endpoint); + } + else if (settings.CloudId is not null && settings.ApiKey is not null) + { + return new(settings.CloudId, new ApiKey(settings.ApiKey)); + } + + throw new InvalidOperationException( + $"A ElasticsearchClient could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or either " + + $"{nameof(settings.Endpoint)} must be provided " + + $"in the '{configurationSectionName}' configuration section."); + } +} diff --git a/src/Components/Aspire.Elastic.Clients.Elasticsearch/AssemblyInfo.cs b/src/Components/Aspire.Elastic.Clients.Elasticsearch/AssemblyInfo.cs new file mode 100644 index 0000000000..462de01119 --- /dev/null +++ b/src/Components/Aspire.Elastic.Clients.Elasticsearch/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using Aspire.Elastic.Clients.Elasticsearch; + +[assembly: ConfigurationSchema("Aspire:Elastic:Clients:Elasticsearch", typeof(ElasticClientsElasticsearchSettings))] diff --git a/src/Components/Aspire.Elastic.Clients.Elasticsearch/ConfigurationSchema.json b/src/Components/Aspire.Elastic.Clients.Elasticsearch/ConfigurationSchema.json new file mode 100644 index 0000000000..ad738f07bc --- /dev/null +++ b/src/Components/Aspire.Elastic.Clients.Elasticsearch/ConfigurationSchema.json @@ -0,0 +1,53 @@ +{ + "type": "object", + "properties": { + "Aspire": { + "type": "object", + "properties": { + "Elastic": { + "type": "object", + "properties": { + "Clients": { + "type": "object", + "properties": { + "Elasticsearch": { + "type": "object", + "properties": { + "ApiKey": { + "type": "string", + "description": "The API Key of the Elastic Cloud to connect to." + }, + "CloudId": { + "type": "string", + "description": "The CloudId of the Elastic Cloud to connect to." + }, + "DisableHealthChecks": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the Elasticsearch health check is disabled or not.", + "default": false + }, + "DisableTracing": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not.", + "default": false + }, + "Endpoint": { + "type": "string", + "format": "uri", + "description": "The endpoint URI string of the Elasticsearch to connect to." + }, + "HealthCheckTimeout": { + "type": "integer", + "description": "Gets or sets a integer value that indicates the Elasticsearch health check timeout in milliseconds." + } + }, + "description": "Provides the client configuration settings for connecting to a Elasticsearch using Elastic.Clients.Elasticsearch." + } + } + } + } + } + } + } + } +} diff --git a/src/Components/Aspire.Elastic.Clients.Elasticsearch/ElasticClientsElasticsearchSettings.cs b/src/Components/Aspire.Elastic.Clients.Elasticsearch/ElasticClientsElasticsearchSettings.cs new file mode 100644 index 0000000000..28f5d74408 --- /dev/null +++ b/src/Components/Aspire.Elastic.Clients.Elasticsearch/ElasticClientsElasticsearchSettings.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data.Common; + +namespace Aspire.Elastic.Clients.Elasticsearch; + +/// +/// Provides the client configuration settings for connecting to a Elasticsearch using Elastic.Clients.Elasticsearch. +/// +public sealed class ElasticClientsElasticsearchSettings +{ + + private const string ConnectionStringEndpoint = "Endpoint"; + private const string ConnectionStringApiKey = "ApiKey"; + private const string ConnectionStringCloudId = "CloudId"; + + /// + /// Gets or sets a boolean value that indicates whether the Elasticsearch health check is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableHealthChecks { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableTracing { get; set; } + + /// + /// Gets or sets a integer value that indicates the Elasticsearch health check timeout in milliseconds. + /// + public int? HealthCheckTimeout { get; set; } + + /// + /// The endpoint URI string of the Elasticsearch to connect to. + /// + public Uri? Endpoint { get; set; } + + /// + /// The API Key of the Elastic Cloud to connect to. + /// + public string? ApiKey { get; set; } + + /// + /// The CloudId of the Elastic Cloud to connect to. + /// + public string? CloudId { get; set; } + + internal void ParseConnectionString(string? connectionString) + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + Endpoint = uri; + } + else + { + var connectionBuilder = new DbConnectionStringBuilder + { + ConnectionString = connectionString + }; + + if (connectionBuilder.ContainsKey(ConnectionStringEndpoint) && Uri.TryCreate(connectionBuilder[ConnectionStringEndpoint].ToString(), UriKind.Absolute, out var serviceUri)) + { + Endpoint = serviceUri; + } + + if (connectionBuilder.ContainsKey(ConnectionStringApiKey)) + { + ApiKey = connectionBuilder[ConnectionStringApiKey].ToString(); + } + + if (connectionBuilder.ContainsKey(ConnectionStringCloudId)) + { + CloudId = connectionBuilder[ConnectionStringCloudId].ToString(); + } + } + } +} diff --git a/src/Components/Aspire.Elastic.Clients.Elasticsearch/ElasticsearchHealthCheck.cs b/src/Components/Aspire.Elastic.Clients.Elasticsearch/ElasticsearchHealthCheck.cs new file mode 100644 index 0000000000..17ed4c0246 --- /dev/null +++ b/src/Components/Aspire.Elastic.Clients.Elasticsearch/ElasticsearchHealthCheck.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Elastic.Clients.Elasticsearch; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +// TODO: Use health check from AspNetCore.Diagnostics.HealthChecks once following PR released: +// https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks/pull/2244 +namespace Aspire.Elastic.Clients.Elasticsearch; + +internal sealed class ElasticsearchHealthCheck : IHealthCheck +{ + private readonly ElasticsearchClient _elasticsearchClient; + + public ElasticsearchHealthCheck(ElasticsearchClient elasticsearchClient) + { + ArgumentNullException.ThrowIfNull(elasticsearchClient, nameof(elasticsearchClient)); + _elasticsearchClient = elasticsearchClient; + } + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + var pingResult = await _elasticsearchClient.PingAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + bool isSuccess = pingResult.ApiCallDetails.HttpStatusCode == 200; + + return isSuccess + ? HealthCheckResult.Healthy() + : new HealthCheckResult(context.Registration.FailureStatus); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, exception: ex); + } + } +} diff --git a/src/Components/Aspire.Elastic.Clients.Elasticsearch/PublicAPI.Shipped.txt b/src/Components/Aspire.Elastic.Clients.Elasticsearch/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/Components/Aspire.Elastic.Clients.Elasticsearch/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Components/Aspire.Elastic.Clients.Elasticsearch/PublicAPI.Unshipped.txt b/src/Components/Aspire.Elastic.Clients.Elasticsearch/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..7cc78d0c12 --- /dev/null +++ b/src/Components/Aspire.Elastic.Clients.Elasticsearch/PublicAPI.Unshipped.txt @@ -0,0 +1,18 @@ +#nullable enable +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.ApiKey.get -> string? +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.ApiKey.set -> void +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.CloudId.get -> string? +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.CloudId.set -> void +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.DisableHealthChecks.get -> bool +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.DisableHealthChecks.set -> void +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.DisableTracing.get -> bool +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.DisableTracing.set -> void +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.ElasticClientsElasticsearchSettings() -> void +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.Endpoint.get -> System.Uri? +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.Endpoint.set -> void +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.HealthCheckTimeout.get -> int? +Aspire.Elastic.Clients.Elasticsearch.ElasticClientsElasticsearchSettings.HealthCheckTimeout.set -> void +Microsoft.Extensions.Hosting.AspireElasticClientsElasticsearchExtensions +static Microsoft.Extensions.Hosting.AspireElasticClientsElasticsearchExtensions.AddElasticsearchClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! connectionName, System.Action? configureSettings = null, System.Action? configureClientSettings = null) -> void +static Microsoft.Extensions.Hosting.AspireElasticClientsElasticsearchExtensions.AddKeyedElasticsearchClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! name, System.Action? configureSettings = null, System.Action? configureClientSettings = null) -> void diff --git a/src/Components/Aspire.Elastic.Clients.Elasticsearch/README.md b/src/Components/Aspire.Elastic.Clients.Elasticsearch/README.md new file mode 100644 index 0000000000..d52c07e5f7 --- /dev/null +++ b/src/Components/Aspire.Elastic.Clients.Elasticsearch/README.md @@ -0,0 +1,145 @@ +# Aspire.Elastic.Clients.Elasticsearch + +Registers a [ElasticsearchClient](https://github.com/elastic/elasticsearch-net) in the DI container for connecting to a Elasticsearch. + +## Getting started + +### Prerequisites + +- Elasticsearch cluster. +- Connection string for accessing the Elasticsearch API endpoint or a CloudId and an ApiKey from [Elastic Cloud](https://www.elastic.co/cloud) + +### Install the package + +Install the .NET Aspire Elasticsearch Client library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Elastic.Clients.Elasticsearch +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddElasticsearchClient` extension method to register a `ElasticsearchClient` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddElasticsearchClient("elasticsearch"); +``` + +## Configuration + +The .NET Aspire Elasticsearch Client component provides multiple options to configure the server connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddElasticsearchClient()`: + +```csharp +builder.AddElasticsearchClient("elasticsearch"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "elasticsearch": "http://elastic:password@localhost:27011" + } +} +``` + +### Use configuration providers + +The .NET Aspire Elasticsearch Client component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `ElasticClientsElasticsearchSettings` from configuration by using the `Aspire:Elastic:Clients:Elasticsearch` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Elastic": { + "Clients": { + "Elasticsearch": { + "ConnectionString": "http://elastic:password@localhost:27011" + } + } + } + } +} +``` + +### Use inline delegates + +Also you can pass the `Action configureSettings` delegate to set up some or all the options inline, for example to set the API key from code: + +```csharp +builder.AddElasticsearchClient("elasticsearch", settings => settings.ConnectionString = "http://elastic:password@localhost:27011"); +``` + +## AppHost extensions + +In your AppHost project, install the `Aspire.Hosting.Elasticsearch` library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.Elasticsearch +``` + +Then, in the _Program.cs_ file of `AppHost`, register a Elasticsearch cluster and consume the connection using the following methods: + +```csharp +var elasticsearch = builder.AddElasticsearch("elasticsearch"); + +var myService = builder.AddProject() + .WithReference(elasticsearch); +``` + +The `WithReference` method configures a connection in the `MyService` project named `elasticsearch`. In the _Program.cs_ file of `MyService`, the Elasticsearch connection can be consumed using: + +```csharp +builder.AddElasticsearchClient("elasticsearch"); +``` + +### Use a ```CloudId``` and an ```ApiKey``` with configuration providers + +When using [Elastic Cloud](https://www.elastic.co/cloud) , +you can provide the ```CloudId``` and ```ApiKey``` in ```Aspire:Elastic:Clients:Elasticsearch:Cloud``` section +and set ```Aspire:Elastic:Clients:Elasticsearch:UseCloud``` key to ```true``` +when calling `builder.AddElasticsearchClient()`. +Example appsettings.json that configures the options: + +```csharp +builder.AddElasticsearchClient("elasticsearch"); +``` + +```json +{ + "Aspire": { + "Elastic": { + "Clients": { + "UseCloud" : true, + "Cloud": { + "ApiKey": "Valid ApiKey", + "CloudId": "Valid CloudId" + } + } + } + } +} +``` + +### Use a ```CloudId``` and an ```ApiKey``` with inline delegates + +```csharp +builder.AddElasticsearchClient("elasticsearch", +settings => { + settings.UseCloud = true; + settings.Cloud.CloudId = "Valid CloudId"; + settings.Cloud.ApiKey = "Valid ApiKey"; +}); +``` + +## Additional documentation + +* https://github.com/elastic/elasticsearch-net +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index 161529d9e3..58ad2678ae 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -32,6 +32,7 @@ As part of the .NET Aspire November preview, we want to include a set of .NET As | Seq | ✅ | ✅ | ✅ | ✅ | ✅ | N/A | N/A | ✅ | | Qdrant.Client | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | Milvus.Client | ✅ | ✅ | ✅ | ✅ | ✅ | | | ✅ | +| Elastic.Clients.Elasticsearch | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | Nomenclature used in the table above: diff --git a/src/Components/Telemetry.md b/src/Components/Telemetry.md index 33bac82863..7427be4f01 100644 --- a/src/Components/Telemetry.md +++ b/src/Components/Telemetry.md @@ -104,6 +104,14 @@ Aspire.Confluent.Kafka: - "messaging.kafka.message.rx" - "messaging.kafka.message.received" +Aspire.Elastic.Clients.Elasticsearch: +- Log categories: + - none (not currently supported by Elastic.Clients.Elasticsearch library) +- Activity source names: + - "Elastic.Transport" +- Metric names: + - none + Aspire.Microsoft.Azure.Cosmos: - Log categories: - "Azure-Cosmos-Operation-Request-Diagnostics" diff --git a/src/Shared/Elastic_logo.png b/src/Shared/Elastic_logo.png new file mode 100644 index 0000000000..c3402e9911 Binary files /dev/null and b/src/Shared/Elastic_logo.png differ diff --git a/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/Aspire.Elastic.Clients.Elasticsearch.Tests.csproj b/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/Aspire.Elastic.Clients.Elasticsearch.Tests.csproj new file mode 100644 index 0000000000..340af28f7a --- /dev/null +++ b/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/Aspire.Elastic.Clients.Elasticsearch.Tests.csproj @@ -0,0 +1,23 @@ + + + + $(NetCurrent) + true + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/AspireElasticClientExtensionsTest.cs b/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/AspireElasticClientExtensionsTest.cs new file mode 100644 index 0000000000..122ce8b92f --- /dev/null +++ b/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/AspireElasticClientExtensionsTest.cs @@ -0,0 +1,178 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Elastic.Clients.Elasticsearch; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using OpenTelemetry.Trace; +using Xunit; + +namespace Aspire.Elastic.Clients.Elasticsearch.Tests; + +public class AspireElasticClientExtensionsTest : IClassFixture +{ + private const string DefaultConnectionName = "elasticsearch"; + + private readonly ElasticsearchContainerFixture _containerFixture; + + public AspireElasticClientExtensionsTest(ElasticsearchContainerFixture containerFixture) + { + _containerFixture = containerFixture; + } + + private string DefaultConnectionString => + RequiresDockerAttribute.IsSupported ? _containerFixture.GetConnectionString() : "http://elastic:password@localhost:27011"; + + [Theory] + [InlineData(true)] + [InlineData(false)] + [RequiresDocker] + public async Task AddElasticsearchClient_HealthCheckShouldBeRegisteredWhenEnabled(bool useKeyed) + { + var key = DefaultConnectionName; + + var builder = CreateBuilder(DefaultConnectionString); + + if (useKeyed) + { + builder.AddKeyedElasticsearchClient(key, settings => + { + settings.DisableHealthChecks = false; + }); + } + else + { + builder.AddElasticsearchClient(DefaultConnectionName, settings => + { + settings.DisableHealthChecks = false; + }); + } + + using var host = builder.Build(); + + var healthCheckService = host.Services.GetRequiredService(); + + var healthCheckReport = await healthCheckService.CheckHealthAsync(); + + var healthCheckName = useKeyed ? $"Elastic.Clients.Elasticsearch_{key}" : "Elastic.Clients.Elasticsearch"; + + Assert.Contains(healthCheckReport.Entries, x => x.Key == healthCheckName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddElasticsearchClient_HealthCheckShouldNotBeRegisteredWhenDisabled(bool useKeyed) + { + var builder = CreateBuilder(DefaultConnectionString); + + if (useKeyed) + { + builder.AddKeyedElasticsearchClient(DefaultConnectionName, settings => + { + settings.DisableHealthChecks = true; + }); + } + else + { + builder.AddElasticsearchClient(DefaultConnectionName, settings => + { + settings.DisableHealthChecks = true; + }); + } + + using var host = builder.Build(); + + var healthCheckService = host.Services.GetService(); + + Assert.Null(healthCheckService); + } + + [Fact] + public void CanAddMultipleKeyedServices() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:elasticsearch1", "http://elastic:password@localhost1:19530"), + new KeyValuePair("ConnectionStrings:elasticsearch2", "http://elastic:password@localhost1:19531"), + new KeyValuePair("ConnectionStrings:elasticsearch3", "http://elastic:password@localhost1:19532"), + ]); + + builder.AddElasticsearchClient("elasticsearch1"); + builder.AddKeyedElasticsearchClient("elasticsearch2"); + builder.AddKeyedElasticsearchClient("elasticsearch3"); + + using var host = builder.Build(); + + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredKeyedService("elasticsearch2"); + var client3 = host.Services.GetRequiredKeyedService("elasticsearch3"); + + Assert.NotSame(client1, client2); + Assert.NotSame(client1, client3); + Assert.NotSame(client2, client3); + } + + [Fact] + public void CanAddClientFromEncodedConnectionString() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:elasticsearch1", "Endpoint=http://elastic:password@localhost1:19530"), + new KeyValuePair("ConnectionStrings:elasticsearch2", "Endpoint=http://localhost1:19531"), + ]); + + builder.AddElasticsearchClient("elasticsearch1"); + builder.AddKeyedElasticsearchClient("elasticsearch2"); + + using var host = builder.Build(); + + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredKeyedService("elasticsearch2"); + + Assert.NotSame(client1, client2); + } + + [Fact] + [RequiresDocker] + public void ElasticsearchInstrumentationEndToEnd() + { + RemoteExecutor.Invoke(async (connectionString) => + { + var builder = CreateBuilder(connectionString); + + builder.AddElasticsearchClient(DefaultConnectionName); + + using var notifier = new ActivityNotifier(); + builder.Services.AddOpenTelemetry().WithTracing(builder => builder.AddProcessor(notifier)); + + using var host = builder.Build(); + host.Start(); + + var elasticsearchClient = host.Services.GetRequiredService(); + await elasticsearchClient.PingAsync(); + + var activityList = await notifier.TakeAsync(1, TimeSpan.FromSeconds(10)); + Assert.Single(activityList); + + var activity = activityList[0]; + Assert.Equal("ping", activity.OperationName); + Assert.Contains(activity.Tags, kvp => kvp.Key == "db.system" && kvp.Value == "elasticsearch"); + }, DefaultConnectionString).Dispose(); + } + + private static HostApplicationBuilder CreateBuilder(string connectionString) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair($"ConnectionStrings:{DefaultConnectionName}", connectionString) + ]); + return builder; + } +} diff --git a/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/ConfigurationTests.cs b/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/ConfigurationTests.cs new file mode 100644 index 0000000000..886594ee71 --- /dev/null +++ b/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/ConfigurationTests.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.Elastic.Clients.Elasticsearch.Tests; + +public class ConfigurationTests +{ + [Fact] + public void EndpointIsNullByDefault() => + Assert.Null(new ElasticClientsElasticsearchSettings().Endpoint); + + [Fact] + public void HealthChecksEnabledByDefault() => + Assert.False(new ElasticClientsElasticsearchSettings().DisableHealthChecks); + + [Fact] + public void TracingEnabledByDefault() => + Assert.False(new ElasticClientsElasticsearchSettings().DisableTracing); +} diff --git a/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/ConformanceTests.cs b/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/ConformanceTests.cs new file mode 100644 index 0000000000..214177623e --- /dev/null +++ b/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/ConformanceTests.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Aspire.Components.ConformanceTests; +using Elastic.Clients.Elasticsearch; +using Elastic.Transport; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Elastic.Clients.Elasticsearch.Tests; + +public class ConformanceTests : ConformanceTests, IClassFixture +{ + private readonly ElasticsearchContainerFixture _containerFixture; + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + protected override string ActivitySourceName => "Elastic.Transport"; + + protected override string[] RequiredLogCategories => Array.Empty(); + + protected override bool CanConnectToServer => RequiresDockerAttribute.IsSupported; + + protected override bool SupportsKeyedRegistrations => true; + + public ConformanceTests(ElasticsearchContainerFixture containerFixture) + { + _containerFixture = containerFixture; + } + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + { + var connectionString = RequiresDockerAttribute.IsSupported ? + $"{_containerFixture.GetConnectionString()}" : + "http://elastic:password@localhost:27017"; + + configuration.AddInMemoryCollection( + [ + new KeyValuePair(CreateConfigKey("Aspire:Elastic:Clients:Elasticsearch", key, "Endpoint"), connectionString), + new KeyValuePair($"ConnectionStrings:{key}", $"Endpoint={connectionString}") + ]); + } + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + //The Testcontainers module creates a container that listens to requests over HTTPS. + //To communicate with the Elasticsearch instance, developers must create a ElasticsearchClientSettings instance and set the ServerCertificateValidationCallback delegate to CertificateValidations.AllowAll. + //Failing to do so will result in a communication failure as the .NET will reject the certificate coming from the container. + if (key is null) + { + builder.AddElasticsearchClient("elasticsearch", configureSettings: configure, configureClientSettings : (c)=> c.ServerCertificateValidationCallback(CertificateValidations.AllowAll)); + } + else + { + builder.AddKeyedElasticsearchClient(key, configureSettings: configure, configureClientSettings: (c) => c.ServerCertificateValidationCallback(CertificateValidations.AllowAll)); + } + } + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Elastic": { + "Clients": { + "Elasticsearch": { + "Endpoint": "http://localhost:6334", + "DisableHealthChecks": true, + "DisableTracing": false, + "DisableMetrics": false + } + } + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "Elastic":{ "Clients": { "Elasticsearch": { "Endpoint": 0 }}}}}""", "Value is \"integer\" but should be \"string\""), + ("""{"Aspire": { "Elastic":{ "Clients": { "Elasticsearch": { "Endpoint": "hello" }}}}}""", "Value does not match format \"uri\"") + }; + + protected override void SetHealthCheck(ElasticClientsElasticsearchSettings options, bool enabled) + { + options.DisableHealthChecks = !enabled; + options.HealthCheckTimeout = 100; + } + + protected override void SetMetrics(ElasticClientsElasticsearchSettings options, bool enabled) + { + throw new NotImplementedException(); + } + + protected override void SetTracing(ElasticClientsElasticsearchSettings options, bool enabled) + { + options.DisableTracing = !enabled; + } + + protected override void TriggerActivity(ElasticsearchClient service) + { + using var source = new CancellationTokenSource(100); + + service.InfoAsync(source.Token).Wait(); + } +} diff --git a/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/ElasticsearchContainerFixture.cs b/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/ElasticsearchContainerFixture.cs new file mode 100644 index 0000000000..d740b18375 --- /dev/null +++ b/tests/Aspire.Elastic.Clients.Elasticsearch.Tests/ElasticsearchContainerFixture.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Aspire.Hosting.Elasticsearch; +using Testcontainers.Elasticsearch; +using Xunit; + +namespace Aspire.Elastic.Clients.Elasticsearch.Tests; + +public sealed class ElasticsearchContainerFixture : IAsyncLifetime +{ + public ElasticsearchContainer? Container { get; private set; } + + public string GetConnectionString() => Container?.GetConnectionString() ?? + throw new InvalidOperationException("The test container was not initialized."); + + public async Task InitializeAsync() + { + if (RequiresDockerAttribute.IsSupported) + { + Container = new ElasticsearchBuilder() + .WithImage($"{ElasticsearchContainerImageTags.Image}:{ElasticsearchContainerImageTags.Tag}") + .Build(); + await Container.StartAsync(); + } + } + + public async Task DisposeAsync() + { + if (Container is not null) + { + await Container.DisposeAsync(); + } + } +} diff --git a/tests/Aspire.Hosting.Elasticsearch.Tests/AddElasticsearchTests.cs b/tests/Aspire.Hosting.Elasticsearch.Tests/AddElasticsearchTests.cs new file mode 100644 index 0000000000..3cba9d2701 --- /dev/null +++ b/tests/Aspire.Hosting.Elasticsearch.Tests/AddElasticsearchTests.cs @@ -0,0 +1,239 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Elasticsearch; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Hosting.Tests.Elasticsearch; +public class AddElasticsearchTests +{ + [Fact] + public async Task AddElasticsearchContainerWithDefaultsAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddElasticsearch("elasticsearch"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("elasticsearch", containerResource.Name); + + var endpoints = containerResource.Annotations.OfType(); + Assert.Equal(2, endpoints.Count()); + + var primaryEndpoint = Assert.Single(endpoints, e => e.Name == "http"); + Assert.Equal(9200, primaryEndpoint.TargetPort); + Assert.False(primaryEndpoint.IsExternal); + Assert.Equal("http", primaryEndpoint.Name); + Assert.Null(primaryEndpoint.Port); + Assert.Equal(ProtocolType.Tcp, primaryEndpoint.Protocol); + Assert.Equal("http", primaryEndpoint.Transport); + Assert.Equal("http", primaryEndpoint.UriScheme); + + var internalEndpoint = Assert.Single(endpoints, e => e.Name == "internal"); + Assert.Equal(9300, internalEndpoint.TargetPort); + Assert.False(internalEndpoint.IsExternal); + Assert.Equal("internal", internalEndpoint.Name); + Assert.Null(internalEndpoint.Port); + Assert.Equal(ProtocolType.Tcp, internalEndpoint.Protocol); + Assert.Equal("tcp", internalEndpoint.Transport); + Assert.Equal("tcp", internalEndpoint.UriScheme); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(ElasticsearchContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(ElasticsearchContainerImageTags.Image, containerAnnotation.Image); + Assert.Equal(ElasticsearchContainerImageTags.Registry, containerAnnotation.Registry); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(containerResource); + + Assert.Collection(config, + env => + { + Assert.Equal("discovery.type", env.Key); + Assert.Equal("single-node", env.Value); + }, + env => + { + Assert.Equal("xpack.security.enabled", env.Key); + Assert.Equal("true", env.Value); + }, + env => + { + Assert.Equal("ELASTIC_PASSWORD", env.Key); + Assert.False(string.IsNullOrEmpty(env.Value)); + }); + } + + [Fact] + public async Task AddElasticsearchContainerAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:pass"] = "pass"; + + var pass = appBuilder.AddParameter("pass"); + appBuilder.AddElasticsearch("elasticsearch",pass); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("elasticsearch", containerResource.Name); + + var endpoints = containerResource.Annotations.OfType(); + Assert.Equal(2, endpoints.Count()); + + var primaryEndpoint = Assert.Single(endpoints, e => e.Name == "http"); + Assert.Equal(9200, primaryEndpoint.TargetPort); + Assert.False(primaryEndpoint.IsExternal); + Assert.Equal("http", primaryEndpoint.Name); + Assert.Null(primaryEndpoint.Port); + Assert.Equal(ProtocolType.Tcp, primaryEndpoint.Protocol); + Assert.Equal("http", primaryEndpoint.Transport); + Assert.Equal("http", primaryEndpoint.UriScheme); + + var internalEndpoint = Assert.Single(endpoints, e => e.Name == "internal"); + Assert.Equal(9300, internalEndpoint.TargetPort); + Assert.False(internalEndpoint.IsExternal); + Assert.Equal("internal", internalEndpoint.Name); + Assert.Null(internalEndpoint.Port); + Assert.Equal(ProtocolType.Tcp, internalEndpoint.Protocol); + Assert.Equal("tcp", internalEndpoint.Transport); + Assert.Equal("tcp", internalEndpoint.UriScheme); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(ElasticsearchContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(ElasticsearchContainerImageTags.Image, containerAnnotation.Image); + Assert.Equal(ElasticsearchContainerImageTags.Registry, containerAnnotation.Registry); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(containerResource); + + Assert.Collection(config, + env => + { + Assert.Equal("discovery.type", env.Key); + Assert.Equal("single-node", env.Value); + }, + env => + { + Assert.Equal("xpack.security.enabled", env.Key); + Assert.Equal("true", env.Value); + }, + env => + { + Assert.Equal("ELASTIC_PASSWORD", env.Key); + Assert.Equal("pass", env.Value); + }); + } + + [Fact] + public async Task ElasticsearchCreatesConnectionString() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var elasticsearch = appBuilder + .AddElasticsearch("elasticsearch") + .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 27020)); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var connectionStringResource = Assert.Single(appModel.Resources.OfType()) as IResourceWithConnectionString; + var connectionString = await connectionStringResource.GetConnectionStringAsync(); + + Assert.Equal($"http://elastic:{elasticsearch.Resource.PasswordParameter.Value}@localhost:27020", connectionString); + Assert.Equal("http://elastic:{elasticsearch-password.value}@{elasticsearch.bindings.http.host}:{elasticsearch.bindings.http.port}", connectionStringResource.ConnectionStringExpression.ValueExpression); + } + + [Fact] + public async Task VerifyManifestWithDefaultsAddsAnnotationMetadata() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var elasticsearch = appBuilder.AddElasticsearch("elasticsearch"); + + var manifest = await ManifestUtils.GetManifest(elasticsearch.Resource); + + var expectedManifest = $$""" + { + "type": "container.v0", + "connectionString": "http://elastic:{elasticsearch-password.value}@{elasticsearch.bindings.http.host}:{elasticsearch.bindings.http.port}", + "image": "{{ElasticsearchContainerImageTags.Registry}}/{{ElasticsearchContainerImageTags.Image}}:{{ElasticsearchContainerImageTags.Tag}}", + "env": { + "discovery.type": "single-node", + "xpack.security.enabled": "true", + "ELASTIC_PASSWORD": "{elasticsearch-password.value}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 9200 + }, + "internal": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 9300 + } + } + } + """; + Assert.Equal(expectedManifest, manifest.ToString()); + } + + [Fact] + public async Task VerifyManifestWithDataVolumeAddsAnnotationMetadata() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var elasticsearch = appBuilder.AddElasticsearch("elasticsearch") + .WithDataVolume("data"); + + var manifest = await ManifestUtils.GetManifest(elasticsearch.Resource); + + var expectedManifest = $$""" + { + "type": "container.v0", + "connectionString": "http://elastic:{elasticsearch-password.value}@{elasticsearch.bindings.http.host}:{elasticsearch.bindings.http.port}", + "image": "{{ElasticsearchContainerImageTags.Registry}}/{{ElasticsearchContainerImageTags.Image}}:{{ElasticsearchContainerImageTags.Tag}}", + "volumes": [ + { + "name": "data", + "target": "/usr/share/elasticsearch/data", + "readOnly": false + } + ], + "env": { + "discovery.type": "single-node", + "xpack.security.enabled": "true", + "ELASTIC_PASSWORD": "{elasticsearch-password.value}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 9200 + }, + "internal": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 9300 + } + } + } + """; + Assert.Equal(expectedManifest, manifest.ToString()); + } +} diff --git a/tests/Aspire.Hosting.Elasticsearch.Tests/Aspire.Hosting.Elasticsearch.Tests.csproj b/tests/Aspire.Hosting.Elasticsearch.Tests/Aspire.Hosting.Elasticsearch.Tests.csproj new file mode 100644 index 0000000000..9940a49c83 --- /dev/null +++ b/tests/Aspire.Hosting.Elasticsearch.Tests/Aspire.Hosting.Elasticsearch.Tests.csproj @@ -0,0 +1,13 @@ + + + + $(NetCurrent) + + + + + + + + + diff --git a/tests/Aspire.Hosting.Elasticsearch.Tests/ElasticsearchFunctionalTests.cs b/tests/Aspire.Hosting.Elasticsearch.Tests/ElasticsearchFunctionalTests.cs new file mode 100644 index 0000000000..6e1f355c53 --- /dev/null +++ b/tests/Aspire.Hosting.Elasticsearch.Tests/ElasticsearchFunctionalTests.cs @@ -0,0 +1,280 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Aspire.Hosting.Utils; +using Elastic.Clients.Elasticsearch; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Polly; +using Xunit; + +namespace Aspire.Hosting.Elasticsearch.Tests; + +public class ElasticsearchFunctionalTests +{ + [Fact] + [RequiresDocker] + public async Task VerifyElasticsearchResource() + { + var builder = CreateDistributedApplicationBuilder(); + + var elasticsearch = builder.AddElasticsearch("elasticsearch"); + + using var app = builder.Build(); + + await app.StartAsync(); + + var hb = Host.CreateApplicationBuilder(); + + hb.Configuration.AddInMemoryCollection(new Dictionary + { + [$"ConnectionStrings:{elasticsearch.Resource.Name}"] = await elasticsearch.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None) + }); + + hb.AddElasticsearchClient(elasticsearch.Resource.Name); + + using var host = hb.Build(); + + await host.StartAsync(); + + var elasticsearchClient = host.Services.GetRequiredService(); + + var person = new Person + { + Id = 1, + FirstName = "Alireza", + LastName = "Baloochi" + }; + + var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new() { MaxRetryAttempts = 10, Delay = TimeSpan.FromSeconds(3) }) + .AddTimeout(TimeSpan.FromSeconds(5)) + .Build(); + + await pipeline.ExecuteAsync( + async token => + { + var indexResponse = await elasticsearchClient.IndexAsync(person, "people", "1",CancellationToken.None); + + var getResponse = await elasticsearchClient.GetAsync("people", "1", CancellationToken.None); + + Assert.True(indexResponse.IsSuccess()); + Assert.True(getResponse.IsSuccess()); + Assert.NotNull(getResponse.Source); + Assert.Equal(person.Id, getResponse.Source?.Id); + }); + } + + [Fact] + [RequiresDocker] + public async Task WithDataVolumeShouldPersistStateBetweenUsages() + { + var builder1 = CreateDistributedApplicationBuilder(); + var elasticsearch1 = builder1.AddElasticsearch("elasticsearch"); + + // Use a deterministic volume name to prevent them from exhausting the machines if deletion fails + var volumeName = VolumeNameGenerator.CreateVolumeName(elasticsearch1, nameof(WithDataVolumeShouldPersistStateBetweenUsages)); + elasticsearch1.WithDataVolume(volumeName); + + var person = new Person + { + Id = 1, + FirstName = "Alireza", + LastName = "Baloochi" + }; + + var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new() { MaxRetryAttempts = 10, Delay = TimeSpan.FromSeconds(3) }) + .AddTimeout(TimeSpan.FromSeconds(5)) + .Build(); + + using (var app = builder1.Build()) + { + await app.StartAsync(); + + var hb = Host.CreateApplicationBuilder(); + + hb.Configuration.AddInMemoryCollection(new Dictionary + { + [$"ConnectionStrings:{elasticsearch1.Resource.Name}"] = await elasticsearch1.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None) + }); + + hb.AddElasticsearchClient(elasticsearch1.Resource.Name); + + using (var host = hb.Build()) + { + await host.StartAsync(); + + await pipeline.ExecuteAsync( + async token => + { + var elasticsearchClient = host.Services.GetRequiredService(); + + var indexResponse = await elasticsearchClient.IndexAsync(person, "people", "1", CancellationToken.None); + + Assert.True(indexResponse.IsSuccess()); + }); + } + + // Stops the container, or the Volume would still be in use + await app.StopAsync(); + } + + var builder2 = CreateDistributedApplicationBuilder(); + var elasticsearch2 = builder2.AddElasticsearch("elasticsearch").WithDataVolume(volumeName); + + using (var app = builder2.Build()) + { + await app.StartAsync(); + + var hb = Host.CreateApplicationBuilder(); + + hb.Configuration.AddInMemoryCollection(new Dictionary + { + [$"ConnectionStrings:{elasticsearch2.Resource.Name}"] = await elasticsearch2.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None) + }); + + hb.AddElasticsearchClient(elasticsearch2.Resource.Name); + + using (var host = hb.Build()) + { + await host.StartAsync(); + await pipeline.ExecuteAsync( + async token => + { + var elasticsearchClient = host.Services.GetRequiredService(); + + var getResponse = await elasticsearchClient.GetAsync("people", "1", CancellationToken.None); + + Assert.True(getResponse.IsSuccess()); + Assert.NotNull(getResponse.Source); + Assert.Equal(person.Id, getResponse.Source?.Id); + }); + + } + + // Stops the container, or the Volume would still be in use + await app.StopAsync(); + } + + DockerUtils.AttemptDeleteDockerVolume(volumeName); + } + + [Fact] + [RequiresDocker] + public async Task WithDataBindMountShouldPersistStateBetweenUsages() + { + var bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + if (!Directory.Exists(bindMountPath)) + { + Directory.CreateDirectory(bindMountPath); + } + + var builder1 = CreateDistributedApplicationBuilder(); + var elasticsearch1 = builder1.AddElasticsearch("elasticsearch").WithDataBindMount(bindMountPath); + + var person = new Person + { + Id = 1, + FirstName = "Alireza", + LastName = "Baloochi" + }; + + var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new() { MaxRetryAttempts = 10, Delay = TimeSpan.FromSeconds(3) }) + .AddTimeout(TimeSpan.FromSeconds(5)) + .Build(); + + using (var app = builder1.Build()) + { + await app.StartAsync(); + + var hb = Host.CreateApplicationBuilder(); + + hb.Configuration.AddInMemoryCollection(new Dictionary + { + [$"ConnectionStrings:{elasticsearch1.Resource.Name}"] = await elasticsearch1.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None) + }); + + hb.AddElasticsearchClient(elasticsearch1.Resource.Name); + + using (var host = hb.Build()) + { + await host.StartAsync(); + + await pipeline.ExecuteAsync( + async token => + { + var elasticsearchClient = host.Services.GetRequiredService(); + + var indexResponse = await elasticsearchClient.IndexAsync(person, "people", "1", CancellationToken.None); + + Assert.True(indexResponse.IsSuccess()); + }); + } + + // Stops the container, or the Volume would still be in use + await app.StopAsync(); + } + + var builder2 = CreateDistributedApplicationBuilder(); + var elasticsearch2 = builder2.AddElasticsearch("elasticsearch").WithDataBindMount(bindMountPath); + + using (var app = builder2.Build()) + { + await app.StartAsync(); + + var hb = Host.CreateApplicationBuilder(); + + hb.Configuration.AddInMemoryCollection(new Dictionary + { + [$"ConnectionStrings:{elasticsearch2.Resource.Name}"] = await elasticsearch2.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None) + }); + + hb.AddElasticsearchClient(elasticsearch2.Resource.Name); + + using (var host = hb.Build()) + { + await host.StartAsync(); + await pipeline.ExecuteAsync( + async token => + { + var elasticsearchClient = host.Services.GetRequiredService(); + + var getResponse = await elasticsearchClient.GetAsync("people", "1", CancellationToken.None); + + Assert.True(getResponse.IsSuccess()); + Assert.NotNull(getResponse.Source); + Assert.Equal(person.Id, getResponse.Source?.Id); + }); + + } + + // Stops the container, or the Volume would still be in use + await app.StopAsync(); + } + + try + { + File.Delete(bindMountPath); + } + catch + { + // Don't fail test if we can't clean the temporary folder + } + } + + private static TestDistributedApplicationBuilder CreateDistributedApplicationBuilder() => + TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(); + + private sealed class Person + { + public int Id { get; set; } + public required string FirstName { get; set; } + public required string LastName { get; set; } + } + +} diff --git a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs index 040b1d4a9a..e52492fb2d 100644 --- a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs +++ b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs @@ -263,7 +263,7 @@ public void PublishingRedisResourceAsContainerResultsInConnectionStringProperty( Assert.Equal("container.v0", container.GetProperty("type").GetString()); Assert.Equal("{rediscontainer.bindings.tcp.host}:{rediscontainer.bindings.tcp.port}", container.GetProperty("connectionString").GetString()); } - + [Fact] public void EnsureAllPostgresManifestTypesHaveVersion0Suffix() { diff --git a/tests/Shared/RepoTesting/Directory.Packages.Helix.props b/tests/Shared/RepoTesting/Directory.Packages.Helix.props index c869279784..71109ca390 100644 --- a/tests/Shared/RepoTesting/Directory.Packages.Helix.props +++ b/tests/Shared/RepoTesting/Directory.Packages.Helix.props @@ -12,6 +12,7 @@ + @@ -31,6 +32,7 @@ +