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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ jobs:
src/AndreGoepel.AppFoundation.ServiceDefaults/AndreGoepel.AppFoundation.ServiceDefaults.csproj \
src/AndreGoepel.AppFoundation.MailService/AndreGoepel.AppFoundation.MailService.csproj \
src/AndreGoepel.AppFoundation/AndreGoepel.AppFoundation.csproj \
src/AndreGoepel.AppFoundation.Hosting/AndreGoepel.AppFoundation.Hosting.csproj; do
src/AndreGoepel.AppFoundation.Hosting/AndreGoepel.AppFoundation.Hosting.csproj \
src/AndreGoepel.AppFoundation.Aspire/AndreGoepel.AppFoundation.Aspire.csproj; do
dotnet pack "$proj" -c Release -p:Version=$VERSION -p:RestoreLockedMode=true -o nupkgs
done

Expand Down
2 changes: 2 additions & 0 deletions AndreGoepel.AppFoundation.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
</Folder>
<Folder Name="/src/">
<Project Path="src/AndreGoepel.AppFoundation/AndreGoepel.AppFoundation.csproj" />
<Project Path="src/AndreGoepel.AppFoundation.Aspire/AndreGoepel.AppFoundation.Aspire.csproj" />
<Project Path="src/AndreGoepel.AppFoundation.Core/AndreGoepel.AppFoundation.Core.csproj" />
<Project Path="src/AndreGoepel.AppFoundation.Hosting/AndreGoepel.AppFoundation.Hosting.csproj" />
<Project Path="src/AndreGoepel.AppFoundation.MailService/AndreGoepel.AppFoundation.MailService.csproj" />
<Project Path="src/AndreGoepel.AppFoundation.ServiceDefaults/AndreGoepel.AppFoundation.ServiceDefaults.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/AndreGoepel.AppFoundation.Tests/AndreGoepel.AppFoundation.Tests.csproj" />
<Project Path="tests/AndreGoepel.AppFoundation.Aspire.Tests/AndreGoepel.AppFoundation.Aspire.Tests.csproj" />
<Project Path="tests/AndreGoepel.AppFoundation.MailService.Tests/AndreGoepel.AppFoundation.MailService.Tests.csproj" />
<Project Path="tests/AndreGoepel.AppFoundation.E2ETests/AndreGoepel.AppFoundation.E2ETests.csproj" />
</Folder>
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<PackageVersion Include="AngleSharp" Version="1.5.2" />
</ItemGroup>
<ItemGroup Label="Aspire">
<PackageVersion Include="Aspire.Hosting" Version="13.4.6" />
<PackageVersion Include="Aspire.Hosting.Docker" Version="13.4.6" />
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.4.6" />
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.4.6" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,11 @@

<ItemGroup>
<ProjectReference Include="..\AndreGoepel.AppFoundation.Sample\AndreGoepel.AppFoundation.Sample.csproj" />
<!-- A helper class library, not an orchestrated app resource — IsAspireProjectResource="false"
stops the Aspire SDK from treating it as one (ASPIRE004). -->
<ProjectReference
Include="..\..\src\AndreGoepel.AppFoundation.Aspire\AndreGoepel.AppFoundation.Aspire.csproj"
IsAspireProjectResource="false"
/>
</ItemGroup>
</Project>
22 changes: 9 additions & 13 deletions samples/AndreGoepel.AppFoundation.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
using Microsoft.Extensions.Configuration;
using AndreGoepel.AppFoundation.Aspire;

var builder = DistributedApplication.CreateBuilder(args);

// A PostgreSQL container with a persistent volume so setup and accounts survive restarts.
// The E2E suite passes E2E=true to skip the volume: tests need a throwaway database, and
// The E2E suite passes E2E=true to skip the data volume: tests need a throwaway database, and
// sharing the developer's volume would leak their local admin account into the test run.
var postgres = builder.AddPostgres("postgres");
if (!builder.Configuration.GetValue<bool>("E2E"))
{
postgres.WithDataVolume();
}
var isE2E = string.Equals(builder.Configuration["E2E"], "true", StringComparison.OrdinalIgnoreCase);

