From 6e92523afd0c3a7847fcea5dfc4cf7694d3422bc Mon Sep 17 00:00:00 2001 From: Jannis Date: Fri, 9 Oct 2026 18:25:10 +0200 Subject: [PATCH] build: add solution, central package management and Aspire AppHost AppHost orchestrates SQL Server (persistent volume), migrations worker, Api and Web and registers the Docker Compose environment. ServiceDefaults expose /health and /alive in all environments. Co-Authored-By: Claude Opus 5.5 --- .editorconfig | 41 ++++++ .gitattributes | 9 ++ .gitignore | 29 ++++ Directory.Build.props | 12 ++ Directory.Packages.props | 59 +++++++++ FamilyPlanner.slnx | 18 +++ dotnet-tools.json | 13 ++ global.json | 9 ++ src/FamilyPlanner.AppHost/AppHost.cs | 48 +++++++ .../FamilyPlanner.AppHost.csproj | 22 ++++ .../Properties/launchSettings.json | 29 ++++ .../appsettings.Development.json | 11 ++ src/FamilyPlanner.AppHost/appsettings.json | 9 ++ src/FamilyPlanner.AppHost/aspire.config.json | 5 + .../Extensions.cs | 124 ++++++++++++++++++ .../FamilyPlanner.ServiceDefaults.csproj | 19 +++ 16 files changed, 457 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 FamilyPlanner.slnx create mode 100644 dotnet-tools.json create mode 100644 global.json create mode 100644 src/FamilyPlanner.AppHost/AppHost.cs create mode 100644 src/FamilyPlanner.AppHost/FamilyPlanner.AppHost.csproj create mode 100644 src/FamilyPlanner.AppHost/Properties/launchSettings.json create mode 100644 src/FamilyPlanner.AppHost/appsettings.Development.json create mode 100644 src/FamilyPlanner.AppHost/appsettings.json create mode 100644 src/FamilyPlanner.AppHost/aspire.config.json create mode 100644 src/FamilyPlanner.ServiceDefaults/Extensions.cs create mode 100644 src/FamilyPlanner.ServiceDefaults/FamilyPlanner.ServiceDefaults.csproj diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..055d65b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,41 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml,props,targets,csproj,slnx,xml,webmanifest}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.cs] +# Sprache +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_prefer_primary_constructors = true:suggestion +csharp_using_directive_placement = outside_namespace:suggestion +dotnet_sort_system_directives_first = true +csharp_new_line_before_open_brace = all + +# Benennung +dotnet_naming_rule.private_fields_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_underscore.style = underscore_camel +dotnet_naming_rule.private_fields_underscore.severity = suggestion +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_camel.capitalization = camel_case +dotnet_naming_style.underscore_camel.required_prefix = _ + +# Generierter Code +[**/Migrations/*.cs] +generated_code = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3de05d4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto eol=lf + +*.sh text eol=lf +*.ps1 text eol=crlf +*.cmd text eol=crlf + +*.png binary +*.ico binary +*.jpg binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0e8189 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +## .NET +bin/ +obj/ +artifacts/ +*.user +*.suo +*.userprefs +.vs/ +.vscode/* +!.vscode/extensions.json +.idea/ +*.swp +TestResults/ +coverage*/ +*.trx + +## Secrets / lokale Konfiguration +.env +deploy/.env +*.pfx +appsettings.*.local.json + +## Aspire +.aspire/ +aspire-output/ + +## OS +.DS_Store +Thumbs.db diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..abfcaed --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + net10.0 + latest + enable + enable + true + false + de;en + false + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..b24a548 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,59 @@ + + + true + false + 13.6.1 + 10.0.12 + 10.0.12 + 1.19.1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FamilyPlanner.slnx b/FamilyPlanner.slnx new file mode 100644 index 0000000..405ca59 --- /dev/null +++ b/FamilyPlanner.slnx @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..5c1165b --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.12", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..1d364c6 --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/FamilyPlanner.AppHost/AppHost.cs b/src/FamilyPlanner.AppHost/AppHost.cs new file mode 100644 index 0000000..ff3cde6 --- /dev/null +++ b/src/FamilyPlanner.AppHost/AppHost.cs @@ -0,0 +1,48 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// `aspire publish` erzeugt daraus eine docker-compose.yaml (siehe docs/ARCHITECTURE.md §9). +builder.AddDockerComposeEnvironment("compose"); + +// Integrationstests setzen FamilyPlanner:EphemeralDatabase=true, damit die Entwicklungsdaten unberührt bleiben. +var ephemeralDatabase = bool.TryParse(builder.Configuration["FamilyPlanner:EphemeralDatabase"], out var ephemeral) && ephemeral; + +var sql = builder.AddSqlServer("sql"); +if (!ephemeralDatabase) +{ + sql.WithDataVolume("familyplanner-sql-data") + .WithLifetime(ContainerLifetime.Persistent); +} + +var database = sql.AddDatabase("familyplannerdb"); + +// Initial-Admin (nur beim allerersten Start relevant, Passwortwechsel wird erzwungen). +var adminUserName = builder.AddParameter("seed-admin-username", value: "admin", publishValueAsDefault: true); +var adminPassword = builder.AddParameter("seed-admin-password", secret: true); +var adminEmail = builder.AddParameter("seed-admin-email", value: "", publishValueAsDefault: true); + +var migrations = builder.AddProject("migrations") + .WithReference(database) + .WaitFor(database) + .WithEnvironment("Seed__AdminUserName", adminUserName) + .WithEnvironment("Seed__AdminPassword", adminPassword) + .WithEnvironment("Seed__AdminEmail", adminEmail); + +var api = builder.AddProject("api") + .WithReference(database) + .WaitFor(database) + .WaitForCompletion(migrations) + .WithHttpHealthCheck("/health"); + +if (ephemeralDatabase) +{ + // Tests melden sich mehrfach an – Rate Limit großzügiger. + api.WithEnvironment("RateLimiting__LoginPermitLimit", "100"); +} + +builder.AddProject("web") + .WithExternalHttpEndpoints() + .WithReference(api) + .WaitFor(api) + .WithHttpHealthCheck("/health"); + +builder.Build().Run(); diff --git a/src/FamilyPlanner.AppHost/FamilyPlanner.AppHost.csproj b/src/FamilyPlanner.AppHost/FamilyPlanner.AppHost.csproj new file mode 100644 index 0000000..dfaf74a --- /dev/null +++ b/src/FamilyPlanner.AppHost/FamilyPlanner.AppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + 28bccc18-4f99-42dc-a9f2-5cb77c780430 + + $(NoWarn);ASPIRE010 + + + + + + + + + + + + + + diff --git a/src/FamilyPlanner.AppHost/Properties/launchSettings.json b/src/FamilyPlanner.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..8908b8e --- /dev/null +++ b/src/FamilyPlanner.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://localhost:17001;http://localhost:15234", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21215", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22044" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15234", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19252", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20180" + } + } + } +} diff --git a/src/FamilyPlanner.AppHost/appsettings.Development.json b/src/FamilyPlanner.AppHost/appsettings.Development.json new file mode 100644 index 0000000..c8664c7 --- /dev/null +++ b/src/FamilyPlanner.AppHost/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Parameters": { + "seed-admin-password": "Admin123!ChangeMe" + } +} diff --git a/src/FamilyPlanner.AppHost/appsettings.json b/src/FamilyPlanner.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/src/FamilyPlanner.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/FamilyPlanner.AppHost/aspire.config.json b/src/FamilyPlanner.AppHost/aspire.config.json new file mode 100644 index 0000000..119b5be --- /dev/null +++ b/src/FamilyPlanner.AppHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "FamilyPlanner.AppHost.csproj" + } +} diff --git a/src/FamilyPlanner.ServiceDefaults/Extensions.cs b/src/FamilyPlanner.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..0be1995 --- /dev/null +++ b/src/FamilyPlanner.ServiceDefaults/Extensions.cs @@ -0,0 +1,124 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Die Endpunkte werden auch in Produktion gemappt (Docker-Health-Checks). Sie liefern nur den + // aggregierten Status (Healthy/Unhealthy) ohne Details und sind daher unkritisch. + // Readiness: alle Health Checks (inkl. Datenbank) müssen erfolgreich sein. + app.MapHealthChecks(HealthEndpointPath).AllowAnonymous().DisableRateLimiting(); + + // Liveness: nur Checks mit dem Tag "live". + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }).AllowAnonymous().DisableRateLimiting(); + + return app; + } +} diff --git a/src/FamilyPlanner.ServiceDefaults/FamilyPlanner.ServiceDefaults.csproj b/src/FamilyPlanner.ServiceDefaults/FamilyPlanner.ServiceDefaults.csproj new file mode 100644 index 0000000..c567fa5 --- /dev/null +++ b/src/FamilyPlanner.ServiceDefaults/FamilyPlanner.ServiceDefaults.csproj @@ -0,0 +1,19 @@ + + + + true + + + + + + + + + + + + + + +