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; } +}