// The database resource name is the connection-string name the foundation reads by default
// (AppFoundationOptions.DatabaseConnectionName == "appfoundation-database").
var database = postgres.AddDatabase("appfoundation-database", "appfoundation");
var (_, database) = builder.AddStandardPostgres(
isE2E,
databaseResourceName: "appfoundation-database",
databaseName: "appfoundation"
);

// MailHog captures outgoing development email locally: an SMTP server on 1025 and a web UI on
// 8025 to read what was "sent". Nothing leaves the machine, and no real mail account is needed.
var mailhog = builder
.AddContainer("mailhog", "mailhog/mailhog", "v1.0.1")
.WithEndpoint(name: "smtp", port: 1025, targetPort: 1025)
.WithHttpEndpoint(name: "http", port: 8025, targetPort: 8025);
var mailhog = builder.AddStandardMailHog();

// The sample web app, wired to the database and started only once it is ready. Email settings
// are database-only (no configuration fallback) — the E2E fixture configures MailHog through the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<PropertyGroup>
<PackageId>AndreGoepel.AppFoundation.Aspire</PackageId>
<Version>1.8.0</Version>
<Description>Standardized .NET Aspire AppHost building blocks (MailHog, Postgres) for AndreGoepel.AppFoundation hosts.</Description>
<PackageTags>appfoundation;aspire;apphost;mailhog;postgresql</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<IsPackable>true</IsPackable>
</PropertyGroup>

<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" />
</ItemGroup>
</Project>
126 changes: 126 additions & 0 deletions src/AndreGoepel.AppFoundation.Aspire/AppFoundationAspireExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;

namespace AndreGoepel.AppFoundation.Aspire;

