diff --git a/Aspire.sln b/Aspire.sln
index d09f21713aa..58d79b1ba31 100644
--- a/Aspire.sln
+++ b/Aspire.sln
@@ -152,6 +152,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.RabbitMQ.Client", "s
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.RabbitMQ.Client.Tests", "tests\Aspire.RabbitMQ.Client.Tests\Aspire.RabbitMQ.Client.Tests.csproj", "{165411FE-755E-4869-A756-F87F455860AC}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.AWS", "src\Aspire.Hosting.AWS\Aspire.Hosting.AWS.csproj", "{7738E898-5D00-45C2-A498-EA7C56E31A27}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.AWS", "src\Components\Aspire.AWS\Aspire.AWS.csproj", "{B2B9CCC6-3DC5-45AA-BBC0-A4258286828B}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "aws", "aws", "{4FE4E999-1D3B-466F-802E-0389EF07EC8E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.AWS.Provisioning", "src\Aspire.Hosting.AWS.Provisioning\Aspire.Hosting.AWS.Provisioning.csproj", "{A2A42422-6FC1-4B3C-A495-DAC86829A381}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aws.AppHost", "samples\aws\Aws.AppHost\Aws.AppHost.csproj", "{F851D31E-1D3A-40DA-B11D-1138179B866D}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aws.ServiceDefaults", "samples\aws\Aws.ServiceDefaults\Aws.ServiceDefaults.csproj", "{8611076F-CA70-4980-808F-BB9AF49BFCFB}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aws.UserService", "samples\aws\Aws.UserService\Aws.UserService.csproj", "{9A7CADAA-1B13-40FE-90FA-2B34491C9B55}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.MySqlConnector", "src\Components\Aspire.MySqlConnector\Aspire.MySqlConnector.csproj", "{CA283D7F-EB95-4353-B196-C409965D2B42}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.MySqlConnector.Tests", "tests\Aspire.MySqlConnector.Tests\Aspire.MySqlConnector.Tests.csproj", "{C8079F06-304F-49B1-A0C1-45AA3782A923}"
@@ -412,6 +425,30 @@ Global
{165411FE-755E-4869-A756-F87F455860AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{165411FE-755E-4869-A756-F87F455860AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{165411FE-755E-4869-A756-F87F455860AC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7738E898-5D00-45C2-A498-EA7C56E31A27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7738E898-5D00-45C2-A498-EA7C56E31A27}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7738E898-5D00-45C2-A498-EA7C56E31A27}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7738E898-5D00-45C2-A498-EA7C56E31A27}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2B9CCC6-3DC5-45AA-BBC0-A4258286828B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2B9CCC6-3DC5-45AA-BBC0-A4258286828B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2B9CCC6-3DC5-45AA-BBC0-A4258286828B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2B9CCC6-3DC5-45AA-BBC0-A4258286828B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A2A42422-6FC1-4B3C-A495-DAC86829A381}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A2A42422-6FC1-4B3C-A495-DAC86829A381}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A2A42422-6FC1-4B3C-A495-DAC86829A381}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A2A42422-6FC1-4B3C-A495-DAC86829A381}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F851D31E-1D3A-40DA-B11D-1138179B866D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F851D31E-1D3A-40DA-B11D-1138179B866D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F851D31E-1D3A-40DA-B11D-1138179B866D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F851D31E-1D3A-40DA-B11D-1138179B866D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8611076F-CA70-4980-808F-BB9AF49BFCFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8611076F-CA70-4980-808F-BB9AF49BFCFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8611076F-CA70-4980-808F-BB9AF49BFCFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8611076F-CA70-4980-808F-BB9AF49BFCFB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9A7CADAA-1B13-40FE-90FA-2B34491C9B55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9A7CADAA-1B13-40FE-90FA-2B34491C9B55}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9A7CADAA-1B13-40FE-90FA-2B34491C9B55}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9A7CADAA-1B13-40FE-90FA-2B34491C9B55}.Release|Any CPU.Build.0 = Release|Any CPU
{CA283D7F-EB95-4353-B196-C409965D2B42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CA283D7F-EB95-4353-B196-C409965D2B42}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CA283D7F-EB95-4353-B196-C409965D2B42}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -495,6 +532,13 @@ Global
{A84C4EE3-2601-4804-BCDC-E9948E164A22} = {A68BA1A5-1604-433D-9778-DC0199831C2A}
{4D8A92AB-4E77-4965-AD8E-8E206DCE66A4} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2}
{165411FE-755E-4869-A756-F87F455860AC} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60}
+ {7738E898-5D00-45C2-A498-EA7C56E31A27} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47}
+ {B2B9CCC6-3DC5-45AA-BBC0-A4258286828B} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2}
+ {4FE4E999-1D3B-466F-802E-0389EF07EC8E} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0}
+ {A2A42422-6FC1-4B3C-A495-DAC86829A381} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47}
+ {F851D31E-1D3A-40DA-B11D-1138179B866D} = {4FE4E999-1D3B-466F-802E-0389EF07EC8E}
+ {8611076F-CA70-4980-808F-BB9AF49BFCFB} = {4FE4E999-1D3B-466F-802E-0389EF07EC8E}
+ {9A7CADAA-1B13-40FE-90FA-2B34491C9B55} = {4FE4E999-1D3B-466F-802E-0389EF07EC8E}
{CA283D7F-EB95-4353-B196-C409965D2B42} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2}
{C8079F06-304F-49B1-A0C1-45AA3782A923} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60}
{DCF2D47A-921A-4900-B5B2-CF97B3531CE8} = {975F6F41-B455-451D-A312-098DE4A167B6}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6827245d7eb..fc53522a337 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,6 +13,13 @@
+
+
+
+
+
+
+
@@ -79,6 +86,9 @@
+
+
+
diff --git a/samples/aws/Aws.AppHost/Aws.AppHost.csproj b/samples/aws/Aws.AppHost/Aws.AppHost.csproj
new file mode 100644
index 00000000000..0e3d5d5a242
--- /dev/null
+++ b/samples/aws/Aws.AppHost/Aws.AppHost.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net8.0
+ 2bf0840a-0dfc-82aa-1586-def9b3b31da0
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/aws/Aws.AppHost/Directory.Build.props b/samples/aws/Aws.AppHost/Directory.Build.props
new file mode 100644
index 00000000000..b9b39c05e81
--- /dev/null
+++ b/samples/aws/Aws.AppHost/Directory.Build.props
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/samples/aws/Aws.AppHost/Directory.Build.targets b/samples/aws/Aws.AppHost/Directory.Build.targets
new file mode 100644
index 00000000000..281a6cb2fc7
--- /dev/null
+++ b/samples/aws/Aws.AppHost/Directory.Build.targets
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/samples/aws/Aws.AppHost/Program.cs b/samples/aws/Aws.AppHost/Program.cs
new file mode 100644
index 00000000000..05399d785de
--- /dev/null
+++ b/samples/aws/Aws.AppHost/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.
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+builder.AddAwsProvisioning();
+var awsS3Bucket = builder.AddAwsS3Bucket("ProfilePicturesBucket");
+var awsSnsTopic = builder.AddAwsSnsTopic("ProfilesTopic");
+var awsSqsQueue = builder.AddAwsSqsQueue("ProfilesQueue");
+
+builder.AddProject("aws.userservice")
+ .WithReference(awsS3Bucket)
+ .WithReference(awsSnsTopic)
+ .WithReference(awsSqsQueue);
+
+builder.Build().Run();
diff --git a/samples/aws/Aws.AppHost/Properties/launchSettings.json b/samples/aws/Aws.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000000..325ab1f4aa3
--- /dev/null
+++ b/samples/aws/Aws.AppHost/Properties/launchSettings.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15295",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16260",
+ }
+ }
+ }
+}
diff --git a/samples/aws/Aws.AppHost/appsettings.Development.json b/samples/aws/Aws.AppHost/appsettings.Development.json
new file mode 100644
index 00000000000..0c208ae9181
--- /dev/null
+++ b/samples/aws/Aws.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/samples/aws/Aws.AppHost/appsettings.json b/samples/aws/Aws.AppHost/appsettings.json
new file mode 100644
index 00000000000..b7eca029ef2
--- /dev/null
+++ b/samples/aws/Aws.AppHost/appsettings.json
@@ -0,0 +1,36 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ },
+ "LocalStack": {
+ "UseLocalStack": true,
+ "Session": {
+ "RegionName": "eu-central-1"
+ }
+ },
+ "AWS": {
+ "Profile": "personal",
+ "Region": "eu-central-1",
+ "StackName": "AspireAwsSampleStack",
+ "S3": {
+ "ProfilePicturesBucket": {
+ "AccessControl": "PublicRead"
+ }
+ },
+ "SNS": {
+ "ProfilesTopic": {
+ "Subscriptions": [ "ProfilesQueue" ]
+ }
+ },
+ "SQS": {
+ "ProfilesQueue": {
+ "MessageRetentionPeriod": 600,
+ "VisibilityTimeout": 600
+ }
+ }
+ }
+}
diff --git a/samples/aws/Aws.AppHost/aspire-manifest.json b/samples/aws/Aws.AppHost/aspire-manifest.json
new file mode 100644
index 00000000000..29ee5bbd817
--- /dev/null
+++ b/samples/aws/Aws.AppHost/aspire-manifest.json
@@ -0,0 +1,24 @@
+{
+ "resources": {
+ "aws.userservice": {
+ "type": "project.v0",
+ "path": "..\\Aws.UserService\\Aws.UserService.csproj",
+ "env": {
+ "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
+ "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true"
+ },
+ "bindings": {
+ "http": {
+ "scheme": "http",
+ "protocol": "tcp",
+ "transport": "http"
+ },
+ "https": {
+ "scheme": "https",
+ "protocol": "tcp",
+ "transport": "http"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/aws/Aws.ServiceDefaults/Aws.ServiceDefaults.csproj b/samples/aws/Aws.ServiceDefaults/Aws.ServiceDefaults.csproj
new file mode 100644
index 00000000000..f7e46bf1952
--- /dev/null
+++ b/samples/aws/Aws.ServiceDefaults/Aws.ServiceDefaults.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Library
+ net8.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/aws/Aws.ServiceDefaults/Extensions.cs b/samples/aws/Aws.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000000..c59308d50da
--- /dev/null
+++ b/samples/aws/Aws.ServiceDefaults/Extensions.cs
@@ -0,0 +1,119 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry.Logs;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Microsoft.Extensions.Hosting;
+
+public static class Extensions
+{
+ public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
+ {
+ 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.UseServiceDiscovery();
+ });
+
+ return builder;
+ }
+
+ public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddRuntimeInstrumentation()
+ .AddBuiltInMeters();
+ })
+ .WithTracing(tracing =>
+ {
+ if (builder.Environment.IsDevelopment())
+ {
+ // We want to view all traces in development
+ tracing.SetSampler(new AlwaysOnSampler());
+ }
+
+ tracing.AddAspNetCoreInstrumentation()
+ .AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder)
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.Configure(logging => logging.AddOtlpExporter());
+ builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter());
+ builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter());
+ }
+
+ // Uncomment the following lines to enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package)
+ // builder.Services.AddOpenTelemetry()
+ // .WithMetrics(metrics => metrics.AddPrometheusExporter());
+
+ // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.Exporter package)
+ // builder.Services.AddOpenTelemetry()
+ // .UseAzureMonitor();
+
+ return builder;
+ }
+
+ public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder)
+ {
+ 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)
+ {
+ // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package)
+ // app.MapPrometheusScrapingEndpoint();
+
+ // All health checks must pass for app to be considered ready to accept traffic after starting
+ app.MapHealthChecks("/health");
+
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks("/alive", new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+
+ return app;
+ }
+
+ private static MeterProviderBuilder AddBuiltInMeters(this MeterProviderBuilder meterProviderBuilder) =>
+ meterProviderBuilder.AddMeter(
+ "Microsoft.AspNetCore.Hosting",
+ "Microsoft.AspNetCore.Server.Kestrel",
+ "System.Net.Http");
+}
diff --git a/samples/aws/Aws.UserService/Aws.UserService.csproj b/samples/aws/Aws.UserService/Aws.UserService.csproj
new file mode 100644
index 00000000000..eb88e5bc880
--- /dev/null
+++ b/samples/aws/Aws.UserService/Aws.UserService.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+ $(NoWarn),CS8002
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/aws/Aws.UserService/Aws.UserService.http b/samples/aws/Aws.UserService/Aws.UserService.http
new file mode 100644
index 00000000000..988a7ed251c
--- /dev/null
+++ b/samples/aws/Aws.UserService/Aws.UserService.http
@@ -0,0 +1,6 @@
+@Aws.UserService_HostAddress = http://localhost:5099
+
+GET {{Aws.UserService_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/samples/aws/Aws.UserService/Contracts/IProfileService.cs b/samples/aws/Aws.UserService/Contracts/IProfileService.cs
new file mode 100644
index 00000000000..037fe7415e6
--- /dev/null
+++ b/samples/aws/Aws.UserService/Contracts/IProfileService.cs
@@ -0,0 +1,8 @@
+using Aws.UserService.Models;
+
+namespace Aws.UserService.Contracts;
+
+public interface IProfileService
+{
+ Task AddProfileAsync(Profile profile);
+}
\ No newline at end of file
diff --git a/samples/aws/Aws.UserService/Contracts/IS3UrlService.cs b/samples/aws/Aws.UserService/Contracts/IS3UrlService.cs
new file mode 100644
index 00000000000..0701685796e
--- /dev/null
+++ b/samples/aws/Aws.UserService/Contracts/IS3UrlService.cs
@@ -0,0 +1,8 @@
+using Amazon.S3;
+
+namespace Aws.UserService.Contracts;
+
+public interface IS3UrlService
+{
+ string GetS3Url(IAmazonS3 amazonS3, string bucket, string key);
+}
\ No newline at end of file
diff --git a/samples/aws/Aws.UserService/Models/Base64Images.cs b/samples/aws/Aws.UserService/Models/Base64Images.cs
new file mode 100644
index 00000000000..aaf4a64e2ec
--- /dev/null
+++ b/samples/aws/Aws.UserService/Models/Base64Images.cs
@@ -0,0 +1,10 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aws.UserService.Models;
+
+public static class Base64Images
+{
+ public const string Image1 =
+ "/9j/4QDeRXhpZgAASUkqAAgAAAAGABIBAwABAAAAAQAAABoBBQABAAAAVgAAABsBBQABAAAAXgAAACgBAwABAAAAAgAAABMCAwABAAAAAQAAAGmHBAABAAAAZgAAAAAAAABIAAAAAQAAAEgAAAABAAAABwAAkAcABAAAADAyMTABkQcABAAAAAECAwCGkgcAFgAAAMAAAAAAoAcABAAAADAxMDABoAMAAQAAAP//AAACoAQAAQAAADIAAAADoAQAAQAAADIAAAAAAAAAQVNDSUkAAABQaWNzdW0gSUQ6IDM4M//bAEMACAYGBwYFCAcHBwkJCAoMFA0MCwsMGRITDxQdGh8eHRocHCAkLicgIiwjHBwoNyksMDE0NDQfJzk9ODI8LjM0Mv/bAEMBCQkJDAsMGA0NGDIhHCEyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMv/CABEIADIAMgMBIgACEQEDEQH/xAAaAAADAQEBAQAAAAAAAAAAAAAAAwQFAQYC/8QAFwEBAQEBAAAAAAAAAAAAAAAAAQIAA//aAAwDAQACEAMQAAAB97NWqXPz9iLl1wptSVnOKyj3SVr25IIippaoq3yfAnojEIdNES3UTpmorM0xTwDM6GVoChICf//EABwQAAMAAwEBAQAAAAAAAAAAAAABAhAREiETQf/aAAgBAQABBQLRUlQXBcDnLKLKGjk2NlMoobx0bL9N+UPHfnQ6HRVHR0fU+x2dDodHbx+4Y8f/xAAWEQEBAQAAAAAAAAAAAAAAAAABIBH/2gAIAQMBAT8BIC9n/8QAGREAAgMBAAAAAAAAAAAAAAAAASAAAhEQ/9oACAECAQE/AbIbcCZMX//EABUQAQEAAAAAAAAAAAAAAAAAAEAh/9oACAEBAAY/AkVP/8QAHxAAAgMBAAEFAAAAAAAAAAAAAAEQETEhQSBRYXHR/9oACAEBAAE/IXZCS5CenVnAr0SC9FuDhV+aHm2Ms2s7v8LaDi9cNXrpk+cW1A4vuW9zDLvyOHQPS2E+CxmTMf/aAAwDAQACAAMAAAAQ3cLBoJk8CVMPCggA/8QAGBEBAQEBAQAAAAAAAAAAAAAAAQARECD/2gAIAQMBAT8QaMOklnAnm2y+Dz//xAAXEQEBAQEAAAAAAAAAAAAAAAABABEQ/9oACAECAQE/EAJZjDbbdYWbBHA4c//EAB8QAQADAQEAAgMBAAAAAAAAAAEAESExQVFhEHGBkf/aAAgBAQABPxAuER5BqqeZ2EMGSpRV8fxS2UqEDAWJkQ+JX4gBk1YIVA5svG4+pAbE2V+4MN25dQ6wTDF/tyxiNLEaG9Ty/uG1LEP7KYaS48j7JD3M6DCDTsC3Y78gSoU5SF2W42PEBt5DqjG+J9sLGv8AsSmvInoxNtnKXP/Z";
+}
diff --git a/samples/aws/Aws.UserService/Models/Profile.cs b/samples/aws/Aws.UserService/Models/Profile.cs
new file mode 100644
index 00000000000..e1ce8c77f7e
--- /dev/null
+++ b/samples/aws/Aws.UserService/Models/Profile.cs
@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+
+namespace Aws.UserService.Models;
+
+public class Profile : IValidatableObject
+{
+ public string? Id { get; set; }
+
+ public string? Name { get; set; }
+
+ public string? Email { get; set; }
+
+ public string? ImageName { get; set; }
+
+ public string? ImageUrl { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ var results = new List();
+
+ if (string.IsNullOrWhiteSpace(Name))
+ {
+ results.Add(new ValidationResult("Name is required", new[] { "Name" }));
+ }
+
+ if (string.IsNullOrWhiteSpace(Email))
+ {
+ results.Add(new ValidationResult("Email is required", new[] { "Email" }));
+ }
+
+ if (string.IsNullOrWhiteSpace(ImageName))
+ {
+ results.Add(new ValidationResult("ImageName is required", new[] { "ImageName" }));
+ }
+
+ return results;
+ }
+}
diff --git a/samples/aws/Aws.UserService/Program.cs b/samples/aws/Aws.UserService/Program.cs
new file mode 100644
index 00000000000..6da5f04ced0
--- /dev/null
+++ b/samples/aws/Aws.UserService/Program.cs
@@ -0,0 +1,47 @@
+using Amazon.S3;
+using Amazon.SimpleNotificationService;
+using Amazon.SQS;
+using Aws.UserService.Contracts;
+using Aws.UserService.Models;
+using Aws.UserService.Services;
+using LocalStack.Client.Extensions;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+// Add services to the container.
+// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+builder.Services.AddTransient();
+builder.Services.AddTransient();
+
+builder.Services.AddLocalStack(builder.Configuration);
+builder.Services.AddAwsService();
+builder.Services.AddAwsService();
+builder.Services.AddAwsService();
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+app.UseHttpsRedirection();
+
+app.MapPost("/profile", async (Profile profile, IProfileService profileService) =>
+ {
+ var profileId = await profileService.AddProfileAsync(profile);
+
+ return Results.Created($"/profile/{profileId}", profileId);
+ })
+ .WithName("PostProfile");
+
+app.Run();
diff --git a/samples/aws/Aws.UserService/Properties/launchSettings.json b/samples/aws/Aws.UserService/Properties/launchSettings.json
new file mode 100644
index 00000000000..f442cabc7a1
--- /dev/null
+++ b/samples/aws/Aws.UserService/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:45796",
+ "sslPort": 44384
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5099",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7026;http://localhost:5099",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/samples/aws/Aws.UserService/Services/S3UrlService.cs b/samples/aws/Aws.UserService/Services/S3UrlService.cs
new file mode 100644
index 00000000000..8e44b3ec545
--- /dev/null
+++ b/samples/aws/Aws.UserService/Services/S3UrlService.cs
@@ -0,0 +1,37 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Amazon.S3;
+using Aws.UserService.Contracts;
+using LocalStack.Client.Options;
+using Microsoft.Extensions.Options;
+
+namespace Aws.UserService.Services;
+
+public class S3UrlService : IS3UrlService
+{
+ private readonly LocalStackOptions _localStackOptions;
+
+ public S3UrlService(IOptions localStackOptions)
+ {
+ _localStackOptions = localStackOptions.Value;
+ }
+
+ public string GetS3Url(IAmazonS3 amazonS3, string bucket, string key)
+ {
+ if (_localStackOptions.UseLocalStack)
+ {
+ return $"http://localhost:4566/{bucket}/{key}";
+ }
+
+ var awsRegion = Environment.GetEnvironmentVariable("AWS_REGION") ?? Environment.GetEnvironmentVariable("AWS_DEFAULT_REGION");
+
+ if (string.IsNullOrWhiteSpace(awsRegion))
+ {
+ var amazonS3Config = (AmazonS3Config)amazonS3.Config;
+ awsRegion = amazonS3Config.RegionEndpoint.SystemName ?? "us-east-1";
+ }
+
+ return $"https://{bucket}.s3.{awsRegion}.amazonaws.com/{key}";
+ }
+}
diff --git a/samples/aws/Aws.UserService/Services/UserService.cs b/samples/aws/Aws.UserService/Services/UserService.cs
new file mode 100644
index 00000000000..f58d313e062
--- /dev/null
+++ b/samples/aws/Aws.UserService/Services/UserService.cs
@@ -0,0 +1,80 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Net;
+using System.Text.Json;
+using Amazon.S3;
+using Amazon.S3.Transfer;
+using Amazon.SimpleNotificationService;
+using Amazon.SimpleNotificationService.Model;
+using Aws.UserService.Contracts;
+using Aws.UserService.Models;
+
+namespace Aws.UserService.Services;
+
+public class ProfileService(
+ IS3UrlService s3UrlService,
+ IAmazonS3 amazonS3,
+ IAmazonSimpleNotificationService amazonSns,
+ ILogger logger,
+ IConfiguration configuration) : IProfileService
+{
+ private readonly string _bucketName = configuration.GetConnectionString("ProfilePicturesBucket") ??
+ throw new ArgumentNullException("ConnectionStrings__ProfilePicturesBucket");
+
+ private readonly string _topicArn = configuration.GetConnectionString("ProfilesTopic") ??
+ throw new ArgumentNullException("ConnectionStrings__ProfilesTopic");
+
+ public async Task AddProfileAsync(Profile profile)
+ {
+ logger.LogInformation("AddProfile called: BucketName: {BucketName}, TopicArn: {TopicArn}", _bucketName, _topicArn);
+
+ try
+ {
+ var bytes = Convert.FromBase64String(Base64Images.Image1!);
+
+ await using var ms = new MemoryStream(bytes);
+ var fileTransferUtility = new TransferUtility(amazonS3);
+ await fileTransferUtility.UploadAsync(ms, _bucketName, profile.ImageName);
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "Error uploading profile pic to S3");
+ throw;
+ }
+
+ var id = Guid.NewGuid().ToString();
+ var s3Url = s3UrlService.GetS3Url(amazonS3, _bucketName, profile.ImageName!);
+ var createdAt = DateTime.UtcNow;
+
+ logger.LogInformation("Profile pic uploaded to S3. Id: {Id}, S3Url: {S3Url}, CreatedAt: {CreatedAt}", id, s3Url, createdAt);
+
+ var createdProfile = new Profile()
+ {
+ Id = id,
+ Name = profile.Name,
+ Email = profile.Email,
+ ImageName = profile.ImageName,
+ ImageUrl = s3Url,
+ CreatedAt = createdAt,
+ };
+
+ var publishRequest = new PublishRequest()
+ {
+ TopicArn = _topicArn,
+ Message = JsonSerializer.Serialize(createdProfile),
+ };
+
+ var publishResponse = await amazonSns.PublishAsync(publishRequest);
+
+ logger.LogInformation("Published to SNS. Id: {Id}, HttpStatusCode: {HttpStatusCode}", id, publishResponse.HttpStatusCode);
+
+ if (publishResponse.HttpStatusCode != HttpStatusCode.OK)
+ {
+ logger.LogError("Error publishing to SNS. Id: {Id}", id);
+ throw new InvalidOperationException("Error publishing to SNS");
+ }
+
+ return profile;
+ }
+}
diff --git a/samples/aws/Aws.UserService/appsettings.Development.json b/samples/aws/Aws.UserService/appsettings.Development.json
new file mode 100644
index 00000000000..82da56d3be2
--- /dev/null
+++ b/samples/aws/Aws.UserService/appsettings.Development.json
@@ -0,0 +1,14 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "LocalStack": {
+ "UseLocalStack": true,
+ "Session": {
+ "RegionName": "eu-central-1"
+ }
+ }
+}
diff --git a/samples/aws/Aws.UserService/appsettings.json b/samples/aws/Aws.UserService/appsettings.json
new file mode 100644
index 00000000000..ca1dfe1afe0
--- /dev/null
+++ b/samples/aws/Aws.UserService/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "LocalStack": {
+ "UseLocalStack": false
+ }
+}
diff --git a/samples/aws/Aws.sln b/samples/aws/Aws.sln
new file mode 100644
index 00000000000..1b3165d9563
--- /dev/null
+++ b/samples/aws/Aws.sln
@@ -0,0 +1,37 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.9.34310.174
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aws.UserService", "Aws.UserService\Aws.UserService.csproj", "{451E69B4-7EF4-43F4-8C96-AB19EC293184}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aws.AppHost", "Aws.AppHost\Aws.AppHost.csproj", "{6375E258-B754-4E61-AABC-0C7637C6E560}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aws.ServiceDefaults", "Aws.ServiceDefaults\Aws.ServiceDefaults.csproj", "{9C16F0DE-2958-482D-B63A-C59EA35F4418}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {451E69B4-7EF4-43F4-8C96-AB19EC293184}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {451E69B4-7EF4-43F4-8C96-AB19EC293184}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {451E69B4-7EF4-43F4-8C96-AB19EC293184}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {451E69B4-7EF4-43F4-8C96-AB19EC293184}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6375E258-B754-4E61-AABC-0C7637C6E560}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6375E258-B754-4E61-AABC-0C7637C6E560}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6375E258-B754-4E61-AABC-0C7637C6E560}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6375E258-B754-4E61-AABC-0C7637C6E560}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9C16F0DE-2958-482D-B63A-C59EA35F4418}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9C16F0DE-2958-482D-B63A-C59EA35F4418}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9C16F0DE-2958-482D-B63A-C59EA35F4418}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9C16F0DE-2958-482D-B63A-C59EA35F4418}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {A2B98649-C3BD-4302-BBC1-3D0C0A2BC721}
+ EndGlobalSection
+EndGlobal
diff --git a/samples/aws/docker-compose.yml b/samples/aws/docker-compose.yml
new file mode 100644
index 00000000000..ce33afe897d
--- /dev/null
+++ b/samples/aws/docker-compose.yml
@@ -0,0 +1,16 @@
+version: "3.8"
+
+services:
+ localstack:
+ container_name: "${LOCALSTACK_DOCKER_NAME-localstack-main}"
+ image: localstack/localstack:3.0
+ ports:
+ - "127.0.0.1:4566:4566" # LocalStack Gateway
+ - "127.0.0.1:4510-4559:4510-4559" # external services port range
+ environment:
+ - DEBUG=1
+ - DOCKER_HOST=unix:///var/run/docker.sock
+ - LS_LOG=trace-internal
+ volumes:
+ # - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
+ - "/var/run/docker.sock:/var/run/docker.sock"
diff --git a/src/Aspire.Hosting.AWS.Provisioning/Aspire.Hosting.AWS.Provisioning.csproj b/src/Aspire.Hosting.AWS.Provisioning/Aspire.Hosting.AWS.Provisioning.csproj
new file mode 100644
index 00000000000..ee20dec66e5
--- /dev/null
+++ b/src/Aspire.Hosting.AWS.Provisioning/Aspire.Hosting.AWS.Provisioning.csproj
@@ -0,0 +1,23 @@
+
+
+
+ $(NetCurrent)
+ true
+ Provisions AWS resources for development in .NET Aspire projects.
+
+ $(NoWarn),CS8002
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Aspire.Hosting.AWS.Provisioning/AwsProvisionerExtensions.cs b/src/Aspire.Hosting.AWS.Provisioning/AwsProvisionerExtensions.cs
new file mode 100644
index 00000000000..7d44dd6e21e
--- /dev/null
+++ b/src/Aspire.Hosting.AWS.Provisioning/AwsProvisionerExtensions.cs
@@ -0,0 +1,42 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Amazon.CloudFormation;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.AWS.Provisioning;
+using Aspire.Hosting.AWS.Provisioning.Provisioners;
+using Aspire.Hosting.AWS;
+using Aspire.Hosting.Lifecycle;
+using LocalStack.Client.Extensions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting;
+
+///
+/// Provides extension methods for adding support for generating AWS resources dynamically during application startup.
+///
+public static class AwsProvisionerExtensions
+{
+ ///
+ /// Adds support for generating AWS resources dynamically during application startup.
+ /// The application must configure the appropriate settings in the section.
+ ///
+ public static IDistributedApplicationBuilder AddAwsProvisioning(this IDistributedApplicationBuilder builder)
+ {
+ builder.Services.AddLifecycleHook();
+
+ builder.Services.AddLocalStack(builder.Configuration);
+ builder.Services.AddAwsService();
+
+ // Attempt to read aws configuration from configuration
+ builder.Services.AddOptions()
+ .BindConfiguration("AWS");
+
+ // TODO: We're keeping state in the provisioners, which is not ideal
+ builder.Services.AddKeyedTransient(typeof(AwsS3BucketResource));
+ builder.Services.AddKeyedTransient(typeof(AwsSqsQueueResource));
+ builder.Services.AddKeyedTransient(typeof(AwsSnsTopicResource));
+
+ return builder;
+ }
+}
diff --git a/src/Aspire.Hosting.AWS.Provisioning/AwsProvisionerOptions.cs b/src/Aspire.Hosting.AWS.Provisioning/AwsProvisionerOptions.cs
new file mode 100644
index 00000000000..db4c91adac1
--- /dev/null
+++ b/src/Aspire.Hosting.AWS.Provisioning/AwsProvisionerOptions.cs
@@ -0,0 +1,12 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Amazon.Extensions.NETCore.Setup;
+
+namespace Aspire.Hosting.AWS.Provisioning;
+
+// TODO: Re-evaluate this approach
+internal sealed class AwsProvisionerOptions : AWSOptions
+{
+ public string? StackName { get; set; }
+}
diff --git a/src/Aspire.Hosting.AWS.Provisioning/Provisioners/AwsProvisioner.cs b/src/Aspire.Hosting.AWS.Provisioning/Provisioners/AwsProvisioner.cs
new file mode 100644
index 00000000000..1327e5040b5
--- /dev/null
+++ b/src/Aspire.Hosting.AWS.Provisioning/Provisioners/AwsProvisioner.cs
@@ -0,0 +1,385 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using System.Net;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Amazon.CloudFormation;
+using Amazon.CloudFormation.Model;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.AWS.CloudFormation;
+using Aspire.Hosting.AWS.Provisioning;
+using Aspire.Hosting.AWS.Provisioning.Provisioners;
+using Aspire.Hosting.Lifecycle;
+using LocalStack.Client.Options;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using InvalidOperationException = System.InvalidOperationException;
+
+namespace Aspire.Hosting.AWS;
+
+// Provisions aws resources for development purposes
+internal sealed class AwsProvisioner(
+ IAmazonCloudFormation cloudFormationClient,
+ IServiceProvider serviceProvider,
+ // IHostEnvironment environment,
+ IConfiguration configuration,
+ IOptions awsProvisionerOptions,
+ IOptions localStackOptions,
+ ILogger logger) : IDistributedApplicationLifecycleHook
+{
+ private static readonly JsonSerializerOptions s_cloudFormationJsonSerializerOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
+
+ private readonly AwsProvisionerOptions _awsProvisionerOptions = awsProvisionerOptions.Value;
+ private readonly LocalStackOptions _localStackOptions = localStackOptions.Value;
+
+ public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
+ {
+ var awsResources = appModel.Resources.OfType().ToList();
+ if (!awsResources.Any())
+ {
+ return;
+ }
+
+ // TODO: run the container somewhere here
+
+ try
+ {
+ await ProvisionAwsResources(awsResources, cancellationToken).ConfigureAwait(false);
+ }
+ catch (MissingConfigurationException ex)
+ {
+ logger.LogWarning(ex, "Required configuration is missing");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error provisioning Aws resources");
+ }
+ }
+
+ private async Task ProvisionAwsResources(IEnumerable awsResources, CancellationToken cancellationToken)
+ {
+ // AWS SDK searches for credentials in the following sources:
+ // - Environment variables
+ // - Shared credentials file
+ // - AWS credentials profile
+ // If no credentials are found, the SDK will throw an exception.
+
+ var stackName = _awsProvisionerOptions.StackName ??
+ throw new MissingConfigurationException("A cloudformation stack name is required. Set the AWS:StackName configuration value.");
+
+ // Now we have a list of resources to provision in a cloud formation template
+ var cloudFormationTemplate = ProcessResources(awsResources);
+
+ // TODO: Use JsonSerializer source generators
+ var templateBody = JsonSerializer.Serialize(cloudFormationTemplate, s_cloudFormationJsonSerializerOptions);
+ var isTemplateValid = await IsTemplateValidAsync(templateBody).ConfigureAwait(false);
+
+ if (!isTemplateValid)
+ {
+ // TODO: Maybe throw a custom exception here?
+ throw new InvalidOperationException("CloudFormation template is not valid");
+ }
+
+ StackStatus? stackStatus = await CheckIfStackExists(stackName, cancellationToken).ConfigureAwait(false);
+
+ if (stackStatus is null || stackStatus == StackStatus.DELETE_COMPLETE)
+ {
+ await CreateStackAsync(stackName, templateBody, cancellationToken).ConfigureAwait(false);
+ }
+ // LocalStack has a bug that doesn't allow to update a stack, I'll open an issue for that in the LocalStack repo
+ else if ((stackStatus == StackStatus.CREATE_COMPLETE || stackStatus == StackStatus.UPDATE_COMPLETE) && _localStackOptions.UseLocalStack)
+ {
+ if (_localStackOptions.UseLocalStack)
+ {
+ logger.LogInformation("LocalStack has a bug that doesn't allow to update a stack. Deleting and recreating it");
+
+ await DeleteStackAsync(stackName, cancellationToken).ConfigureAwait(false);
+ await CreateStackAsync(stackName, templateBody, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ await UpdateStackAsync(stackName, templateBody, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ else if (stackStatus == StackStatus.CREATE_FAILED || stackStatus == StackStatus.ROLLBACK_FAILED || stackStatus == StackStatus.UPDATE_ROLLBACK_FAILED)
+ {
+ // The stack is in a failed state, so delete it before creating a new one
+
+ await DeleteStackAsync(stackName, cancellationToken).ConfigureAwait(false);
+ await CreateStackAsync(stackName, templateBody, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ logger.LogInformation("Stack {StackName} is in {StackStatus} state. No action required", stackName, stackStatus);
+ }
+
+ // TODO: add cloud formation outputs to the configure aws resources
+
+ var stackOutputs = await RetrieveStackOutputsAsync(stackName, cancellationToken).ConfigureAwait(false);
+
+ // foreach (var stackOutput in stackOutputs)
+ // {
+ // logger.LogInformation("Stack output {StackOutputKey}: {StackOutputValue}", stackOutput.Key, stackOutput.Value);
+ // }
+
+ // TODO: Find a better way to set the outputs
+ foreach (var awsResource in awsResources)
+ {
+ var resourceOutputs = stackOutputs.Where(pair => pair.Key.StartsWith(awsResource.Name)).ToImmutableDictionary();
+
+ if (resourceOutputs.Count == 0)
+ {
+ continue;
+ }
+
+ var provisioner = serviceProvider.GetKeyedService(awsResource.GetType());
+
+ if (provisioner is null)
+ {
+ logger.LogWarning("No provisioner found for {ResourceType} skipping", awsResource.GetType().Name);
+ continue;
+ }
+
+ // TODO: Setting the outputs to resources very are fragile, it's based on string key matching, which is not ideal.
+ // Maybe we can use a custom attribute to mark the properties that should be set as outputs?
+ provisioner.SetResourceOutputs(awsResource, resourceOutputs);
+ }
+ }
+
+ private CloudFormationTemplate ProcessResources(IEnumerable awsResources)
+ {
+ var usedResources = new HashSet();
+ var cloudFormationTemplate = new CloudFormationTemplate();
+
+ var resources = awsResources.ToList();
+ foreach (var resource in resources)
+ {
+ if (usedResources.Contains(resource.Name))
+ {
+ continue;
+ }
+
+ var provisioner = serviceProvider.GetKeyedService(resource.GetType());
+
+ if (provisioner is null)
+ {
+ logger.LogWarning("No provisioner found for {ResourceType} skipping", resource.GetType().Name);
+ continue;
+ }
+
+ provisioner.ConfigureResource(configuration, resource);
+
+ // TODO: We're keeping state in the provisioners, which is not ideal.
+ // Maybe passing all resource to provisioners and do operations below in the provisioner itself?
+ // We can utilize ProvisioningContext for this purpose.
+ if (resource is AwsSnsTopicResource topicResource && topicResource.Subscriptions.Any())
+ {
+ // TODO: null check
+ var snsProvisioner = (SnsProvisioner)provisioner;
+ try
+ {
+ var snsSubscribersResources = topicResource.Subscriptions
+ .Select(resName => resources.Single(awsResource => awsResource.Name == resName));
+
+ foreach (var snsSubscriberResource in snsSubscribersResources)
+ {
+ var subProvisioner = serviceProvider.GetKeyedService(snsSubscriberResource.GetType());
+
+ subProvisioner!.ConfigureResource(configuration, snsSubscriberResource);
+
+ var awsConstruct = subProvisioner.CreateConstruct(snsSubscriberResource, new ProvisioningContext());
+ snsProvisioner.AddSubscriptions(awsConstruct);
+
+ cloudFormationTemplate.AddResource(awsConstruct);
+ usedResources.Add(snsSubscriberResource.Name);
+ }
+ }
+ catch (InvalidOperationException e)
+ {
+ throw new MissingConfigurationException($"The resource {resource.Name} has a subscription to a resource that does not exist.", e);
+ }
+ }
+
+ // TODO: Couldn't figure out if need ProvisioningContext yet, so I'm keeping it here for now
+ var construct = provisioner.CreateConstruct(resource, new ProvisioningContext());
+ cloudFormationTemplate.AddResource(construct);
+ usedResources.Add(resource.Name);
+ }
+
+ return cloudFormationTemplate;
+ }
+
+ private async Task CreateStackAsync(string stackName, string templateBody, CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Creating stack {StackName}", stackName);
+
+ var createStackRequest = new CreateStackRequest { StackName = stackName, TemplateBody = templateBody };
+
+ // TODO: Handle exceptions
+ var createStackResponse = await cloudFormationClient.CreateStackAsync(createStackRequest, cancellationToken).ConfigureAwait(false);
+
+ if (createStackResponse.HttpStatusCode != HttpStatusCode.OK)
+ {
+ logger.LogError("Error creating stack {StackName}", stackName);
+
+ // TODO: throw custom exception
+ }
+
+ var desiredStatusesForCreation = new HashSet { StackStatus.CREATE_COMPLETE, StackStatus.ROLLBACK_COMPLETE };
+ // I'm not sure if timeout should be configurable
+ await WaitForStackCompletion(stackName, desiredStatusesForCreation, TimeSpan.FromMinutes(10), cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task UpdateStackAsync(string stackName, string templateBody, CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Updating stack {StackName}", stackName);
+
+ var updateStackRequest = new UpdateStackRequest { StackName = stackName, TemplateBody = templateBody };
+
+ try
+ {
+ var updateStackResponse = await cloudFormationClient.UpdateStackAsync(updateStackRequest, cancellationToken).ConfigureAwait(false);
+
+ if (updateStackResponse.HttpStatusCode != HttpStatusCode.OK)
+ {
+ logger.LogError("Error updating stack {StackName}", stackName);
+
+ // TODO: throw custom exception
+ }
+ }
+ catch (AmazonCloudFormationException e)
+ {
+ if (!(e.ErrorCode == "ValidationError" && e.Message.Contains("No updates are to be performed.")))
+ {
+ // We don't want to throw if there are no updates to be performed
+ logger.LogWarning(e, "Error updating stack {StackName}", stackName);
+ }
+ }
+
+ var desiredStatusesForUpdate = new HashSet { StackStatus.UPDATE_COMPLETE, StackStatus.UPDATE_ROLLBACK_COMPLETE };
+ await WaitForStackCompletion(stackName, desiredStatusesForUpdate, TimeSpan.FromMinutes(10), cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task DeleteStackAsync(string stackName, CancellationToken cancellationToken)
+ {
+ var deleteStackRequest = new DeleteStackRequest { StackName = stackName };
+ var deleteStackResponse = await cloudFormationClient.DeleteStackAsync(deleteStackRequest, cancellationToken).ConfigureAwait(false);
+
+ if (deleteStackResponse.HttpStatusCode != HttpStatusCode.OK)
+ {
+ logger.LogError("Error deleting stack {StackName}", stackName);
+
+ // TODO: throw custom exception
+ }
+
+ var desiredStatusesForDelete = new HashSet { StackStatus.DELETE_COMPLETE };
+ await WaitForStackCompletion(stackName, desiredStatusesForDelete, TimeSpan.FromMinutes(10), cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task> RetrieveStackOutputsAsync(string stackName, CancellationToken cancellationToken)
+ {
+ var outputs = new Dictionary();
+ var response = await cloudFormationClient.DescribeStacksAsync(new DescribeStacksRequest { StackName = stackName }, cancellationToken).ConfigureAwait(false);
+
+ foreach (var output in response.Stacks[0].Outputs)
+ {
+ outputs[output.OutputKey] = output.OutputValue;
+ }
+
+ return outputs.ToImmutableDictionary();
+ }
+
+ private async Task CheckIfStackExists(string stackName, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var describeStacksRequest = new DescribeStacksRequest { StackName = stackName };
+ var describeStacksResponse = await cloudFormationClient.DescribeStacksAsync(describeStacksRequest, cancellationToken).ConfigureAwait(false);
+ var stackStatus = describeStacksResponse.Stacks.FirstOrDefault()?.StackStatus;
+
+ return stackStatus;
+ }
+ catch (AmazonCloudFormationException e)
+ {
+ if (!(e.ErrorCode == "ValidationError" && e.Message.Contains("does not exist")))
+ {
+ logger.LogError(e, "Error checking if stack {StackName} exists", stackName);
+ throw;
+ }
+
+ logger.LogInformation("Stack {StackName} does not exist", stackName);
+
+ return null;
+ }
+ }
+
+ private async Task IsTemplateValidAsync(string templateBody)
+ {
+ try
+ {
+ var validateRequest = new ValidateTemplateRequest { TemplateBody = templateBody };
+ var response = await cloudFormationClient.ValidateTemplateAsync(validateRequest).ConfigureAwait(false);
+
+ logger.LogInformation("Template validation succeeded for {TemplateBody}", templateBody);
+
+ // Optionally, print additional details about the template
+ foreach (var parameter in response.Parameters)
+ {
+ logger.LogInformation("Parameter: {ParameterParameterKey}, Default: {ParameterDefaultValue}", parameter.ParameterKey, parameter.DefaultValue);
+ }
+
+ return true;
+ }
+ catch (AmazonCloudFormationException ex)
+ {
+ logger.LogError(ex, "Template validation failed for {TemplateBody}", templateBody);
+ return false;
+ }
+ }
+
+ private async Task WaitForStackCompletion(string stackName, IReadOnlySet desiredStatuses, TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ var startTime = DateTime.UtcNow;
+
+ logger.LogInformation("Waiting for stack {StackName} to reach the desired state...", stackName);
+
+ while (DateTime.UtcNow - startTime < timeout)
+ {
+ logger.LogInformation("Checking stack {StackName} status...", stackName);
+
+ var response = await cloudFormationClient.DescribeStacksAsync(new DescribeStacksRequest { StackName = stackName }, cancellationToken).ConfigureAwait(false);
+
+ // Can it be multiple stacks with the same name?
+ var currentStatus = response.Stacks.FirstOrDefault()?.StackStatus;
+
+ logger.LogInformation("Stack {StackName} is in {StackStatus} state", stackName, currentStatus);
+
+ // Not sure if this is the best way to check if the stack is in the desired state
+ // TODO: Null check
+ if (desiredStatuses.Contains(currentStatus!))
+ {
+ return; // Desired state reached
+ }
+
+ logger.LogInformation("Stack {StackName} is not in the desired state yet. Waiting for 30 secs...", stackName);
+ await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken).ConfigureAwait(false); // Polling interval
+ }
+
+ throw new TimeoutException("Timeout waiting for stack to reach the desired state.");
+ }
+
+ private sealed class MissingConfigurationException : Exception
+ {
+ public MissingConfigurationException(string message) : base(message)
+ {
+ }
+
+ public MissingConfigurationException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+ }
+}
diff --git a/src/Aspire.Hosting.AWS.Provisioning/Provisioners/AwsResourceProvisionerOfT.cs b/src/Aspire.Hosting.AWS.Provisioning/Provisioners/AwsResourceProvisionerOfT.cs
new file mode 100644
index 00000000000..8fc121db994
--- /dev/null
+++ b/src/Aspire.Hosting.AWS.Provisioning/Provisioners/AwsResourceProvisionerOfT.cs
@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using Microsoft.Extensions.Configuration;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.AWS.CloudFormation.Constructs;
+
+namespace Aspire.Hosting.AWS.Provisioning;
+
+internal sealed class ProvisioningContext()
+{
+ // public RegionEndpoint Location => location;
+}
+
+internal interface IAwsResourceProvisioner
+{
+ void ConfigureResource(IConfiguration configuration, IAwsResource resource);
+
+ IAwsConstruct CreateConstruct(IAwsResource resource, ProvisioningContext context);
+
+ void SetResourceOutputs(IAwsResource awsResource, IImmutableDictionary resourceOutputs);
+}
+
+internal abstract class AwsResourceProvisioner : IAwsResourceProvisioner
+ where TResource : class, IAwsResource
+ where TConstruct : AwsConstruct
+{
+ public abstract void ConfigureResource(IConfiguration configuration, TResource resource);
+
+ public abstract TConstruct CreateConstruct(TResource resource, ProvisioningContext context);
+
+ public abstract void SetResourceOutputs(TResource resource, IImmutableDictionary resourceOutputs);
+
+ public void ConfigureResource(IConfiguration configuration, IAwsResource resource) =>
+ ConfigureResource(configuration, (TResource)resource);
+
+ public IAwsConstruct CreateConstruct(IAwsResource resource, ProvisioningContext context) =>
+ CreateConstruct((TResource)resource, context);
+
+ public void SetResourceOutputs(IAwsResource awsResource, IImmutableDictionary resourceOutputs) =>
+ SetResourceOutputs((TResource)awsResource, resourceOutputs);
+}
diff --git a/src/Aspire.Hosting.AWS.Provisioning/Provisioners/S3Provisioner.cs b/src/Aspire.Hosting.AWS.Provisioning/Provisioners/S3Provisioner.cs
new file mode 100644
index 00000000000..3023462d82b
--- /dev/null
+++ b/src/Aspire.Hosting.AWS.Provisioning/Provisioners/S3Provisioner.cs
@@ -0,0 +1,45 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.AWS.CloudFormation.Constructs;
+using Microsoft.Extensions.Configuration;
+
+namespace Aspire.Hosting.AWS.Provisioning.Provisioners;
+
+internal sealed class S3Provisioner : AwsResourceProvisioner
+{
+ public override void ConfigureResource(IConfiguration configuration, AwsS3BucketResource resource)
+ {
+ var bucketSection = configuration.GetSection($"AWS:S3:{resource.Name}");
+ var bucketName = bucketSection["BucketName"];
+ var accessControl = bucketSection["AccessControl"];
+
+ resource.BucketName = bucketName;
+ resource.AccessControl = accessControl;
+
+ // TODO: add tags, versioning, or other S3-specific settings
+ }
+
+ public override AwsS3BucketConstruct CreateConstruct(AwsS3BucketResource resource, ProvisioningContext context)
+ {
+ var bucketConstruct = new AwsS3BucketConstruct(resource.Name)
+ {
+ Properties = new AwsS3BucketConstruct.BucketProperties
+ {
+ BucketName = resource.BucketName,
+ AccessControl = resource.AccessControl
+ // Additional properties can be set here based on the resource definition
+ }
+ };
+
+ return bucketConstruct;
+ }
+
+ public override void SetResourceOutputs(AwsS3BucketResource awsResource, IImmutableDictionary resourceOutputs)
+ {
+ awsResource.Arn = resourceOutputs[$"{awsResource.Name}-BucketArn"];
+ awsResource.BucketName = resourceOutputs[$"{awsResource.Name}-BucketName"];
+ }
+}
diff --git a/src/Aspire.Hosting.AWS.Provisioning/Provisioners/SnsProvisioner.cs b/src/Aspire.Hosting.AWS.Provisioning/Provisioners/SnsProvisioner.cs
new file mode 100644
index 00000000000..86efe5486fa
--- /dev/null
+++ b/src/Aspire.Hosting.AWS.Provisioning/Provisioners/SnsProvisioner.cs
@@ -0,0 +1,53 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.AWS.CloudFormation.Constructs;
+using Microsoft.Extensions.Configuration;
+
+namespace Aspire.Hosting.AWS.Provisioning.Provisioners;
+
+internal sealed class SnsProvisioner : AwsResourceProvisioner
+{
+ private readonly IList _subscribedResources = new List();
+
+ public override void ConfigureResource(IConfiguration configuration, AwsSnsTopicResource resource)
+ {
+ var snsSection = configuration.GetSection($"AWS:SNS:{resource.Name}");
+ var subscriptions = new List();
+ snsSection.GetSection("Subscriptions").Bind(subscriptions);
+
+ resource.Subscriptions = subscriptions;
+
+ // TODO: add tags, etc.
+ }
+
+ public override AwsSnsTopicConstruct CreateConstruct(AwsSnsTopicResource resource, ProvisioningContext context)
+ {
+ var awsSnsTopicConstruct = new AwsSnsTopicConstruct(resource.Name);
+ // Additional properties can be set here based on the resource definition
+
+ foreach (var subscription in resource.Subscriptions)
+ {
+ var subscribedResource = _subscribedResources.FirstOrDefault(r => r.Name == subscription);
+ if (subscribedResource is not null)
+ {
+ awsSnsTopicConstruct.AddSubscriber(subscribedResource);
+ }
+ }
+
+ return awsSnsTopicConstruct;
+ }
+
+ public void AddSubscriptions(IAwsConstruct construct)
+ {
+ _subscribedResources.Add(construct);
+ }
+
+ public override void SetResourceOutputs(AwsSnsTopicResource resource, IImmutableDictionary resourceOutputs)
+ {
+ resource.Arn = resourceOutputs[$"{resource.Name}-TopicARN"];
+ resource.TopicName = resourceOutputs[$"{resource.Name}-TopicName"];
+ }
+}
diff --git a/src/Aspire.Hosting.AWS.Provisioning/Provisioners/SqsProvisioner.cs b/src/Aspire.Hosting.AWS.Provisioning/Provisioners/SqsProvisioner.cs
new file mode 100644
index 00000000000..fe71b47c94c
--- /dev/null
+++ b/src/Aspire.Hosting.AWS.Provisioning/Provisioners/SqsProvisioner.cs
@@ -0,0 +1,51 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.AWS.CloudFormation.Constructs;
+using Microsoft.Extensions.Configuration;
+
+namespace Aspire.Hosting.AWS.Provisioning.Provisioners;
+
+internal sealed class SqsProvisioner : AwsResourceProvisioner
+{
+ public override void ConfigureResource(IConfiguration configuration, AwsSqsQueueResource resource)
+ {
+ var sqsSection = configuration.GetSection($"AWS:SQS:{resource.Name}");
+
+ var messageRetentionPeriod = sqsSection["MessageRetentionPeriod"];
+ var visibilityTimeout = sqsSection["VisibilityTimeout"];
+
+ // Should we throw if the value is not an integer?
+ if (messageRetentionPeriod is not null && int.TryParse(messageRetentionPeriod, out var value))
+ {
+ resource.MessageRetentionPeriod = value;
+ }
+
+ if (visibilityTimeout is not null && int.TryParse(visibilityTimeout, out value))
+ {
+ resource.VisibilityTimeout = value;
+ }
+ }
+
+ public override AwsSqsQueueConstruct CreateConstruct(AwsSqsQueueResource resource, ProvisioningContext context)
+ {
+ var awsSqsQueueConstruct = new AwsSqsQueueConstruct(resource.Name)
+ {
+ Properties = new AwsSqsQueueConstruct.QueueProperties()
+ {
+ MessageRetentionPeriod = resource.MessageRetentionPeriod, VisibilityTimeout = resource.VisibilityTimeout
+ }
+ };
+
+ return awsSqsQueueConstruct;
+ }
+
+ public override void SetResourceOutputs(AwsSqsQueueResource resource, IImmutableDictionary resourceOutputs)
+ {
+ resource.QueueName = resourceOutputs[$"{resource.Name}-QueueName"];
+ resource.Arn = resourceOutputs[$"{resource.Name}-QueueARN"];
+ resource.QueueUrl = new Uri(resourceOutputs[$"{resource.Name}-QueueURL"]);
+ }
+}
diff --git a/src/Aspire.Hosting.AWS/Aspire.Hosting.AWS.csproj b/src/Aspire.Hosting.AWS/Aspire.Hosting.AWS.csproj
new file mode 100644
index 00000000000..478fd0c3720
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/Aspire.Hosting.AWS.csproj
@@ -0,0 +1,17 @@
+
+
+
+ $(NetCurrent)
+ true
+ AWWS resource types for .NET Aspire.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Aspire.Hosting.AWS/AwsResourceExtensions.cs b/src/Aspire.Hosting.AWS/AwsResourceExtensions.cs
new file mode 100644
index 00000000000..917e80a5bec
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/AwsResourceExtensions.cs
@@ -0,0 +1,67 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting;
+
+///
+/// Provides extension methods for adding AWS resources to the application model.
+///
+public static class AwsResourceExtensions
+{
+ ///
+ /// Adds an AWS S3 bucket resource to the application model.
+ ///
+ /// The .
+ /// The name of the resource.
+ /// A resource builder for the S3 bucket resource.
+ public static IResourceBuilder AddAwsS3Bucket(this IDistributedApplicationBuilder builder, string name)
+ {
+ var s3Bucket = new AwsS3BucketResource(name);
+ return builder.AddResource(s3Bucket)
+ .WithAnnotation(new ManifestPublishingCallbackAnnotation(WriteS3BucketToManifest));
+ }
+
+ ///
+ /// Adds an AWS SQS queue resource to the application model.
+ ///
+ /// The .
+ /// The name of the resource.
+ /// A resource builder for the SQS queue resource.
+ public static IResourceBuilder AddAwsSqsQueue(this IDistributedApplicationBuilder builder, string name)
+ {
+ var sqsQueue = new AwsSqsQueueResource(name);
+ return builder.AddResource(sqsQueue)
+ .WithAnnotation(new ManifestPublishingCallbackAnnotation(WriteSqsQueueToManifest));
+ }
+
+ ///
+ /// Adds an AWS SNS topic resource to the application model.
+ ///
+ /// The .
+ /// The name of the resource.
+ /// A resource builder for the SNS topic resource.
+ public static IResourceBuilder AddAwsSnsTopic(this IDistributedApplicationBuilder builder, string name)
+ {
+ var snsTopic = new AwsSnsTopicResource(name);
+ return builder.AddResource(snsTopic)
+ .WithAnnotation(new ManifestPublishingCallbackAnnotation(WriteSnsTopicToManifest));
+ }
+
+ private static void WriteS3BucketToManifest(Utf8JsonWriter jsonWriter)
+ {
+ jsonWriter.WriteString("type", "aws.s3.bucket");
+ }
+
+ private static void WriteSqsQueueToManifest(Utf8JsonWriter jsonWriter)
+ {
+ jsonWriter.WriteString("type", "aws.sqs.queue");
+ }
+
+ private static void WriteSnsTopicToManifest(Utf8JsonWriter jsonWriter)
+ {
+ jsonWriter.WriteString("type", "aws.sns.topic");
+ }
+}
diff --git a/src/Aspire.Hosting.AWS/AwsS3BucketResource.cs b/src/Aspire.Hosting.AWS/AwsS3BucketResource.cs
new file mode 100644
index 00000000000..c9e1fc8dff5
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/AwsS3BucketResource.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.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents an AWS S3 bucket resource.
+///
+/// The name of the resource.
+public class AwsS3BucketResource(string name) : Resource(name), IAwsResource, IResourceWithConnectionString
+{
+ ///
+ /// Gets or sets the name of the S3 bucket.
+ ///
+ public string? BucketName { get; set; }
+
+ ///
+ /// Gets or sets the access control of the S3 bucket.
+ ///
+ public string? AccessControl { get; set; }
+
+ ///
+ /// Gets or sets the Amazon Resource Name (ARN) of the S3 Bucket.
+ ///
+ public string? Arn { get; set; }
+
+ ///
+ /// Gets the name of the S3 bucket resource.
+ ///
+ /// The name of the S3 bucket resource.
+ public string? GetConnectionString() => BucketName;
+}
diff --git a/src/Aspire.Hosting.AWS/AwsSnsTopicResource.cs b/src/Aspire.Hosting.AWS/AwsSnsTopicResource.cs
new file mode 100644
index 00000000000..24a3ceb4f7f
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/AwsSnsTopicResource.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.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents an AWS SNS topic resource.
+///
+/// The name of the resource.
+public class AwsSnsTopicResource(string name) : Resource(name), IAwsResource, IResourceWithConnectionString
+{
+ ///
+ /// Gets or sets the Amazon Resource Name (ARN) of the SNS.
+ ///
+ public string? Arn { get; set; }
+
+ ///
+ /// Gets or sets the name of the SNS topic.
+ ///
+ public string? TopicName { get; set; }
+
+ ///
+ /// Gets or sets the subscriptions of the SNS.
+ ///
+ public IList Subscriptions { get; set; } = new List();
+
+ ///
+ /// Gets the name of the SNS topic resource.
+ ///
+ /// The name of the SNS topic resource.
+ public string? GetConnectionString() => Arn;
+}
diff --git a/src/Aspire.Hosting.AWS/AwsSqsQueueResource.cs b/src/Aspire.Hosting.AWS/AwsSqsQueueResource.cs
new file mode 100644
index 00000000000..ba6875dfd9c
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/AwsSqsQueueResource.cs
@@ -0,0 +1,42 @@
+// 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;
+
+///
+/// Represents an AWS SQS queue resource.
+///
+/// The name of the resource.
+public class AwsSqsQueueResource(string name) : Resource(name), IAwsResource, IResourceWithConnectionString
+{
+ ///
+ /// Gets or sets the URI of the SQS queue.
+ ///
+ public Uri? QueueUrl { get; set; }
+
+ ///
+ /// Gets or sets the Amazon Resource Name (ARN) of the Sqs.
+ ///
+ public string? Arn { get; set; }
+
+ ///
+ /// Gets or sets the name of the Sqs.
+ ///
+ public string? QueueName { get; set; }
+
+ ///
+ /// Gets or sets the visibility timeout of the Sqs.
+ ///
+ public int? MessageRetentionPeriod { get; set; }
+
+ ///
+ /// Gets or sets the visibility timeout of the Sqs.
+ ///
+ public int? VisibilityTimeout { get; set; }
+
+ ///
+ /// Gets the url of the SQS queue resource.
+ ///
+ /// The url of the SQS queue resource.
+ public string? GetConnectionString() => QueueUrl?.ToString();
+}
diff --git a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationTemplate.cs b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationTemplate.cs
new file mode 100644
index 00000000000..5b66d5f4d3c
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationTemplate.cs
@@ -0,0 +1,45 @@
+// 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.AWS.CloudFormation.Constructs;
+
+namespace Aspire.Hosting.AWS.CloudFormation;
+
+///
+/// Represents an AWS CloudFormation template, encapsulating the format version, a description,
+/// and a collection of resources to be deployed.
+///
+public sealed class CloudFormationTemplate
+{
+ private readonly Dictionary _resources = [];
+ private readonly Dictionary _outputs = [];
+
+ public string AWSTemplateFormatVersion { get; init; } = "2010-09-09";
+ public string? Description { get; init; }
+
+ public IReadOnlyDictionary Resources => _resources;
+
+ public IReadOnlyDictionary Outputs => _outputs;
+
+ ///
+ /// Adds an AWS resource to the CloudFormation template.
+ ///
+ /// The AWS resource to add.
+ /// Thrown when a resource with the same name already exists.
+ public void AddResource(IAwsConstruct construct)
+ {
+ // TODO: Maybe some validation here?
+
+ if (!_resources.TryAdd(construct.Name, construct))
+ {
+ throw new InvalidOperationException($"Resource with name {construct.Name} already exists");
+ }
+
+ foreach (var output in construct.GetOutputs())
+ {
+ _outputs.Add(output.Key, output.Value);
+ }
+ }
+
+ public record Output(object Value, string Description);
+}
diff --git a/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsConstruct.cs b/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsConstruct.cs
new file mode 100644
index 00000000000..17444a8439a
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsConstruct.cs
@@ -0,0 +1,28 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Aspire.Hosting.AWS.CloudFormation.Constructs;
+
+///
+/// Serves as the base class for AWS resource types to be included in a CloudFormation template.
+///
+/// The name of the resource.
+[JsonDerivedType(typeof(AwsS3BucketConstruct))]
+[JsonDerivedType(typeof(AwsSnsTopicConstruct))]
+[JsonDerivedType(typeof(AwsSqsQueueConstruct))]
+public abstract class AwsConstruct(string name) : IAwsConstruct
+{
+ [JsonIgnore] public string Name { get; } = name;
+
+ public abstract string Type { get; }
+
+ public Dictionary? Tags { get; init; }
+
+ public abstract IReadOnlyDictionary GetOutputs();
+
+ public abstract class Properties
+ {
+ }
+}
diff --git a/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsS3BucketConstruct.cs b/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsS3BucketConstruct.cs
new file mode 100644
index 00000000000..f2b2d213777
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsS3BucketConstruct.cs
@@ -0,0 +1,40 @@
+// 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.AWS.CloudFormation.Functions;
+
+namespace Aspire.Hosting.AWS.CloudFormation.Constructs;
+
+///
+/// Represents an Amazon S3 bucket resource, providing properties and configurations specific to S3.
+///
+/// The name of the resource.
+public class AwsS3BucketConstruct(string name) : AwsConstruct(name)
+{
+ public override string Type => "AWS::S3::Bucket";
+
+ public new BucketProperties Properties { get; init; } = new();
+
+ public class BucketProperties : Properties
+ {
+ public string? BucketName { get; set; }
+
+ public string? AccessControl { get; set; }
+
+ public VersioningConfiguration? VersioningConfiguration { get; set; }
+ }
+
+ public class VersioningConfiguration
+ {
+ public string? Status { get; init; }
+ }
+
+ public override IReadOnlyDictionary GetOutputs()
+ {
+ return new Dictionary()
+ {
+ { $"{Name}-BucketName", new CloudFormationTemplate.Output(new FnGetAtt(Name, "BucketName"), "S3 Bucket Name") },
+ { $"{Name}-BucketArn", new CloudFormationTemplate.Output(new FnGetAtt(Name, "Arn"), "S3 Bucket Arn") }
+ };
+ }
+}
diff --git a/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsSnsTopicConstruct.cs b/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsSnsTopicConstruct.cs
new file mode 100644
index 00000000000..4eb2e855394
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsSnsTopicConstruct.cs
@@ -0,0 +1,63 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+using Aspire.Hosting.AWS.CloudFormation.Functions;
+
+namespace Aspire.Hosting.AWS.CloudFormation.Constructs;
+
+///
+/// Represents an Amazon SNS topic resource, including topic properties and associated subscriptions.
+///
+/// The name of the resource.
+public class AwsSnsTopicConstruct(string name) : AwsConstruct(name)
+{
+ public override string Type => "AWS::SNS::Topic";
+
+ public new TopicProperties Properties { get; init; } = new();
+
+ public class TopicProperties : Properties
+ {
+ public string? TopicName { get; init; }
+ public string? DisplayName { get; init; }
+
+ [JsonPropertyName("Subscription")] public List Subscriptions { get; init; } = new();
+ }
+
+ public class Subscription(string protocol, object endpoint)
+ {
+ public string Protocol { get; init; } = protocol;
+ public object Endpoint { get; init; } = endpoint;
+ }
+
+ ///
+ /// Adds a subscriber to the SNS topic.
+ ///
+ /// The AWS resource to subscribe to the topic.
+ ///
+ /// TODO: Extend this method to support other types of subscribers.
+ ///
+ public void AddSubscriber(IAwsConstruct awsConstruct)
+ {
+ // TODO: Check if the resource is a valid type (SNS, SQS, HTTP, etc.)
+ if (awsConstruct is AwsSqsQueueConstruct)
+ {
+ // TODO: Check if it's an existing resource. Maybe adding ARN to the resource class?
+
+ var getAtt = new FnGetAtt(awsConstruct.Name, "Arn");
+
+ var subscription = new Subscription("sqs", getAtt) { Protocol = "sqs", Endpoint = getAtt };
+ Properties.Subscriptions.Add(subscription);
+ }
+ // TODO: Add checks for other resource types
+ }
+
+ public override IReadOnlyDictionary GetOutputs()
+ {
+ return new Dictionary()
+ {
+ { $"{Name}-TopicName", new CloudFormationTemplate.Output(new FnGetAtt(Name, "TopicName"), "SNS Topic Name") },
+ { $"{Name}-TopicARN", new CloudFormationTemplate.Output(new {Ref = Name}, "SNS Topic Arn") }
+ };
+ }
+}
diff --git a/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsSqsQueueConstruct.cs b/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsSqsQueueConstruct.cs
new file mode 100644
index 00000000000..dfeaa26a635
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/CloudFormation/Constructs/AwsSqsQueueConstruct.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 Aspire.Hosting.AWS.CloudFormation.Functions;
+
+namespace Aspire.Hosting.AWS.CloudFormation.Constructs;
+
+///
+/// Represents an Amazon SQS queue resource, allowing specification of queue properties and settings.
+///
+/// The name of the resource.
+public class AwsSqsQueueConstruct(string name) : AwsConstruct(name)
+{
+ public override string Type => "AWS::SQS::Queue";
+
+ public new QueueProperties Properties { get; init; } = new();
+
+ public class QueueProperties : Properties
+ {
+ public string? QueueName { get; init; }
+ public int? VisibilityTimeout { get; init; }
+ public int? MessageRetentionPeriod { get; init; }
+ }
+
+ public override IReadOnlyDictionary GetOutputs()
+ {
+ return new Dictionary()
+ {
+ { $"{Name}-QueueName", new CloudFormationTemplate.Output(new FnGetAtt(Name, "QueueName"), "SQS Name") },
+ { $"{Name}-QueueURL", new CloudFormationTemplate.Output(new { Ref = Name }, "SQS Url") },
+ { $"{Name}-QueueARN", new CloudFormationTemplate.Output(new FnGetAtt(Name, "Arn"), "SQS Arn") }
+ };
+ }
+}
diff --git a/src/Aspire.Hosting.AWS/CloudFormation/Constructs/IAwsConstruct.cs b/src/Aspire.Hosting.AWS/CloudFormation/Constructs/IAwsConstruct.cs
new file mode 100644
index 00000000000..a2051d85da5
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/CloudFormation/Constructs/IAwsConstruct.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Aspire.Hosting.AWS.CloudFormation.Constructs;
+
+[JsonDerivedType(typeof(AwsS3BucketConstruct))]
+[JsonDerivedType(typeof(AwsSnsTopicConstruct))]
+[JsonDerivedType(typeof(AwsSqsQueueConstruct))]
+public interface IAwsConstruct
+{
+ [JsonIgnore] string Name { get; }
+
+ string Type { get; }
+
+ Dictionary? Tags { get; init; }
+
+ IReadOnlyDictionary GetOutputs();
+}
diff --git a/src/Aspire.Hosting.AWS/CloudFormation/Functions/FnGetAtt.cs b/src/Aspire.Hosting.AWS/CloudFormation/Functions/FnGetAtt.cs
new file mode 100644
index 00000000000..f47d5d36a6a
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/CloudFormation/Functions/FnGetAtt.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 System.Text.Json.Serialization;
+
+namespace Aspire.Hosting.AWS.CloudFormation.Functions;
+
+///
+/// Represents the 'Fn::GetAtt' intrinsic function in CloudFormation,
+/// used to obtain the value of an attribute from another resource in the template.
+///
+/// The logical name of the resource whose attribute is being retrieved.
+/// The name of the attribute whose value is being retrieved.
+internal sealed class FnGetAtt(string resourceName, string attributeName)
+{
+ [JsonPropertyName("Fn::GetAtt")]
+ public List Arguments { get; set; } = [resourceName, attributeName];
+}
diff --git a/src/Aspire.Hosting.AWS/Extensions/ResourceBuilderExtensions.cs b/src/Aspire.Hosting.AWS/Extensions/ResourceBuilderExtensions.cs
new file mode 100644
index 00000000000..64a6d020dec
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/Extensions/ResourceBuilderExtensions.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 Aspire.Hosting.AWS.Extensions;
+
+public static class ResourceBuilderExtensions
+{
+
+}
diff --git a/src/Aspire.Hosting.AWS/IAwsResource.cs b/src/Aspire.Hosting.AWS/IAwsResource.cs
new file mode 100644
index 00000000000..74bee6a2441
--- /dev/null
+++ b/src/Aspire.Hosting.AWS/IAwsResource.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;
+
+///
+/// Represents an AWS resource, as a marker interface for 's
+/// that can be deployed to an AWS. And provides the Amazon Resource Name (ARN) of the resource.
+///
+public interface IAwsResource : IResource
+{
+ ///
+ /// Gets the Amazon Resource Name (ARN) of the resource.
+ ///
+ string? Arn { get; }
+}
diff --git a/src/Components/Aspire.AWS/Aspire.AWS.csproj b/src/Components/Aspire.AWS/Aspire.AWS.csproj
new file mode 100644
index 00000000000..20f3b6eb0bb
--- /dev/null
+++ b/src/Components/Aspire.AWS/Aspire.AWS.csproj
@@ -0,0 +1,27 @@
+
+
+
+ $(NetCurrent)
+ true
+
+ false
+ false
+ $(ComponentAzurePackageTags) storage blobs blob
+ A client for Azure Blob Storage that integrates with Aspire, including health checks, logging and telemetry.
+ $(SharedDir)AzureStorageContainer_256x.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Components/Aspire.AWS/AwsComponent.cs b/src/Components/Aspire.AWS/AwsComponent.cs
new file mode 100644
index 00000000000..feffacf1df1
--- /dev/null
+++ b/src/Components/Aspire.AWS/AwsComponent.cs
@@ -0,0 +1,130 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Amazon.Extensions.NETCore.Setup;
+using Amazon.Runtime;
+using Amazon.S3;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace Aspire.AWS.Common;
+
+internal abstract class AWSComponent
+ where TSettings : class, new()
+ where TClient : IAmazonService
+ where TClientConfig : ClientConfig, new()
+{
+ // Abstract methods to be implemented in derived classes
+ protected abstract void BindSettingsToConfiguration(TSettings settings, IConfiguration configuration);
+
+ protected abstract void BindClientConfigToConfiguration(TClientConfig clientConfig, IConfiguration configuration);
+
+ protected abstract TClient CreateClient(TSettings settings, TClientConfig clientConfig);
+
+ protected abstract IHealthCheck CreateHealthCheck(TClient client, TSettings settings);
+
+ // Example of a method to add a client to the DI container
+ internal void AddClient(
+ IServiceCollection services,
+ IConfiguration configuration,
+ string configurationSectionName,
+ Action? configureSettings = null)
+ {
+ // Create and bind settings from configuration
+ var settings = new TSettings();
+ BindSettingsToConfiguration(settings, configuration.GetSection(configurationSectionName));
+ configureSettings?.Invoke(settings);
+
+ // Create and bind client configuration
+ var clientConfig = new TClientConfig();
+ BindClientConfigToConfiguration(clientConfig, configuration.GetSection(configurationSectionName));
+
+ // Add the AWS service client to the services
+ services.AddAWSService();
+ }
+
+ // Method to add health check (example implementation)
+ //internal void AddHealthCheck(IServiceCollection services, string healthCheckName, TClient client, TSettings settings)
+ //{
+ // services.AddHealthChecks()
+ // .AddCheck(healthCheckName, () => CreateHealthCheck(client, settings));
+ //}
+
+ // Other shared functionalities, like handling AWS credentials, can be added here.
+}
+
+internal abstract class AWSComponentSimple
+ where TSettings : class, new()
+ where TClient : IAmazonService
+{
+ // Abstract methods to be implemented in derived classes
+ protected abstract void BindSettingsToConfiguration(TSettings settings, IConfiguration configuration);
+
+ protected abstract TClient AddClient(TSettings settings, AWSOptions clientConfig);
+
+ protected abstract IHealthCheck CreateHealthCheck(TClient client, TSettings settings);
+
+ // Example of a method to add a client to the DI container
+ internal void AddClient(
+ IServiceCollection services,
+ IConfiguration configuration,
+ string configurationSectionName,
+ Action? configureSettings = null)
+ {
+ // Create and bind settings from configuration
+ var settings = new TSettings();
+ BindSettingsToConfiguration(settings, configuration.GetSection(configurationSectionName));
+ configureSettings?.Invoke(settings);
+
+ // Add the AWS service client to the services
+ services.AddAWSService();
+ }
+
+ // Method to add health check (example implementation)
+ //internal void AddHealthCheck(IServiceCollection services, string healthCheckName, TClient client, TSettings settings)
+ //{
+ // services.AddHealthChecks()
+ // .AddCheck(healthCheckName, () => CreateHealthCheck(client, settings));
+ //}
+
+ // Other shared functionalities, like handling AWS credentials, can be added here.
+}
+
+internal sealed class S3ComponentSimple : AWSComponentSimple
+{
+ protected override void BindSettingsToConfiguration(AwsS3Settings settings, IConfiguration configuration)
+ {
+ throw new NotImplementedException();
+ }
+
+ protected override AmazonS3Client AddClient(AwsS3Settings settings, AWSOptions clientConfig)
+ {
+ throw new NotImplementedException();
+ }
+
+ protected override IHealthCheck CreateHealthCheck(AmazonS3Client client, AwsS3Settings settings)
+ {
+ throw new NotImplementedException();
+ }
+}
+
+public sealed class AwsS3Settings
+{
+ ///
+ /// Gets or sets a boolean value that indicates whether the Blob Storage health check is enabled or not.
+ /// Enabled by default.
+ ///
+ public bool HealthChecks { get; set; } = true;
+
+ ///
+ /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is enabled or not.
+ /// Disabled by default.
+ ///
+ ///
+ /// ActivitySource support in Azure SDK is experimental, the shape of Activities may change in the future without notice.
+ /// It can be enabled by setting "Azure.Experimental.EnableActivitySource" switch to true.
+ /// Or by setting "AZURE_EXPERIMENTAL_ENABLE_ACTIVITY_SOURCE" environment variable to "true".
+ ///
+ public bool Tracing { get; set; }
+}