diff --git a/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/Program.cs b/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/Program.cs index 5299dc2c65..78ae32c84c 100644 --- a/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/Program.cs +++ b/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/Program.cs @@ -21,6 +21,8 @@ // External resources. var db10 = builder.AddPostgres("pg10").WithPgAdmin().PublishAsConnectionString().AddDatabase("db10"); +var db11 = builder.AddPostgres("pg11").WithPgWeb().AddDatabase("postgres"); + builder.AddProject("api") .WithExternalHttpEndpoints() .WithReference(db1) @@ -32,8 +34,8 @@ .WithReference(db7) .WithReference(db8) .WithReference(db9) - .WithReference(db10); - + .WithReference(db10) + .WithReference(db11); #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 diff --git a/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/aspire-manifest.json b/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/aspire-manifest.json index 1090c7fe68..188e5a36c0 100644 --- a/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/aspire-manifest.json +++ b/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/aspire-manifest.json @@ -166,6 +166,29 @@ "type": "value.v0", "connectionString": "{pg10.connectionString};Database=db10" }, + "pg11": { + "type": "container.v0", + "connectionString": "Host={pg11.bindings.tcp.host};Port={pg11.bindings.tcp.port};Username=postgres;Password={pg11-password.value}", + "image": "docker.io/library/postgres:16.2", + "env": { + "POSTGRES_HOST_AUTH_METHOD": "scram-sha-256", + "POSTGRES_INITDB_ARGS": "--auth-host=scram-sha-256 --auth-local=scram-sha-256", + "POSTGRES_USER": "postgres", + "POSTGRES_PASSWORD": "{pg11-password.value}" + }, + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 5432 + } + } + }, + "postgres": { + "type": "value.v0", + "connectionString": "{pg11.connectionString};Database=postgres" + }, "api": { "type": "project.v0", "path": "../PostgresEndToEnd.ApiService/PostgresEndToEnd.ApiService.csproj", @@ -184,7 +207,8 @@ "ConnectionStrings__db7": "{db7.connectionString}", "ConnectionStrings__db8": "{db8.connectionString}", "ConnectionStrings__db9": "{db9.connectionString}", - "ConnectionStrings__db10": "{db10.connectionString}" + "ConnectionStrings__db10": "{db10.connectionString}", + "ConnectionStrings__postgres": "{postgres.connectionString}" }, "bindings": { "http": { @@ -290,6 +314,21 @@ } } } + }, + "pg11-password": { + "type": "parameter.v0", + "value": "{pg11-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22 + } + } + } + } } } } \ No newline at end of file diff --git a/spelling.dic b/spelling.dic index 9c21905221..5c6a474ea4 100644 --- a/spelling.dic +++ b/spelling.dic @@ -62,3 +62,4 @@ upsert uris urls kubernetes +Pgweb diff --git a/src/Aspire.Hosting.PostgreSQL/PgWebContainerResource.cs b/src/Aspire.Hosting.PostgreSQL/PgWebContainerResource.cs new file mode 100644 index 0000000000..3e2e61f46d --- /dev/null +++ b/src/Aspire.Hosting.PostgreSQL/PgWebContainerResource.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.ApplicationModel; + +namespace Aspire.Hosting.Postgres; + +/// +/// Represents a container resource for pgweb. +/// +/// The name of the container resource. +public sealed class PgWebContainerResource(string name) : ContainerResource(name) +{ + internal const string PrimaryEndpointName = "http"; + + private EndpointReference? _primaryEndpoint; + + /// + /// Gets the primary endpoint for the pgweb. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); +} diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index 6a328b82ed..38b88bbb0b 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -72,7 +72,7 @@ public static IResourceBuilder AddDatabase(this IResou } /// - /// Adds a pgAdmin 4 administration and development platform for PostgreSQL to the application model. This version the package defaults to the 8.3 tag of the dpage/pgadmin4 container image + /// Adds a pgAdmin 4 administration and development platform for PostgreSQL to the application model. This version the package defaults to the 8.8 tag of the dpage/pgadmin4 container image /// /// The PostgreSQL server resource builder. /// Callback to configure PgAdmin container resource. @@ -170,6 +170,103 @@ public static IResourceBuilder WithHostPort(this IReso }); } + /// + /// Configures the host port that the pgweb resource is exposed on instead of using randomly assigned port. + /// + /// The resource builder for pgweb. + /// The port to bind on the host. If is used random port will be assigned. + /// The resource builder for pgweb. + public static IResourceBuilder WithHostPort(this IResourceBuilder builder, int? port) + { + return builder.WithEndpoint("http", endpoint => + { + endpoint.Port = port; + }); + } + + /// + /// Adds an administration and development platform for PostgreSQL to the application model using pgweb. + /// + /// The Postgres server resource builder. + /// Configuration callback for pgweb container resource. + /// The name of the container (Optional). + /// + /// Use in application host with a Postgres resource + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var postgres = builder.AddPostgres("postgres") + /// .WithPgWeb(); + /// var db = postgres.AddDatabase("db"); + /// + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(db); + /// + /// builder.Build().Run(); + /// + /// + /// + /// This version the package defaults to the 0.15.0 tag of the sosedoff/pgweb container image. + /// + /// A reference to the . + public static IResourceBuilder WithPgWeb(this IResourceBuilder builder, Action>? configureContainer = null, string? containerName = null) + { + + if (builder.ApplicationBuilder.Resources.OfType().SingleOrDefault() is { } existingPgWebResource) + { + var builderForExistingResource = builder.ApplicationBuilder.CreateResourceBuilder(existingPgWebResource); + configureContainer?.Invoke(builderForExistingResource); + return builder; + } + else + { + containerName ??= $"{builder.Resource.Name}-pgweb"; + var dir = Directory.CreateTempSubdirectory().FullName; + var pgwebContainer = new PgWebContainerResource(containerName); + var pgwebContainerBuilder = builder.ApplicationBuilder.AddResource(pgwebContainer) + .WithImage(PostgresContainerImageTags.PgWebImage, PostgresContainerImageTags.PgWebTag) + .WithImageRegistry(PostgresContainerImageTags.PgWebRegistry) + .WithHttpEndpoint(targetPort: 8081, name: "http") + .WithBindMount(dir, "/.pgweb/bookmarks") + .WithArgs("--bookmarks-dir=/.pgweb/bookmarks") + .WithArgs("--sessions") + .ExcludeFromManifest(); + + configureContainer?.Invoke(pgwebContainerBuilder); + + builder.ApplicationBuilder.Eventing.Subscribe(async (e, ct) => + { + var adminResource = builder.ApplicationBuilder.Resources.OfType().Single(); + var serverFileMount = adminResource.Annotations.OfType().Single(v => v.Target == "/.pgweb/bookmarks"); + var postgresInstances = builder.ApplicationBuilder.Resources.OfType(); + + if (!Directory.Exists(serverFileMount.Source!)) + { + Directory.CreateDirectory(serverFileMount.Source!); + } + + foreach (var postgresDatabase in postgresInstances) + { + var user = postgresDatabase.Parent.UserNameParameter?.Value ?? "postgres"; + + var fileContent = $""" + host = "{postgresDatabase.Parent.PrimaryEndpoint.ContainerHost}" + port = {postgresDatabase.Parent.PrimaryEndpoint.Port} + user = "{user}" + password = "{postgresDatabase.Parent.PasswordParameter.Value}" + database = "{postgresDatabase.DatabaseName}" + sslmode = "disable" + """; + + var filePath = Path.Combine(serverFileMount.Source!, $"{postgresDatabase.Name}.toml"); + await File.WriteAllTextAsync(filePath, fileContent, ct).ConfigureAwait(false); + } + }); + + return builder; + } + } + private static void SetPgAdminEnvironmentVariables(EnvironmentCallbackContext context) { // Disables pgAdmin authentication. diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs b/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs index 08b9da314e..796aa53a8f 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs @@ -11,4 +11,7 @@ internal static class PostgresContainerImageTags public const string PgAdminRegistry = "docker.io"; public const string PgAdminImage = "dpage/pgadmin4"; public const string PgAdminTag = "8.8"; + public const string PgWebRegistry = "docker.io"; + public const string PgWebImage = "sosedoff/pgweb"; + public const string PgWebTag = "0.15.0"; } diff --git a/src/Aspire.Hosting.PostgreSQL/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.PostgreSQL/PublicAPI.Unshipped.txt index 7dc5c58110..1c3f8f6ea8 100644 --- a/src/Aspire.Hosting.PostgreSQL/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting.PostgreSQL/PublicAPI.Unshipped.txt @@ -1 +1,7 @@ #nullable enable +Aspire.Hosting.Postgres.PgWebContainerResource +Aspire.Hosting.Postgres.PgWebContainerResource.PgWebContainerResource(string! name) -> void +Aspire.Hosting.Postgres.PgWebContainerResource.PrimaryEndpoint.get -> Aspire.Hosting.ApplicationModel.EndpointReference! +static Aspire.Hosting.PostgresBuilderExtensions.WithHostPort(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, int? port) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.PostgresBuilderExtensions.WithPgWeb(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, System.Action!>? configureContainer = null, string? containerName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! + diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs index c47a068507..52d4760344 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs @@ -376,6 +376,48 @@ public void WithPgAdminAddsContainer() Assert.Equal("/pgadmin4/servers.json", volume.Target); } + [Fact] + public void WithPgWebAddsWithPgWebResource() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddPostgres("mypostgres1").WithPgWeb(); + builder.AddPostgres("mypostgres2").WithPgWeb(); + + Assert.Single(builder.Resources.OfType()); + } + + [Fact] + public void WithPgWebSupportsChangingContainerImageValues() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddPostgres("mypostgres").WithPgWeb(c => + { + c.WithImageRegistry("example.mycompany.com"); + c.WithImage("customrediscommander"); + c.WithImageTag("someothertag"); + }); + + var resource = Assert.Single(builder.Resources.OfType()); + var containerAnnotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal("example.mycompany.com", containerAnnotation.Registry); + Assert.Equal("customrediscommander", containerAnnotation.Image); + Assert.Equal("someothertag", containerAnnotation.Tag); + } + + [Fact] + public void WithRedisInsightSupportsChangingHostPort() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddPostgres("mypostgres").WithPgWeb(c => + { + c.WithHostPort(1000); + }); + + var resource = Assert.Single(builder.Resources.OfType()); + var endpoint = Assert.Single(resource.Annotations.OfType()); + Assert.Equal(1000, endpoint.Port); + } + [Fact] public void WithPgAdminWithCallbackMutatesImage() { @@ -444,6 +486,62 @@ public async Task WithPostgresProducesValidServersJsonFile(string containerHost) Assert.Equal($"echo '{pg2.Resource.PasswordParameter.Value}'", servers.GetProperty("2").GetProperty("PasswordExecCommand").GetString()); } + [Theory] + [InlineData("host.docker.internal")] + [InlineData("host.containers.internal")] + public async Task WithPgwebProducesValidBookmarkFiles(string containerHost) + { + var builder = DistributedApplication.CreateBuilder(); + var pg1 = builder.AddPostgres("mypostgres1").WithPgWeb(pga => pga.WithHostPort(8081)); + var pg2 = builder.AddPostgres("mypostgres2").WithPgWeb(pga => pga.WithHostPort(8081)); + + // Add fake allocated endpoints. + pg1.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001, containerHost)); + pg2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002, "host2")); + + var db1 = pg1.AddDatabase("db1"); + var db2 = pg2.AddDatabase("db2"); + + var pgadmin = builder.Resources.Single(r => r.Name.EndsWith("-pgweb")); + var volume = pgadmin.Annotations.OfType().Single(); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + await builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); + + var bookMarkFiles = Directory + .GetFiles(volume.Source!); + + Assert.Collection(bookMarkFiles, + filePath => + { + Assert.Equal(".toml", Path.GetExtension(filePath)) ; + }, + filePath => + { + Assert.Equal(".toml", Path.GetExtension(filePath)); + }); + + var bookmarkFilesContent = new List(); + + foreach (var filePath in bookMarkFiles) + { + bookmarkFilesContent.Add(File.ReadAllText(filePath)); + } + + Assert.NotEmpty(bookmarkFilesContent); + Assert.Collection(bookmarkFilesContent, + content => + { + Assert.Equal(CreatePgWebBookmarkfileContent(db1.Resource), content); + }, + content => + { + Assert.Equal(CreatePgWebBookmarkfileContent(db2.Resource), content); + }); + } + [Fact] public void ThrowsWithIdenticalChildResourceNames() { @@ -501,4 +599,20 @@ public void CanAddDatabasesWithTheSameNameOnMultipleServers() Assert.Equal("{postgres1.connectionString};Database=imports", db1.Resource.ConnectionStringExpression.ValueExpression); Assert.Equal("{postgres2.connectionString};Database=imports", db2.Resource.ConnectionStringExpression.ValueExpression); } + + private static string CreatePgWebBookmarkfileContent(PostgresDatabaseResource postgresDatabase) + { + var user = postgresDatabase.Parent.UserNameParameter?.Value ?? "postgres"; + + var fileContent = $""" + host = "{postgresDatabase.Parent.PrimaryEndpoint.ContainerHost}" + port = {postgresDatabase.Parent.PrimaryEndpoint.Port} + user = "{user}" + password = "{postgresDatabase.Parent.PasswordParameter.Value}" + database = "{postgresDatabase.DatabaseName}" + sslmode = "disable" + """; + + return fileContent; + } } diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs index d17ba64090..b433815ac1 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs @@ -3,6 +3,7 @@ using System.Data; using Aspire.Components.Common.Tests; +using Aspire.Hosting.Postgres; using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -62,6 +63,54 @@ await pipeline.ExecuteAsync(async token => }, cts.Token); } + [Fact(Skip = "https://github.com/dotnet/aspire/issues/5325")] + [RequiresDocker] + public async Task VerifyWithPgWeb() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + + var dbName = "postgres"; + var pg = builder.AddPostgres("pg1").WithPgWeb().AddDatabase(dbName); + + builder.Services.AddHttpClient(); + + using var app = builder.Build(); + + await app.StartAsync(); + + var factory = app.Services.GetRequiredService(); + var client = factory.CreateClient(); + + var pgWeb = builder.Resources.OfType().SingleOrDefault(); + + var pgWebEndpoint = pgWeb!.PrimaryEndpoint; + + var testConnectionApiUrl = $"{pgWebEndpoint.Scheme}://{pgWebEndpoint.Host}:{pgWebEndpoint.Port}/api/connect"; + + var pipeline = new ResiliencePipelineBuilder().AddRetry(new Polly.Retry.RetryStrategyOptions + { + Delay = TimeSpan.FromSeconds(2), + MaxRetryAttempts = 10, + }).Build(); + + await pipeline.ExecuteAsync(async (ct) => + { + var httpContent = new MultipartFormDataContent + { + { new StringContent(dbName), "bookmark_id" } + }; + + client.DefaultRequestHeaders.Add("x-session-id", Guid.NewGuid().ToString()); + + var response = await client.PostAsync(testConnectionApiUrl, httpContent, ct) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + }, cts.Token).ConfigureAwait(false); + } + [Theory] [InlineData(true)] [InlineData(false)]