diff --git a/Aspire.sln b/Aspire.sln index ad1e0dd2696..77e05ffe136 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -583,6 +583,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Dapr.Tests", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.AWS.Tests", "tests\Aspire.Hosting.AWS.Tests\Aspire.Hosting.AWS.Tests.csproj", "{6F71BC73-B703-4E64-98E0-801781302E7A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "waitfor", "waitfor", "{3FF3F00C-95C0-46FC-B2BE-A3920C71E393}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WaitForSandbox.AppHost", "playground\waitfor\WaitForSandbox.AppHost\WaitForSandbox.AppHost.csproj", "{415E011A-1C56-41A1-BAEB-CA5D5CED1A57}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WaitForSandbox.ApiService", "playground\waitfor\WaitForSandbox.ApiService\WaitForSandbox.ApiService.csproj", "{C554C480-3DA7-4D62-A09A-3F3F743D7A66}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WaitForSandbox.DbSetup", "playground\waitfor\WaitForSandbox.DbSetup\WaitForSandbox.DbSetup.csproj", "{37BC5B4A-3F96-4BB0-92E6-81666F4324E4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WaitForSandbox.Common", "playground\waitfor\WaitForSandbox.Common\WaitForSandbox.Common.csproj", "{F0C976EF-EE26-4EA9-B324-0CD21DCEA140}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1537,6 +1547,22 @@ Global {6F71BC73-B703-4E64-98E0-801781302E7A}.Debug|Any CPU.Build.0 = Debug|Any CPU {6F71BC73-B703-4E64-98E0-801781302E7A}.Release|Any CPU.ActiveCfg = Release|Any CPU {6F71BC73-B703-4E64-98E0-801781302E7A}.Release|Any CPU.Build.0 = Release|Any CPU + {415E011A-1C56-41A1-BAEB-CA5D5CED1A57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {415E011A-1C56-41A1-BAEB-CA5D5CED1A57}.Debug|Any CPU.Build.0 = Debug|Any CPU + {415E011A-1C56-41A1-BAEB-CA5D5CED1A57}.Release|Any CPU.ActiveCfg = Release|Any CPU + {415E011A-1C56-41A1-BAEB-CA5D5CED1A57}.Release|Any CPU.Build.0 = Release|Any CPU + {C554C480-3DA7-4D62-A09A-3F3F743D7A66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C554C480-3DA7-4D62-A09A-3F3F743D7A66}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C554C480-3DA7-4D62-A09A-3F3F743D7A66}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C554C480-3DA7-4D62-A09A-3F3F743D7A66}.Release|Any CPU.Build.0 = Release|Any CPU + {37BC5B4A-3F96-4BB0-92E6-81666F4324E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37BC5B4A-3F96-4BB0-92E6-81666F4324E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37BC5B4A-3F96-4BB0-92E6-81666F4324E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37BC5B4A-3F96-4BB0-92E6-81666F4324E4}.Release|Any CPU.Build.0 = Release|Any CPU + {F0C976EF-EE26-4EA9-B324-0CD21DCEA140}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0C976EF-EE26-4EA9-B324-0CD21DCEA140}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0C976EF-EE26-4EA9-B324-0CD21DCEA140}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0C976EF-EE26-4EA9-B324-0CD21DCEA140}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1818,6 +1844,11 @@ Global {091EA540-355B-4763-9980-5F83F0BB6F11} = {15966C27-17FA-4A46-A172-55985411540A} {C60C5CFA-5B6D-4432-BFCD-54D1BEEC7DBE} = {830A89EC-4029-4753-B25A-068BAE37DEC7} {6F71BC73-B703-4E64-98E0-801781302E7A} = {830A89EC-4029-4753-B25A-068BAE37DEC7} + {3FF3F00C-95C0-46FC-B2BE-A3920C71E393} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} + {415E011A-1C56-41A1-BAEB-CA5D5CED1A57} = {3FF3F00C-95C0-46FC-B2BE-A3920C71E393} + {C554C480-3DA7-4D62-A09A-3F3F743D7A66} = {3FF3F00C-95C0-46FC-B2BE-A3920C71E393} + {37BC5B4A-3F96-4BB0-92E6-81666F4324E4} = {3FF3F00C-95C0-46FC-B2BE-A3920C71E393} + {F0C976EF-EE26-4EA9-B324-0CD21DCEA140} = {3FF3F00C-95C0-46FC-B2BE-A3920C71E393} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6DCEDFEC-988E-4CB3-B45B-191EB5086E0C} diff --git a/playground/waitfor/WaitForSandbox.ApiService/Program.cs b/playground/waitfor/WaitForSandbox.ApiService/Program.cs new file mode 100644 index 00000000000..7d8f529afc7 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.ApiService/Program.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore; +using WaitForSandbox.Common; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddNpgsqlDbContext("db"); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); +app.MapGet("/", async (MyDbContext dbContext) => +{ + // We only work with db1Context for the rest of this + // since we've proven connectivity to the others for now. + var entry = new Entry(); + await dbContext.Entries.AddAsync(entry); + await dbContext.SaveChangesAsync(); + + var entries = await dbContext.Entries.ToListAsync(); + + return new + { + totalEntries = entries.Count, + entries = entries + }; +}); + +app.Run(); + diff --git a/playground/waitfor/WaitForSandbox.ApiService/Properties/launchSettings.json b/playground/waitfor/WaitForSandbox.ApiService/Properties/launchSettings.json new file mode 100644 index 00000000000..f7bf310e7ae --- /dev/null +++ b/playground/waitfor/WaitForSandbox.ApiService/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/waitfor/WaitForSandbox.ApiService/WaitForSandbox.ApiService.csproj b/playground/waitfor/WaitForSandbox.ApiService/WaitForSandbox.ApiService.csproj new file mode 100644 index 00000000000..6b01c816d0f --- /dev/null +++ b/playground/waitfor/WaitForSandbox.ApiService/WaitForSandbox.ApiService.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + + + + + + + + + diff --git a/playground/waitfor/WaitForSandbox.ApiService/WaitForSandbox.ApiService.http b/playground/waitfor/WaitForSandbox.ApiService/WaitForSandbox.ApiService.http new file mode 100644 index 00000000000..59fcd09ca55 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.ApiService/WaitForSandbox.ApiService.http @@ -0,0 +1,6 @@ +@CosmosEndToEnd.ApiService_HostAddress = http://localhost:5193 + +GET {{SqlServerEndToEnd.ApiService_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/playground/waitfor/WaitForSandbox.ApiService/appsettings.Development.json b/playground/waitfor/WaitForSandbox.ApiService/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/waitfor/WaitForSandbox.ApiService/appsettings.json b/playground/waitfor/WaitForSandbox.ApiService/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/playground/waitfor/WaitForSandbox.AppHost/Program.cs b/playground/waitfor/WaitForSandbox.AppHost/Program.cs new file mode 100644 index 00000000000..9ed60e654c0 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.AppHost/Program.cs @@ -0,0 +1,32 @@ +// 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 pg = builder.AddPostgres("pg") +// .AsAzurePostgresFlexibleServer() + .WithPgAdmin(); + +var db = pg.AddDatabase("db"); + +var dbsetup = builder.AddProject("dbsetup") + .WithReference(db) + .WaitFor(pg); + +builder.AddProject("api") + .WithExternalHttpEndpoints() + .WaitForCompletion(dbsetup) + .WaitFor(db) + .WithReference(db); + +#if !SKIP_DASHBOARD_REFERENCE +// 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 +// or build with `/p:SkipDashboardReference=true`, 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); +#endif + +builder.Build().Run(); diff --git a/playground/waitfor/WaitForSandbox.AppHost/Properties/launchSettings.json b/playground/waitfor/WaitForSandbox.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..f9dac51d48d --- /dev/null +++ b/playground/waitfor/WaitForSandbox.AppHost/Properties/launchSettings.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:15887;http://localhost:15888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16175", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17037", + "DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16175", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17038", + "DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, + "generate-manifest": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "applicationUrl": "http://localhost:15888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16175" + } + } + } +} diff --git a/playground/waitfor/WaitForSandbox.AppHost/WaitForSandbox.AppHost.csproj b/playground/waitfor/WaitForSandbox.AppHost/WaitForSandbox.AppHost.csproj new file mode 100644 index 00000000000..7297c1b48c8 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.AppHost/WaitForSandbox.AppHost.csproj @@ -0,0 +1,26 @@ + + + + Exe + net8.0 + enable + enable + true + 9776201d-1036-4111-8b0a-7e8a520ea26c + + + + + + + + + + + + + + + + + diff --git a/playground/waitfor/WaitForSandbox.AppHost/appsettings.Development.json b/playground/waitfor/WaitForSandbox.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/waitfor/WaitForSandbox.AppHost/appsettings.json b/playground/waitfor/WaitForSandbox.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/playground/waitfor/WaitForSandbox.AppHost/aspire-manifest.json b/playground/waitfor/WaitForSandbox.AppHost/aspire-manifest.json new file mode 100644 index 00000000000..643d1bc4a1f --- /dev/null +++ b/playground/waitfor/WaitForSandbox.AppHost/aspire-manifest.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json.schemastore.org/aspire-8.0.json", + "resources": { + "pg": { + "type": "azure.bicep.v0", + "connectionString": "{pg.secretOutputs.connectionString}", + "path": "pg.module.bicep", + "params": { + "keyVaultName": "", + "administratorLogin": "{pg-username.value}", + "administratorLoginPassword": "{pg-password.value}" + } + }, + "db": { + "type": "value.v0", + "connectionString": "{pg.connectionString};Database=db" + }, + "dbsetup": { + "type": "project.v0", + "path": "../WaitForSandbox.DbSetup/WaitForSandbox.DbSetup.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", + "HTTP_PORTS": "{dbsetup.bindings.http.targetPort}", + "ConnectionStrings__db": "{db.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + }, + "api": { + "type": "project.v0", + "path": "../WaitForSandbox.ApiService/WaitForSandbox.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", + "HTTP_PORTS": "{api.bindings.http.targetPort}", + "ConnectionStrings__db": "{db.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "external": true + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http", + "external": true + } + } + }, + "pg-username": { + "type": "parameter.v0", + "value": "{pg-username.inputs.value}", + "inputs": { + "value": { + "type": "string", + "default": { + "generate": { + "minLength": 10, + "numeric": false, + "special": false + } + } + } + } + }, + "pg-password": { + "type": "parameter.v0", + "value": "{pg-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22 + } + } + } + } + } + } +} \ No newline at end of file diff --git a/playground/waitfor/WaitForSandbox.AppHost/pg.module.bicep b/playground/waitfor/WaitForSandbox.AppHost/pg.module.bicep new file mode 100644 index 00000000000..d2d7924d606 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.AppHost/pg.module.bicep @@ -0,0 +1,72 @@ +targetScope = 'resourceGroup' + +@description('') +param location string = resourceGroup().location + +@description('') +param administratorLogin string + +@secure() +@description('') +param administratorLoginPassword string + +@description('') +param keyVaultName string + + +resource keyVault_IeF8jZvXV 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +resource postgreSqlFlexibleServer_lEvmXPNkk 'Microsoft.DBforPostgreSQL/flexibleServers@2023-03-01-preview' = { + name: toLower(take('pg${uniqueString(resourceGroup().id)}', 24)) + location: location + tags: { + 'aspire-resource-name': 'pg' + } + sku: { + name: 'Standard_B1ms' + tier: 'Burstable' + } + properties: { + administratorLogin: administratorLogin + administratorLoginPassword: administratorLoginPassword + version: '16' + storage: { + storageSizeGB: 32 + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + availabilityZone: '1' + } +} + +resource postgreSqlFirewallRule_MQ1aepXRF 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = { + parent: postgreSqlFlexibleServer_lEvmXPNkk + name: 'AllowAllAzureIps' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +resource postgreSqlFlexibleServerDatabase_njyTxNuMP 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2023-03-01-preview' = { + parent: postgreSqlFlexibleServer_lEvmXPNkk + name: 'db' + properties: { + } +} + +resource keyVaultSecret_Ddsc3HjrA 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault_IeF8jZvXV + name: 'connectionString' + location: location + properties: { + value: 'Host=${postgreSqlFlexibleServer_lEvmXPNkk.properties.fullyQualifiedDomainName};Username=${administratorLogin};Password=${administratorLoginPassword}' + } +} diff --git a/playground/waitfor/WaitForSandbox.Common/Entry.cs.cs b/playground/waitfor/WaitForSandbox.Common/Entry.cs.cs new file mode 100644 index 00000000000..481374aed74 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.Common/Entry.cs.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace WaitForSandbox.Common; + +public class Entry +{ + public Guid Id { get; set; } = Guid.NewGuid(); +} diff --git a/playground/waitfor/WaitForSandbox.Common/MyDbContext.cs b/playground/waitfor/WaitForSandbox.Common/MyDbContext.cs new file mode 100644 index 00000000000..57a10f57c9a --- /dev/null +++ b/playground/waitfor/WaitForSandbox.Common/MyDbContext.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore; + +namespace WaitForSandbox.Common; + +public class MyDbContext(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity().HasKey(e => e.Id); + } + + public DbSet Entries { get; set; } +} diff --git a/playground/waitfor/WaitForSandbox.Common/WaitForSandbox.Common.csproj b/playground/waitfor/WaitForSandbox.Common/WaitForSandbox.Common.csproj new file mode 100644 index 00000000000..9899ed8b852 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.Common/WaitForSandbox.Common.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/playground/waitfor/WaitForSandbox.DbSetup/Program.cs b/playground/waitfor/WaitForSandbox.DbSetup/Program.cs new file mode 100644 index 00000000000..06b83c96e92 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.DbSetup/Program.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using WaitForSandbox.Common; + +var builder = WebApplication.CreateBuilder(args); +builder.AddNpgsqlDbContext("db"); +using var app = builder.Build(); +using var scope = app.Services.CreateScope(); +using var db = scope.ServiceProvider.GetRequiredService(); + +var created = await db.Database.EnsureCreatedAsync(); +if (created) +{ + Console.WriteLine("Database created!"); +} diff --git a/playground/waitfor/WaitForSandbox.DbSetup/Properties/launchSettings.json b/playground/waitfor/WaitForSandbox.DbSetup/Properties/launchSettings.json new file mode 100644 index 00000000000..914e06f2a93 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.DbSetup/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "WaitForSandbox.DbSetup": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:53474;http://localhost:53475" + } + } +} \ No newline at end of file diff --git a/playground/waitfor/WaitForSandbox.DbSetup/WaitForSandbox.DbSetup.csproj b/playground/waitfor/WaitForSandbox.DbSetup/WaitForSandbox.DbSetup.csproj new file mode 100644 index 00000000000..3c478632e29 --- /dev/null +++ b/playground/waitfor/WaitForSandbox.DbSetup/WaitForSandbox.DbSetup.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs index 61db16a37f9..5d864cf8038 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs @@ -41,21 +41,30 @@ IDistributedApplicationEventing eventing private readonly AzureProvisionerOptions _options = options.Value; - private static IResource PromoteAzureResourceFromAnnotation(IResource resource) + private static List<(IResource Resource, IAzureResource AzureResource)> GetAzureResourcesFromAppModel(DistributedApplicationModel appModel) { // Some resources do not derive from IAzureResource but can be handled // by the Azure provisioner because they have the AzureBicepResourceAnnotation // which holds a reference to the surrogate AzureBicepResource which implements // IAzureResource and can be used by the Azure Bicep Provisioner. - if (resource.Annotations.OfType().SingleOrDefault() is not { } azureSurrogate) + var azureResources = new List<(IResource, IAzureResource)>(); + foreach (var resource in appModel.Resources) { - return resource; - } - else - { - return azureSurrogate.Resource; + if (resource is IAzureResource azureResource) + { + // If we are dealing with an Azure resource then we just return it. + azureResources.Add((resource, azureResource)); + } + if (resource.Annotations.OfType().SingleOrDefault() is { } annotation) + { + // If we aren't an Azure resource and there is no surrogate, return null for + // the Azure resource in the tuple (we'll filter it out later. + azureResources.Add((resource, annotation.Resource)); + } } + + return azureResources; } public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) @@ -66,42 +75,51 @@ public async Task BeforeStartAsync(DistributedApplicationModel appModel, Cancell return; } - var azureResources = appModel.Resources.Select(PromoteAzureResourceFromAnnotation).OfType().ToList(); + var azureResources = GetAzureResourcesFromAppModel(appModel); + if (azureResources.Count == 0) { return; } - static IAzureResource? SelectParentAzureResource(IResource resource) => resource switch + static IResource? SelectParentResource(IResource resource) => resource switch { IAzureResource ar => ar, - IResourceWithParent rp => SelectParentAzureResource(rp.Parent), + IResourceWithParent rp => SelectParentResource(rp.Parent), _ => null }; - // parent -> children lookup - var parentChildLookup = appModel.Resources.OfType() - .Select(x => (Child: x, Root: SelectParentAzureResource(x.Parent))) - .Where(x => x.Root is not null) - .ToLookup(x => x.Root, x => x.Child); + // Create a map of parents to their children used to propogate state changes later. + var parentChildLookup = appModel.Resources.OfType().ToLookup(r => r.Parent); // Sets the state of the resource and all of its children - async Task UpdateStateAsync(IAzureResource resource, Func stateFactory) + async Task UpdateStateAsync((IResource Resource, IAzureResource AzureResource) resource, Func stateFactory) { - await notificationService.PublishUpdateAsync(resource, stateFactory).ConfigureAwait(false); + await notificationService.PublishUpdateAsync(resource.AzureResource, stateFactory).ConfigureAwait(false); - foreach (var child in parentChildLookup[resource]) + // Some IAzureResource instances are a surrogate for for another resource in the app model + // to ensure that resource events are published for the resource that the user expects + // we lookup the resource in the app model here and publish the update to it as well. + if (resource.Resource != resource.AzureResource) + { + await notificationService.PublishUpdateAsync(resource.Resource, stateFactory).ConfigureAwait(false); + } + + // We basically want child resources to be moved into the same state as their parent resources whenever + // there is a state update. This is done for us in DCP so we replicate the behavior here in the Azure Provisioner. + var childResources = parentChildLookup[resource.Resource]; + foreach (var child in childResources) { await notificationService.PublishUpdateAsync(child, stateFactory).ConfigureAwait(false); } } // After the resource is provisioned, set its state - async Task AfterProvisionAsync(IAzureResource resource) + async Task AfterProvisionAsync((IResource Resource, IAzureResource AzureResource) resource) { try { - await resource.ProvisioningTaskCompletionSource!.Task.ConfigureAwait(false); + await resource.AzureResource.ProvisioningTaskCompletionSource!.Task.ConfigureAwait(false); await UpdateStateAsync(resource, s => s with { @@ -130,7 +148,7 @@ await UpdateStateAsync(resource, s => s with // Mark all resources as starting foreach (var r in azureResources) { - r.ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + r.AzureResource!.ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); await UpdateStateAsync(r, s => s with { @@ -161,7 +179,7 @@ private static async Task GetUserSecretsAsync(string? userSecretsPat private async Task ProvisionAzureResources( IConfiguration configuration, ILogger logger, - IList azureResources, + IList<(IResource Resource, IAzureResource AzureResource)> azureResources, CancellationToken cancellationToken) { // Try to find the user secrets path so that provisioners can persist connection information. @@ -214,13 +232,13 @@ private async Task ProvisionAzureResources( // Set the completion source for all resources foreach (var resource in azureResources) { - resource.ProvisioningTaskCompletionSource?.TrySetResult(); + resource.AzureResource.ProvisioningTaskCompletionSource?.TrySetResult(); } } - private async Task ProcessResourceAsync(IConfiguration configuration, Lazy> provisioningContextLazy, IAzureResource resource, CancellationToken cancellationToken) + private async Task ProcessResourceAsync(IConfiguration configuration, Lazy> provisioningContextLazy, (IResource Resource, IAzureResource AzureResource) resource, CancellationToken cancellationToken) { - var beforeResourceStartedEvent = new BeforeResourceStartedEvent(resource, serviceProvider); + var beforeResourceStartedEvent = new BeforeResourceStartedEvent(resource.Resource, serviceProvider); await eventing.PublishAsync(beforeResourceStartedEvent, cancellationToken).ConfigureAwait(false); IAzureResourceProvisioner? SelectProvisioner(IAzureResource resource) @@ -242,65 +260,71 @@ private async Task ProcessResourceAsync(IConfiguration configuration, Lazy GetProvisioningContextAsync(Lazy> userSecretsLazy, CancellationToken cancellationToken) diff --git a/src/Aspire.Hosting.PostgreSQL/Aspire.Hosting.PostgreSQL.csproj b/src/Aspire.Hosting.PostgreSQL/Aspire.Hosting.PostgreSQL.csproj index c85a73b11e1..b8ceb5f95aa 100644 --- a/src/Aspire.Hosting.PostgreSQL/Aspire.Hosting.PostgreSQL.csproj +++ b/src/Aspire.Hosting.PostgreSQL/Aspire.Hosting.PostgreSQL.csproj @@ -17,6 +17,10 @@ + + + + diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index 38b88bbb0b1..28ef4c508ce 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -6,6 +6,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Postgres; using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting; @@ -26,6 +27,14 @@ public static class PostgresBuilderExtensions /// The parameter used to provide the administrator password for the PostgreSQL resource. If a random password will be generated. /// The host port used when launching the container. If null a random port will be assigned. /// A reference to the . + /// + /// + /// This resource includes built-in health checks. When this resource is referenced as a dependency + /// using the + /// extension method then the dependent resource will wait until the Postgres resource is able to service + /// requests. + /// + /// public static IResourceBuilder AddPostgres(this IDistributedApplicationBuilder builder, string name, IResourceBuilder? userName = null, @@ -38,6 +47,42 @@ public static IResourceBuilder AddPostgres(this IDistrib var passwordParameter = password?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-password"); var postgresServer = new PostgresServerResource(name, userName?.Resource, passwordParameter); + + string? connectionString = null; + + builder.Eventing.Subscribe(postgresServer, async (@event, ct) => + { + connectionString = await postgresServer.GetConnectionStringAsync(ct).ConfigureAwait(false); + + var lookup = builder.Resources.OfType().ToDictionary(d => d.Name); + + foreach (var databaseName in postgresServer.Databases) + { + if (!lookup.TryGetValue(databaseName.Key, out var databaseResource)) + { + throw new DistributedApplicationException($"Database resource '{databaseName}' under Postgres server resource '{postgresServer.Name}' not in model."); + } + + var connectionStringAvailableEvent = new ConnectionStringAvailableEvent(databaseResource, @event.Services); + await builder.Eventing.PublishAsync(connectionStringAvailableEvent, ct).ConfigureAwait(false); + + var beforeResourceStartedEvent = new BeforeResourceStartedEvent(databaseResource, @event.Services); + await builder.Eventing.PublishAsync(beforeResourceStartedEvent, ct).ConfigureAwait(false); + } + }); + + var healthCheckKey = $"{name}_check"; + builder.Services.AddHealthChecks().AddNpgSql(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey, configure: (connection) => + { + // HACK: The Npgsql client defaults to using the username in the connection string if the database is not specified. Here + // we override this default behavior because we are working with a non-database scoped connection string. The Aspirified + // package doesn't have to deal with this because it uses a datasource from DI which doesn't have this issue: + // + // https://github.com/npgsql/npgsql/blob/c3b31c393de66a4b03fba0d45708d46a2acb06d2/src/Npgsql/NpgsqlConnection.cs#L445 + // + connection.ConnectionString = connection.ConnectionString + ";Database=postgres;"; + }); + return builder.AddResource(postgresServer) .WithEndpoint(port: port, targetPort: 5432, name: PostgresServerResource.PrimaryEndpointName) // Internal port is always 5432. .WithImage(PostgresContainerImageTags.Image, PostgresContainerImageTags.Tag) @@ -48,7 +93,8 @@ public static IResourceBuilder AddPostgres(this IDistrib { context.EnvironmentVariables[UserEnvVarName] = postgresServer.UserNameReference; context.EnvironmentVariables[PasswordEnvVarName] = postgresServer.PasswordParameter; - }); + }) + .WithHealthCheck(healthCheckKey); } /// @@ -58,6 +104,19 @@ public static IResourceBuilder AddPostgres(this IDistrib /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. /// The name of the database. If not provided, this defaults to the same value as . /// A reference to the . + /// + /// + /// This resource includes built-in health checks. When this resource is referenced as a dependency + /// using the + /// extension method then the dependent resource will wait until the Postgres database is available. + /// + /// + /// Note that by default calling + /// does not result in the database being created on the Postgres server. It is expected that code within your solution + /// will create the database. As a result if + /// is used with this resource it will wait indefinitely until the database exists. + /// + /// public static IResourceBuilder AddDatabase(this IResourceBuilder builder, string name, string? databaseName = null) { ArgumentNullException.ThrowIfNull(builder); @@ -68,7 +127,19 @@ public static IResourceBuilder AddDatabase(this IResou builder.Resource.AddDatabase(name, databaseName); var postgresDatabase = new PostgresDatabaseResource(name, databaseName, builder.Resource); - return builder.ApplicationBuilder.AddResource(postgresDatabase); + + string? connectionString = null; + + builder.ApplicationBuilder.Eventing.Subscribe(postgresDatabase, async (@event, ct) => + { + connectionString = await postgresDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + }); + + var healthCheckKey = $"{name}_check"; + builder.ApplicationBuilder.Services.AddHealthChecks().AddNpgSql(sp => connectionString!, name: healthCheckKey); + + return builder.ApplicationBuilder.AddResource(postgresDatabase) + .WithHealthCheck(healthCheckKey); } /// diff --git a/src/Aspire.Hosting.Redis/Aspire.Hosting.Redis.csproj b/src/Aspire.Hosting.Redis/Aspire.Hosting.Redis.csproj index f9a4bab88cf..6ad552bddfc 100644 --- a/src/Aspire.Hosting.Redis/Aspire.Hosting.Redis.csproj +++ b/src/Aspire.Hosting.Redis/Aspire.Hosting.Redis.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index d80cefd8f1f..7e765915a1a 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -6,6 +6,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Redis; using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting; @@ -24,15 +25,35 @@ public static class RedisBuilderExtensions /// 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. /// A reference to the . + /// + /// + /// This resource includes built-in health checks. When this resource is referenced as a dependency + /// using the + /// extension method then the dependent resource will wait until the Redis resource is able to service + /// requests. + /// + /// public static IResourceBuilder AddRedis(this IDistributedApplicationBuilder builder, string name, int? port = null) { ArgumentNullException.ThrowIfNull(builder); var redis = new RedisResource(name); + + string? connectionString = null; + + builder.Eventing.Subscribe(redis, async (@event, ct) => + { + connectionString = await redis.GetConnectionStringAsync(ct).ConfigureAwait(false); + }); + + var healthCheckKey = $"{name}_check"; + builder.Services.AddHealthChecks().AddRedis(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); + return builder.AddResource(redis) .WithEndpoint(port: port, targetPort: 6379, name: RedisResource.PrimaryEndpointName) .WithImage(RedisContainerImageTags.Image, RedisContainerImageTags.Tag) - .WithImageRegistry(RedisContainerImageTags.Registry); + .WithImageRegistry(RedisContainerImageTags.Registry) + .WithHealthCheck(healthCheckKey); } /// diff --git a/src/Aspire.Hosting/ApplicationModel/ConnectionStringAvailableEvent.cs b/src/Aspire.Hosting/ApplicationModel/ConnectionStringAvailableEvent.cs new file mode 100644 index 00000000000..354e2a7e10c --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ConnectionStringAvailableEvent.cs @@ -0,0 +1,22 @@ +// 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.Eventing; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// The is raised when a connection string becomes available for a resource. +/// +/// The for the event. +/// The for the app host. +public class ConnectionStringAvailableEvent(IResource resource, IServiceProvider services) : IDistributedApplicationResourceEvent +{ + /// + public IResource Resource => resource; + + /// + /// The for the app host. + /// + public IServiceProvider Services => services; +} diff --git a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs index e11693939ec..e289d85b8bb 100644 --- a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs +++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Aspire.Hosting.ApplicationModel; @@ -35,6 +36,11 @@ public sealed record CustomResourceSnapshot /// public int? ExitCode { get; init; } + /// + /// The health status of the resource. + /// + public HealthStatus? HealthStatus { get; init; } + /// /// The environment variables that should show up in the dashboard for this resource. /// diff --git a/src/Aspire.Hosting/ApplicationModel/HealthCheckAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/HealthCheckAnnotation.cs new file mode 100644 index 00000000000..7dc1e78ef19 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/HealthCheckAnnotation.cs @@ -0,0 +1,16 @@ +// 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; + +/// +/// An annotation which tracks the name of the health check used to detect to health of a resource. +/// +/// The key for the health check in the app host which associated with this resource. +public class HealthCheckAnnotation(string key) : IResourceAnnotation +{ + /// + /// The key for the health check in the app host which associated with this resource. + /// + public string Key => key; +} diff --git a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs index 24772830242..2df90c4dca4 100644 --- a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs +++ b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs @@ -1164,6 +1164,12 @@ await notificationService.PublishUpdateAsync(cr.ModelResource, s => s with private async Task CreateExecutableAsync(AppResource er, ILogger resourceLogger, CancellationToken cancellationToken) { + if (er.ModelResource is IResourceWithConnectionString) + { + var connectionStringAvailableEvent = new ConnectionStringAvailableEvent(er.ModelResource, serviceProvider); + await eventing.PublishAsync(connectionStringAvailableEvent, cancellationToken).ConfigureAwait(false); + } + var beforeResourceStartedEvent = new BeforeResourceStartedEvent(er.ModelResource, serviceProvider); await eventing.PublishAsync(beforeResourceStartedEvent, cancellationToken).ConfigureAwait(false); @@ -1436,6 +1442,12 @@ await notificationService.PublishUpdateAsync(cr.ModelResource, s => s with private async Task CreateContainerAsync(AppResource cr, ILogger resourceLogger, CancellationToken cancellationToken) { + if (cr.ModelResource is IResourceWithConnectionString) + { + var connectionStringAvailableEvent = new ConnectionStringAvailableEvent(cr.ModelResource, serviceProvider); + await eventing.PublishAsync(connectionStringAvailableEvent, cancellationToken).ConfigureAwait(false); + } + var beforeResourceStartedEvent = new BeforeResourceStartedEvent(cr.ModelResource, serviceProvider); await eventing.PublishAsync(beforeResourceStartedEvent, cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 73b9a68c624..056891e872c 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -7,11 +7,13 @@ using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp; using Aspire.Hosting.Eventing; +using Aspire.Hosting.Health; using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Publishing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -159,8 +161,10 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddHostedService(); _innerBuilder.Services.AddSingleton(options); _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(Eventing); + _innerBuilder.Services.AddHealthChecks(); if (ExecutionContext.IsRunMode) { diff --git a/src/Aspire.Hosting/Health/ResourceNotificationHealthCheckPublisher.cs b/src/Aspire.Hosting/Health/ResourceNotificationHealthCheckPublisher.cs new file mode 100644 index 00000000000..2c1dff517f3 --- /dev/null +++ b/src/Aspire.Hosting/Health/ResourceNotificationHealthCheckPublisher.cs @@ -0,0 +1,27 @@ +// 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 Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Hosting.Health; + +internal class ResourceNotificationHealthCheckPublisher(DistributedApplicationModel model, ResourceNotificationService resourceNotificationService) : IHealthCheckPublisher +{ + public async Task PublishAsync(HealthReport report, CancellationToken cancellationToken) + { + foreach (var resource in model.Resources) + { + if (resource.TryGetAnnotationsOfType(out var annotations)) + { + var resourceEntries = report.Entries.Where(e => annotations.Any(a => a.Key == e.Key)); + var status = resourceEntries.All(e => e.Value.Status == HealthStatus.Healthy) ? HealthStatus.Healthy : HealthStatus.Unhealthy; + + await resourceNotificationService.PublishUpdateAsync(resource, s => s with + { + HealthStatus = status + }).ConfigureAwait(false); + } + } + } +} diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index a0ff5f8815b..2bcb2f4f2b5 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -15,6 +15,10 @@ Aspire.Hosting.ApplicationModel.BeforeStartEvent Aspire.Hosting.ApplicationModel.BeforeStartEvent.BeforeStartEvent(System.IServiceProvider! services, Aspire.Hosting.ApplicationModel.DistributedApplicationModel! model) -> void Aspire.Hosting.ApplicationModel.BeforeStartEvent.Model.get -> Aspire.Hosting.ApplicationModel.DistributedApplicationModel! Aspire.Hosting.ApplicationModel.BeforeStartEvent.Services.get -> System.IServiceProvider! +Aspire.Hosting.ApplicationModel.ConnectionStringAvailableEvent +Aspire.Hosting.ApplicationModel.ConnectionStringAvailableEvent.ConnectionStringAvailableEvent(Aspire.Hosting.ApplicationModel.IResource! resource, System.IServiceProvider! services) -> void +Aspire.Hosting.ApplicationModel.ConnectionStringAvailableEvent.Resource.get -> Aspire.Hosting.ApplicationModel.IResource! +Aspire.Hosting.ApplicationModel.ConnectionStringAvailableEvent.Services.get -> System.IServiceProvider! Aspire.Hosting.ApplicationModel.ContainerLifetimeAnnotation Aspire.Hosting.ApplicationModel.ContainerLifetimeAnnotation.ContainerLifetimeAnnotation() -> void Aspire.Hosting.ApplicationModel.ContainerLifetimeAnnotation.LifetimeType.get -> Aspire.Hosting.ApplicationModel.ContainerLifetimeType @@ -22,6 +26,11 @@ Aspire.Hosting.ApplicationModel.ContainerLifetimeAnnotation.LifetimeType.set -> Aspire.Hosting.ApplicationModel.ContainerLifetimeType Aspire.Hosting.ApplicationModel.ContainerLifetimeType.Default = 0 -> Aspire.Hosting.ApplicationModel.ContainerLifetimeType Aspire.Hosting.ApplicationModel.ContainerLifetimeType.Persistent = 1 -> Aspire.Hosting.ApplicationModel.ContainerLifetimeType +Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.get -> Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? +Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.init -> void +Aspire.Hosting.ApplicationModel.HealthCheckAnnotation +Aspire.Hosting.ApplicationModel.HealthCheckAnnotation.HealthCheckAnnotation(string! key) -> void +Aspire.Hosting.ApplicationModel.HealthCheckAnnotation.Key.get -> string! Aspire.Hosting.ApplicationModel.ResourceNotificationService.ResourceNotificationService(Microsoft.Extensions.Logging.ILogger! logger, Microsoft.Extensions.Hosting.IHostApplicationLifetime! hostApplicationLifetime) -> void Aspire.Hosting.ApplicationModel.ResourceNotificationService.WaitForResourceAsync(string! resourceName, System.Func! predicate, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Aspire.Hosting.DistributedApplicationBuilder.Eventing.get -> Aspire.Hosting.Eventing.IDistributedApplicationEventing! @@ -60,6 +69,7 @@ Aspire.Hosting.DistributedApplicationExecutionContextOptions.ServiceProvider.get Aspire.Hosting.DistributedApplicationExecutionContextOptions.ServiceProvider.set -> void static Aspire.Hosting.ResourceBuilderExtensions.WaitFor(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder! dependency) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ResourceBuilderExtensions.WaitForCompletion(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder! dependency, int exitCode = 0) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.ResourceBuilderExtensions.WithHealthCheck(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! key) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Exited -> string! static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.FailedToStart -> string! static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Finished -> string! diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index b4f85ee9a5a..c0a0bcc389e 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -5,6 +5,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -562,6 +563,12 @@ public static IResourceBuilder ExcludeFromManifest(this IResourceBuilder /// This method is useful when a resource should wait until another has started running. This can help /// reduce errors in logs during local development where dependency resources. + /// Some resources automatically register health checks with the application host container. For these + /// resources, calling also results + /// in the resource being blocked from starting until the health checks associated with the dependency resource + /// return . + /// The method can be used to associate + /// additional health checks with a resource. /// /// /// Start message queue before starting the worker service. @@ -607,6 +614,14 @@ public static IResourceBuilder WaitFor(this IResourceBuilder builder, I $"Resource '{dependency.Resource.Name}' has entered the '{snapshot.State.Text}' state prematurely." ); } + + // If our dependency resource has health check annotations we want to wait until they turn healthy + // otherwise we don't care about their health status. + if (dependency.Resource.TryGetAnnotationsOfType(out var _)) + { + resourceLogger.LogInformation("Waiting for resource '{Name}' to become healthy.", dependency.Resource.Name); + await rns.WaitForResourceAsync(dependency.Resource.Name, re => re.Snapshot.HealthStatus == HealthStatus.Healthy, cancellationToken: ct).ConfigureAwait(false); + } }); return builder; @@ -692,4 +707,52 @@ static bool IsKnownTerminalState(CustomResourceSnapshot snapshot) => KnownResourceStates.TerminalStates.Contains(snapshot.State?.Text) && snapshot.ExitCode is not null; } + + /// + /// Adds a to the resource annotations to associate a resource with a named health check managed by the health check service. + /// + /// The type of the resource. + /// The resource builder. + /// The key for the health check. + /// The resource builder. + /// + /// + /// The method is used in conjunction with + /// the to associate a resource + /// registered in the application hosts dependency injection container. The + /// method does not inject the health check itself it is purely an association mechanism. + /// + /// + /// + /// Define a custom health check and associate it with a resource. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var startAfter = DateTime.Now.AddSeconds(30); + /// + /// builder.Services.AddHealthChecks().AddCheck(mycheck", () => + /// { + /// return DateTime.Now > startAfter ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy(); + /// }); + /// + /// var pg = builder.AddPostgres("pg") + /// .WithHealthCheck("mycheck"); + /// + /// builder.AddProject<Projects.MyApp>("myapp") + /// .WithReference(pg) + /// .WaitFor(pg); // This will result in waiting for the building check, and the + /// // custom check defined in the code. + /// + /// + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder, string key) where T: IResource + { + if (builder.Resource.TryGetAnnotationsOfType(out var annotations) && annotations.Any(a => a.Key == key)) + { + throw new DistributedApplicationException($"Resource '{builder.Resource.Name}' already has a health check with key '{key}'."); + } + + builder.WithAnnotation(new HealthCheckAnnotation(key)); + + return builder; + } } diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs index 58f3ed4b976..5bf307dcfaf 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs @@ -14,6 +14,14 @@ namespace Aspire.Hosting.PostgreSQL.Tests; public class AddPostgresTests { + [Fact] + public void AddPostgresAddsHealthCheckAnnotationToResource() + { + var builder = DistributedApplication.CreateBuilder(); + var redis = builder.AddPostgres("postgres"); + Assert.Single(redis.Resource.Annotations, a => a is HealthCheckAnnotation hca && hca.Key == "postgres_check"); + } + [Fact] public void AddPostgresAddsGeneratedPasswordParameterWithUserSecretsParameterDefaultInRunMode() { diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs index b433815ac11..ba5ac31f407 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs @@ -3,10 +3,12 @@ using System.Data; using Aspire.Components.Common.Tests; +using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Postgres; using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Npgsql; using Polly; @@ -17,6 +19,125 @@ namespace Aspire.Hosting.PostgreSQL.Tests; public class PostgresFunctionalTests(ITestOutputHelper testOutputHelper) { + [Fact] + [RequiresDocker] + public async Task VerifyWaitForOnPostgresServerBlocksDependentResources() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + + // We use the following check added to the Postgres resource to block + // dependent reosurces from starting. This means we'll have two checks + // associated with the postgres resource ... the built in one and the + // one that we add here. We'll manipulate the TCS to allow us to check + // states at various stages of the execution. + var healthCheckTcs = new TaskCompletionSource(); + builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => + { + return healthCheckTcs.Task; + }); + + var postgres = builder.AddPostgres("postgres") + .WithHealthCheck("blocking_check"); + + var dependentResource = builder.AddPostgres("dependentresource") + .WaitFor(postgres); // Just using another postgres instance as a dependent resource. + + using var app = builder.Build(); + + var pendingStart = app.StartAsync(cts.Token); + + var rns = app.Services.GetRequiredService(); + + // What for the postgres server to start. + await rns.WaitForResourceAsync(postgres.Resource.Name, KnownResourceStates.Running, cts.Token); + + // Wait for the dependent resource to be in the Waiting state. + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Waiting, cts.Token); + + // Now unblock the health check. + healthCheckTcs.SetResult(HealthCheckResult.Healthy()); + + // ... and wait for the resource as a whole to move into the health state. + await rns.WaitForResourceAsync(postgres.Resource.Name, (re => re.Snapshot.HealthStatus == HealthStatus.Healthy), cts.Token); + + // ... then the dependent resource should be able to move into a running state. + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await pendingStart; // Startup should now complete. + + // ... but we'll shut everything down immediately because we are done. + await app.StopAsync(); + } + + [Fact] + [RequiresDocker] + public async Task VerifyWaitForOnPostgresDatabaseBlocksDependentResources() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + + // We use the following check added to the Postgres resource to block + // dependent reosurces from starting. This means we'll have two checks + // associated with the postgres resource ... the built in one and the + // one that we add here. We'll manipulate the TCS to allow us to check + // states at various stages of the execution. + var healthCheckTcs = new TaskCompletionSource(); + builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => + { + return healthCheckTcs.Task; + }); + + var postgres = builder.AddPostgres("postgres") + .WithHealthCheck("blocking_check"); + + var db = postgres.AddDatabase("db"); + + var dependentResource = builder.AddPostgres("dependentresource") + .WaitFor(db); // Wait on the database instead of the server! + + using var app = builder.Build(); + + var pendingStart = app.StartAsync(cts.Token); + + var rns = app.Services.GetRequiredService(); + + // What for the postgres server to start. + await rns.WaitForResourceAsync(postgres.Resource.Name, KnownResourceStates.Running, cts.Token); + + // The database should adopt the state of the parent resource. + await rns.WaitForResourceAsync(db.Resource.Name, KnownResourceStates.Running, cts.Token); + + // Wait for the dependent resource to be in the Waiting state. + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Waiting, cts.Token); + + // Now unblock the health check. + healthCheckTcs.SetResult(HealthCheckResult.Healthy()); + + // ... and wait for the resource as a whole to move into the health state. + await rns.WaitForResourceAsync(postgres.Resource.Name, (re => re.Snapshot.HealthStatus == HealthStatus.Healthy), cts.Token); + + // Create the database. + var connectionString = await postgres.Resource.GetConnectionStringAsync(cts.Token); + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(cts.Token); + + var command = connection.CreateCommand(); + command.CommandText = "CREATE DATABASE db;"; + await command.ExecuteNonQueryAsync(cts.Token); + + // ... then wait for the database to turn healthy. + await rns.WaitForResourceAsync(db.Resource.Name, (re => re.Snapshot.HealthStatus == HealthStatus.Healthy), cts.Token); + + // ... then the dependent resource should be able to move into a running state. + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await pendingStart; // Startup should now complete. + + // ... but we'll shut everything down immediately because we are done. + await app.StopAsync(); + } + [Fact] [RequiresDocker] public async Task VerifyPostgresResource() diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index 621a992e405..e07fa7f1cc3 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -12,6 +12,14 @@ namespace Aspire.Hosting.Redis.Tests; public class AddRedisTests { + [Fact] + public void AddRedisAddsHealthCheckAnnotationToResource() + { + var builder = DistributedApplication.CreateBuilder(); + var redis = builder.AddRedis("redis"); + Assert.Single(redis.Resource.Annotations, a => a is HealthCheckAnnotation hca && hca.Key == "redis_check"); + } + [Fact] public void AddRedisContainerWithDefaultsAddsAnnotationMetadata() { diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index 50cfa132eb4..502abd218c6 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Components.Common.Tests; +using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using StackExchange.Redis; using Xunit; @@ -14,6 +16,57 @@ namespace Aspire.Hosting.Redis.Tests; public class RedisFunctionalTests(ITestOutputHelper testOutputHelper) { + [Fact] + [RequiresDocker] + public async Task VerifyWaitForOnRedisBlocksDependentResources() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + + // We use the following check added to the Redis resource to block + // dependent reosurces from starting. This means we'll have two checks + // associated with the redis resource ... the built in one and the + // one that we add here. We'll manipulate the TCS to allow us to check + // states at various stages of the execution. + var healthCheckTcs = new TaskCompletionSource(); + builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => + { + return healthCheckTcs.Task; + }); + + var redis = builder.AddRedis("redis") + .WithHealthCheck("blocking_check"); + + var dependentResource = builder.AddRedis("dependentresource") + .WaitFor(redis); // Just using another redis instance as a dependent resource. + + using var app = builder.Build(); + + var pendingStart = app.StartAsync(cts.Token); + + var rns = app.Services.GetRequiredService(); + + // What for the Redis server to start. + await rns.WaitForResourceAsync(redis.Resource.Name, KnownResourceStates.Running, cts.Token); + + // Wait for the dependent resource to be in the Waiting state. + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Waiting, cts.Token); + + // Now unblock the health check. + healthCheckTcs.SetResult(HealthCheckResult.Healthy()); + + // ... and wait for the resource as a whole to move into the health state. + await rns.WaitForResourceAsync(redis.Resource.Name, (re => re.Snapshot.HealthStatus == HealthStatus.Healthy), cts.Token); + + // ... then the dependent resource should be able to move into a running state. + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await pendingStart; // Startup should now complete. + + // ... but we'll shut everything down immediately because we are done. + await app.StopAsync(); + } + [Fact] [RequiresDocker] public async Task VerifyRedisResource() diff --git a/tests/Aspire.Playground.Tests/Infrastructure/DistributedApplicationExtensions.cs b/tests/Aspire.Playground.Tests/Infrastructure/DistributedApplicationExtensions.cs index f66ad2acfe9..09849292e1c 100644 --- a/tests/Aspire.Playground.Tests/Infrastructure/DistributedApplicationExtensions.cs +++ b/tests/Aspire.Playground.Tests/Infrastructure/DistributedApplicationExtensions.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; @@ -134,7 +134,9 @@ public static void EnsureNoErrorsLogged(this DistributedApplication app) var (appHostlogs, resourceLogs) = app.GetLogs(); - AssertDoesNotContain(appHostlogs, log => log.Level >= LogLevel.Error); + // Ignoring log entries from DefaultHealthCheckService for now. Once we have dashboard integration for health checks + // we'll filter out these log messages from the apphost completely but its useful for debugging for now. + AssertDoesNotContain(appHostlogs, log => log.Level >= LogLevel.Error && log.Category != "Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService"); AssertDoesNotContain(resourceLogs, log => log.Category is { Length: > 0 } category && assertableResourceLogNames.Contains(category) && log.Level >= LogLevel.Error); static bool ShouldAssertErrorsForResource(IResource resource)