/// <summary>
/// Standardized AppHost building blocks for the AndreGoepel ecosystem, extracted from the
/// near-identical MailHog + Postgres + <c>EmailSender__*</c> blocks that <c>andregoepel-dev</c>,
/// <c>finance-app</c>, <c>customer-portal</c>, and this repo's own sample AppHost each hand-rolled.
/// AppHost projects are dev-only orchestration — never part of the runtime dependency chain — so a
/// sibling package referenced only by <c>*.AppHost</c> projects stays architecturally consistent
/// with the rest of the ecosystem's AppHosts already referencing
/// <c>AndreGoepel.AppFoundation.ServiceDefaults</c> from this same repo.
/// </summary>
public static class AppFoundationAspireExtensions
{
/// <summary>
/// Adds a MailHog container that captures outgoing development email locally: an SMTP
/// endpoint and an HTTP endpoint for its web UI / API.
/// </summary>
/// <param name="builder">The AppHost's distributed application builder.</param>
/// <param name="name">Resource name of the container.</param>
/// <param name="tag">
/// Image tag. Defaults to the pinned <c>v1.0.1</c> for reproducible dev environments; pass
/// <c>null</c> to use the image's default (untagged/latest) instead.
/// </param>
/// <param name="smtpPort">Host port MailHog's SMTP endpoint is exposed on.</param>
/// <param name="httpPort">Host port MailHog's HTTP (web UI / API) endpoint is exposed on.</param>
/// <returns>
/// The container resource, so callers can still reference its endpoints (e.g.
/// <c>mailhog.GetEndpoint("smtp")</c>) to wire up their own app's <c>EmailSender__*</c>
/// environment variables — those stay app-specific (sender name, credentials) and are not
/// standardized here.
/// </returns>
/// <remarks>
/// The HTTP endpoint is always named <c>"http"</c> — the canonical name across the ecosystem
/// (matching <c>AndreGoepel.Testing.E2E</c>'s <c>E2EAppFixtureOptions.MailHogEndpointName</c>
/// default), not the <c>"web"</c> name <c>andregoepel-dev</c>/<c>finance-app</c> used historically.
/// </remarks>
public static IResourceBuilder<ContainerResource> AddStandardMailHog(
this IDistributedApplicationBuilder builder,
string name = "mailhog",
string? tag = "v1.0.1",
int smtpPort = 1025,
int httpPort = 8025
)
{
var container = tag is null
? builder.AddContainer(name, "mailhog/mailhog")
: builder.AddContainer(name, "mailhog/mailhog", tag);

return container
.WithEndpoint(name: "smtp", port: smtpPort, targetPort: 1025)
.WithHttpEndpoint(name: "http", port: httpPort, targetPort: 8025);
}

/// <summary>
/// Adds a Postgres server and database, applying the ecosystem's E2E convention: outside E2E
/// the server keeps its data across restarts on a fixed host port; under E2E every one of
/// those is dropped so each run gets a fresh, throwaway, volume-less database on a dynamic
/// port instead of a developer's persistent local data.
/// </summary>
/// <param name="builder">The AppHost's distributed application builder.</param>
/// <param name="isE2E">
/// Whether the AppHost is running under the E2E test harness (typically
/// <c>string.Equals(builder.Configuration["E2E"], "true", StringComparison.OrdinalIgnoreCase)</c>).
/// <c>true</c> skips <c>WithDataVolume</c>, the persistent container lifetime, and the fixed
/// host port.
/// </param>
/// <param name="serverName">Resource name of the Postgres server.</param>
/// <param name="databaseResourceName">
/// Resource name of the logical database — the connection-string name a host reads (e.g.
/// <c>AppFoundationOptions.DatabaseConnectionName</c>, which defaults to
/// <c>"appfoundation-database"</c>).
/// </param>
/// <param name="databaseName">
/// Actual database name created on the server. Defaults to <paramref name="databaseResourceName"/>
/// when <c>null</c>.
/// </param>
/// <param name="userName">Optional explicit database user parameter; auto-generated when omitted.</param>
/// <param name="password">Optional explicit database password parameter; auto-generated when omitted.</param>
/// <param name="hostPort">
/// Fixed host port used outside E2E. Pass <c>null</c> to leave the port dynamic even outside
/// E2E. Has no effect when <paramref name="isE2E"/> is <c>true</c>.
/// </param>
/// <param name="dataVolumeName">
/// Optional explicit data volume name, forwarded to <c>WithDataVolume</c>. Auto-generated when
/// <c>null</c>. Has no effect when <paramref name="isE2E"/> is <c>true</c>.
/// </param>
/// <returns>Both the server and database resources — the common case only needs <c>Database</c>.</returns>
public static StandardPostgres AddStandardPostgres(
this IDistributedApplicationBuilder builder,
bool isE2E,
string serverName = "postgres-server",
string databaseResourceName = "appfoundation-database",
string? databaseName = null,
IResourceBuilder<ParameterResource>? userName = null,
IResourceBuilder<ParameterResource>? password = null,
int? hostPort = 5432,
string? dataVolumeName = null
)
{
var server =
userName is not null && password is not null
? builder.AddPostgres(serverName, userName, password)
: builder.AddPostgres(serverName);

if (!isE2E)
{
server = server.WithLifetime(ContainerLifetime.Persistent);

if (hostPort is { } port)
{
server = server.WithHostPort(port);
}

server = server.WithDataVolume(dataVolumeName);
}

var database = databaseName is null
? server.AddDatabase(databaseResourceName)
: server.AddDatabase(databaseResourceName, databaseName);

return new StandardPostgres(server, database);
}
}
17 changes: 17 additions & 0 deletions src/AndreGoepel.AppFoundation.Aspire/StandardPostgres.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Aspire.Hosting.ApplicationModel;

namespace AndreGoepel.AppFoundation.Aspire;

/// <summary>
/// The two resources <see cref="AppFoundationAspireExtensions.AddStandardPostgres"/> creates.
/// Deconstructs positionally, so the common case that only needs the database stays a
/// one-liner: <c>var (_, appDb) = builder.AddStandardPostgres(isE2E);</c>
/// </summary>
/// <param name="Server">The Postgres server container resource.</param>
/// <param name="Database">
/// The logical database resource — what a host app passes to <c>WithReference</c>.
/// </param>
public sealed record StandardPostgres(
IResourceBuilder<PostgresServerResource> Server,
IResourceBuilder<PostgresDatabaseResource> Database
);
Loading