diff --git a/.github/workflows/nuget-audit.yml b/.github/workflows/nuget-audit.yml index 3c42532..e5a2359 100644 --- a/.github/workflows/nuget-audit.yml +++ b/.github/workflows/nuget-audit.yml @@ -1,9 +1,9 @@ -name: NuGet Audit -on: - workflow_dispatch: -env: - DOTNET_NOLOGO: true -jobs: - call-shared-nuget-audit: - uses: particular/shared-workflows/.github/workflows/nuget-audit.yml@main +name: NuGet Audit +on: + workflow_dispatch: +env: + DOTNET_NOLOGO: true +jobs: + call-shared-nuget-audit: + uses: particular/shared-workflows/.github/workflows/nuget-audit.yml@main secrets: inherit \ No newline at end of file diff --git a/.github/workflows/update-dependencies.yml b/.github/workflows/update-dependencies.yml index 0702b6c..8cf2585 100644 --- a/.github/workflows/update-dependencies.yml +++ b/.github/workflows/update-dependencies.yml @@ -1,24 +1,24 @@ -name: Update Dependencies -on: - schedule: - # At 14 minutes past the hour, every 6 hours, starting at 3AM UTC - - cron: '14 3/6 * * *' - workflow_dispatch: - inputs: - dryRun: - type: boolean - required: false - default: false - description: Dry run (don't create PRs) - debugMode: - type: boolean - required: false - default: false - description: Run Renovate with debug logging -jobs: - renovate: - uses: particular/shared-workflows/.github/workflows/dependency-updates.yml@main - with: - dryRun: ${{ inputs.dryRun || false }} - debugMode: ${{ inputs.debugMode || false }} +name: Update Dependencies +on: + schedule: + # At 14 minutes past the hour, every 6 hours, starting at 3AM UTC + - cron: '14 3/6 * * *' + workflow_dispatch: + inputs: + dryRun: + type: boolean + required: false + default: false + description: Dry run (don't create PRs) + debugMode: + type: boolean + required: false + default: false + description: Run Renovate with debug logging +jobs: + renovate: + uses: particular/shared-workflows/.github/workflows/dependency-updates.yml@main + with: + dryRun: ${{ inputs.dryRun || false }} + debugMode: ${{ inputs.debugMode || false }} secrets: inherit \ No newline at end of file diff --git a/.gitignore b/.gitignore index fd0d72e..8f3b5a0 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,4 @@ src/scaffolding.config src/volumes .env +src/AppHost/appsettings.Development.json diff --git a/README.md b/README.md index 571a0be..d66382a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ The example also ships the following monitoring services: - .NET 10 SDK - Docker - Docker Compose +- Aspire CLI (for AppHost publish/deploy workflow) ## How to run the example @@ -61,6 +62,34 @@ docker compose up -d The `dotnet publish` command builds projects and publishes Linux container images to the local Docker registry using the .NET SDK built-in container support. The Docker Compose command starts those endpoint images and configures all the additional infrastructural containers. +### Running with .NET Aspire AppHost (default setup) + +An Aspire AppHost is available at `src/AppHost/AppHost.csproj` and models the default setup from `docker-compose.yml` (no Azure Service Bus emulator path). + +To run the default setup through AppHost orchestration: + +```shell +env 'Parameters__particular-license=' \ +Parameters__azureServiceBusConnectionString="" \ +dotnet run --project src/AppHost/AppHost.csproj +``` + +To generate Docker Compose deployment artifacts from the AppHost: + +```shell +env 'Parameters__particular-license=' \ +Parameters__azureServiceBusConnectionString="" \ +aspire publish --apphost src/AppHost/AppHost.csproj --output-path ./aspire-output +``` + +Then run the generated artifacts: + +```shell +docker compose --env-file ./aspire-output/.env -f ./aspire-output/docker-compose.yaml up -d +``` + +The generated AppHost Compose output keeps the existing OTEL collector/Prometheus/Grafana/Jaeger path and also adds the Aspire dashboard container. + To stop the running solution and remove all deployed containers. Using a command prompt, execute the following command: ```shell @@ -108,6 +137,7 @@ The endpoint containers (Client, LoanBroker, Bank1/2/3, EmailSender) do not expo | Port | Service | |-------|-----------------------------------------------| | 1433 | SQL Server (NServiceBus persistence) | +| 18888 | Aspire dashboard UI (AppHost-generated Compose) | | 3000 | Grafana | | 4317 | OpenTelemetry Collector — OTLP gRPC | | 5318 | OpenTelemetry Collector — OTLP HTTP | diff --git a/aspire.config.json b/aspire.config.json new file mode 100644 index 0000000..983fec8 --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "src/AppHost/AppHost.csproj" + } +} \ No newline at end of file diff --git a/src/AppHost/AppHost.csproj b/src/AppHost/AppHost.csproj new file mode 100644 index 0000000..f5ace65 --- /dev/null +++ b/src/AppHost/AppHost.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + enable + enable + 4e39df4e-d8de-43bc-84c0-0b596fb97262 + + + + + + + + + + + + + + + + + diff --git a/src/AppHost/Program.cs b/src/AppHost/Program.cs new file mode 100644 index 0000000..39c0602 --- /dev/null +++ b/src/AppHost/Program.cs @@ -0,0 +1,87 @@ +using Aspire.Hosting.ApplicationModel; + +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddDockerComposeEnvironment("compose") + .WithDashboard(enabled: true); + +var serviceBusConnectionString = builder.AddParameter("azureServiceBusConnectionString", secret: true); +var transport = builder.AddConnectionString("transport", ReferenceExpression.Create($"{serviceBusConnectionString}")); + +var sqlPassword = builder.AddParameter("sql-password", "YourStrong@Passw0rd"); +var sqlServer = builder.AddSqlServer("sqlserver", password: sqlPassword) + .WithEnvironment("MSSQL_PID", "Developer") + .WithEnvironment("MSSQL_COLLATION", "SQL_Latin1_General_CP1_CI_AS") + .WithVolume("sqlserver-data", "/var/opt/mssql"); + +var nsbDatabase = sqlServer.AddDatabase("nsb-database", "NServiceBus"); + +var creditBureau = builder.AddDockerfile( + "creditbureau", + "../..", + dockerfilePath: "./src/CreditBureau/Dockerfile") + .WithEndpoint(targetPort: 80, port: 7071, scheme: "http", name: "http", isExternal: true) + .WaitFor(sqlServer); + +var otelCollector = builder.AddContainer("otel-collector", "otel/opentelemetry-collector-contrib", "0.102.1") + .WithBindMount("../otel/otel-collector-config.yaml", "/etc/otelcol-contrib/config.yaml", isReadOnly: true) + .WithArgs("--config=/etc/otelcol-contrib/config.yaml") + .WithEndpoint(port: 4317, targetPort: 4317, name: "grpc", scheme: "tcp", isExternal: true) + .WithEndpoint(targetPort: 5318, port: 5318, name: "http", scheme: "http", isExternal: true) + .WithEndpoint(targetPort: 1234, port: 1234, name: "prometheus", scheme: "http", isExternal: true); + +var prometheus = builder.AddContainer("prometheus", "docker.io/prom/prometheus", "v2.53.2") + .WithBindMount("../prometheus", "/etc/prometheus") + .WithVolume("prometheus-data", "/prometheus") + .WithArgs("--web.enable-lifecycle", "--config.file=/etc/prometheus/prometheus.yml") + .WithEndpoint(targetPort: 9090, port: 9090, scheme: "http", name: "http", isExternal: true) + .WaitFor(otelCollector); + +var grafana = builder.AddContainer("grafana", "docker.io/grafana/grafana-oss", "latest") + .WithBindMount("../grafana/provisioning", "/etc/grafana/provisioning") + .WithBindMount("../grafana/dashboards", "/var/lib/grafana/dashboards") + .WithVolume("grafana-data", "/var/lib/grafana") + .WithEndpoint(targetPort: 3000, port: 3000, scheme: "http", name: "http", isExternal: true) + .WaitFor(prometheus); + +var jaeger = builder.AddContainer("jaeger", "docker.io/jaegertracing/all-in-one", "latest") + .WithEnvironment("COLLECTOR_OTLP_ENABLED", "true") + .WithVolume("jaeger-data", "/tmp") + .WithEndpoint(targetPort: 16686, port: 16686, scheme: "http", name: "http", isExternal: true) + .WaitFor(otelCollector); + +var platform = builder + .AddParticularPlatform("particular") + .WithTransportAzureServiceBus(transport) + .AddDefaultComponents(); + +var otelHttp = otelCollector.GetEndpoint("http"); +var creditBureauHttp = creditBureau.GetEndpoint("http"); + +IResourceBuilder ConfigureEndpoint(IResourceBuilder endpoint) => + endpoint + .WithParticularPlatform(platform) + .WithEnvironment(context => + { + context.EnvironmentVariables["SQL_CONNECTION_STRING"] = nsbDatabase.Resource.ConnectionStringExpression; + context.EnvironmentVariables["CREDIT_BUREAU_URL"] = ReferenceExpression.Create($"{creditBureauHttp}/api/score"); + context.EnvironmentVariables["OTLP_METRICS_URL"] = ReferenceExpression.Create($"{otelHttp}/v1/metrics"); + context.EnvironmentVariables["OTLP_TRACING_URL"] = ReferenceExpression.Create($"{otelHttp}/v1/traces"); + }) + .WaitFor(sqlServer) + .WaitFor(otelCollector) + .WaitFor(platform); + +var loanBroker = ConfigureEndpoint(builder.AddProject("loan-broker")) + .WaitFor(creditBureau); + +ConfigureEndpoint(builder.AddProject("bank1")); +ConfigureEndpoint(builder.AddProject("bank2")); +ConfigureEndpoint(builder.AddProject("bank3")); +ConfigureEndpoint(builder.AddProject("email-sender")); + +ConfigureEndpoint(builder.AddProject("client")) + .WithArgs("--demo") + .WaitFor(loanBroker); + +builder.Build().Run(); diff --git a/src/AppHost/Properties/launchSettings.json b/src/AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..a8224fc --- /dev/null +++ b/src/AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://loanbrokershowcase.dev.localhost:17290;http://loanbrokershowcase.dev.localhost:15170", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21118", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22281" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://loanbrokershowcase.dev.localhost:15170", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19233", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20173" + } + } + } +} diff --git a/src/AppHost/appsettings.json b/src/AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/src/AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/AzureLoanBrokerShowcase.slnx b/src/AzureLoanBrokerShowcase.slnx index 4274c9b..fee6aa3 100644 --- a/src/AzureLoanBrokerShowcase.slnx +++ b/src/AzureLoanBrokerShowcase.slnx @@ -34,6 +34,7 @@ + diff --git a/src/Client/UILoop.cs b/src/Client/UILoop.cs index d88daa1..d99e1df 100644 --- a/src/Client/UILoop.cs +++ b/src/Client/UILoop.cs @@ -20,6 +20,16 @@ public static async Task RunLoop(IHost app, string[] args) var messageSession = app.Services.GetRequiredService(); var running = true; var continuousSend = false; + + // Console.KeyAvailable/ReadKey throw when stdin is redirected (e.g. running as an Aspire + // project resource or any other non-TTY host), so only poll the keyboard when we actually + // have an interactive console. In --demo mode the loop drives itself and needs no input. + var interactive = !Console.IsInputRedirected; + if (!interactive) + { + Console.WriteLine("No interactive console detected; keyboard controls are disabled."); + } + Console.CancelKeyPress += (_, e) => { e.Cancel = true; @@ -37,7 +47,7 @@ public static async Task RunLoop(IHost app, string[] args) while (running) { - if (Console.KeyAvailable) + if (interactive && Console.KeyAvailable) { var k = Console.ReadKey(true); switch (k.Key) diff --git a/src/CommonConfigurations/OpenTelemetryExtensions.cs b/src/CommonConfigurations/OpenTelemetryExtensions.cs index 002a56e..d92f4e7 100644 --- a/src/CommonConfigurations/OpenTelemetryExtensions.cs +++ b/src/CommonConfigurations/OpenTelemetryExtensions.cs @@ -21,7 +21,7 @@ public static void EnableOpenTelemetryMetrics(this EndpointConfiguration endpoin var resourceBuilder = ResourceBuilder.CreateDefault().AddAttributes(attributes); - Sdk.CreateMeterProviderBuilder() + var meterProviderBuilder = Sdk.CreateMeterProviderBuilder() .SetResourceBuilder(resourceBuilder) .AddMeter("NServiceBus.Core.Pipeline.Incoming") .AddMeter("LoanBroker") @@ -30,8 +30,16 @@ public static void EnableOpenTelemetryMetrics(this EndpointConfiguration endpoin var url = Environment.GetEnvironmentVariable(OtlpMetricsUrlEnvVar) ?? OtlpMetricsDefaultUrl; cfg.Endpoint = new Uri(url); cfg.Protocol = OtlpExportProtocol.HttpProtobuf; - }) - .Build(); + }); + + // When orchestrated by Aspire, also export to the dashboard's OTLP endpoint so traces and + // metrics show up in the Aspire dashboard. + if (HasAspireOtlpEndpoint()) + { + meterProviderBuilder.AddOtlpExporter(); + } + + meterProviderBuilder.Build(); } public static void EnableOpenTelemetryTracing(this EndpointConfiguration endpointConfiguration) @@ -46,7 +54,7 @@ public static void EnableOpenTelemetryTracing(this EndpointConfiguration endpoin var resourceBuilder = ResourceBuilder.CreateDefault().AddAttributes(attributes); - Sdk.CreateTracerProviderBuilder() + var tracerProviderBuilder = Sdk.CreateTracerProviderBuilder() .SetResourceBuilder(resourceBuilder) .AddSource("NServiceBus.Core") .AddOtlpExporter(cfg => @@ -54,10 +62,23 @@ public static void EnableOpenTelemetryTracing(this EndpointConfiguration endpoin var url = Environment.GetEnvironmentVariable(OtlpTracesUrlEnvVar) ?? OtlpTracesDefaultUrl; cfg.Endpoint = new Uri(url); cfg.Protocol = OtlpExportProtocol.HttpProtobuf; - }) - .Build(); + }); + + // Also feed the Aspire dashboard when running under the AppHost (see metrics for details). + if (HasAspireOtlpEndpoint()) + { + tracerProviderBuilder.AddOtlpExporter(); + } + + tracerProviderBuilder.Build(); } + // Aspire injects OTEL_EXPORTER_OTLP_ENDPOINT (plus protocol/headers) into project resources it + // orchestrates. Its absence means we are not running under Aspire (e.g. plain Docker Compose), + // so the dashboard exporter is skipped and only the collector pipeline is used. + static bool HasAspireOtlpEndpoint() => + !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT")); + const string OtlpMetricsDefaultUrl = "http://localhost:5318/v1/metrics"; const string OtlpTracesDefaultUrl = "http://localhost:5318/v1/traces"; const string OtlpMetricsUrlEnvVar = "OTLP_METRICS_URL"; diff --git a/src/CommonConfigurations/SharedConventions.cs b/src/CommonConfigurations/SharedConventions.cs index 667730d..d1fc9d1 100644 --- a/src/CommonConfigurations/SharedConventions.cs +++ b/src/CommonConfigurations/SharedConventions.cs @@ -20,8 +20,12 @@ public static HostApplicationBuilder ConfigureAzureNServiceBusEndpoint(this Host var endpointConfiguration = new EndpointConfiguration(endpointName); - // Configure Azure Transport - var serviceBusConnectionString = Environment.GetEnvironmentVariable("AZURE_SERVICE_BUS_CONNECTION_STRING"); + // Configure Azure Transport. Prefer the transport connection string provided by the + // Particular Service Platform (when orchestrated by Aspire it is injected as + // ConnectionStrings__transport). Fall back to the legacy env var for the standalone + // docker-compose flow. + var serviceBusConnectionString = builder.Configuration.GetConnectionString("transport") + ?? Environment.GetEnvironmentVariable("AZURE_SERVICE_BUS_CONNECTION_STRING"); ArgumentException.ThrowIfNullOrWhiteSpace(serviceBusConnectionString);