Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .github/workflows/nuget-audit.yml
Original file line number Diff line number Diff line change
@@ -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
46 changes: 23 additions & 23 deletions .github/workflows/update-dependencies.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,4 @@ src/scaffolding.config
src/volumes

.env
src/AppHost/appsettings.Development.json
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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=<your Particular license>' \
Parameters__azureServiceBusConnectionString="<your Azure Service Bus connection string>" \
dotnet run --project src/AppHost/AppHost.csproj
```

To generate Docker Compose deployment artifacts from the AppHost:

```shell
env 'Parameters__particular-license=<your Particular license>' \
Parameters__azureServiceBusConnectionString="<your Azure Service Bus connection string>" \
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
Expand Down Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions aspire.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"appHost": {
"path": "src/AppHost/AppHost.csproj"
}
}
25 changes: 25 additions & 0 deletions src/AppHost/AppHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Aspire.AppHost.Sdk/13.4.5">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>4e39df4e-d8de-43bc-84c0-0b596fb97262</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.Docker" Version="13.4.4" />
<PackageReference Include="Aspire.Hosting.SqlServer" Version="13.4.4" />
<PackageReference Include="Particular.Aspire.Hosting.ServicePlatform" Version="1.1.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Bank1Adapter\Bank1Adapter.csproj" />
<ProjectReference Include="..\Bank2Adapter\Bank2Adapter.csproj" />
<ProjectReference Include="..\Bank3Adapter\Bank3Adapter.csproj" />
<ProjectReference Include="..\Client\Client.csproj" />
<ProjectReference Include="..\EmailSender\EmailSender.csproj" />
<ProjectReference Include="..\LoanBroker\LoanBroker.csproj" />
</ItemGroup>
</Project>
87 changes: 87 additions & 0 deletions src/AppHost/Program.cs
Original file line number Diff line number Diff line change
@@ -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<ProjectResource> ConfigureEndpoint(IResourceBuilder<ProjectResource> 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<Projects.LoanBroker>("loan-broker"))
.WaitFor(creditBureau);

ConfigureEndpoint(builder.AddProject<Projects.Bank1Adapter>("bank1"));
ConfigureEndpoint(builder.AddProject<Projects.Bank2Adapter>("bank2"));
ConfigureEndpoint(builder.AddProject<Projects.Bank3Adapter>("bank3"));
ConfigureEndpoint(builder.AddProject<Projects.EmailSender>("email-sender"));

ConfigureEndpoint(builder.AddProject<Projects.Client>("client"))
.WithArgs("--demo")
.WaitFor(loanBroker);

builder.Build().Run();
29 changes: 29 additions & 0 deletions src/AppHost/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
9 changes: 9 additions & 0 deletions src/AppHost/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
1 change: 1 addition & 0 deletions src/AzureLoanBrokerShowcase.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<File Path="sqlserver/init-db.sh" />
</Folder>
<Project Path="Bank1Adapter/Bank1Adapter.csproj" />
<Project Path="AppHost/AppHost.csproj" />
<Project Path="Bank2Adapter/Bank2Adapter.csproj" />
<Project Path="Bank3Adapter/Bank3Adapter.csproj" />
<Project Path="BankMessages/BankMessages.csproj" />
Expand Down
12 changes: 11 additions & 1 deletion src/Client/UILoop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ public static async Task RunLoop(IHost app, string[] args)
var messageSession = app.Services.GetRequiredService<IMessageSession>();
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;
Expand All @@ -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)
Expand Down
33 changes: 27 additions & 6 deletions src/CommonConfigurations/OpenTelemetryExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -46,18 +54,31 @@ 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 =>
{
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";
Expand Down
8 changes: 6 additions & 2 deletions src/CommonConfigurations/SharedConventions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down