diff --git a/Aspire.sln b/Aspire.sln index fe783e6803a..9e3d8531398 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -608,6 +608,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlServerEndToEnd.Common", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlServerEndToEnd.DbSetup", "playground\SqlServerEndToEnd\SqlServerEndToEnd.DbSetup\SqlServerEndToEnd.DbSetup.csproj", "{125C081D-7E5B-4F35-B5CD-E2B56140380F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWSCDK.AppHost", "playground\AWS\AWSCDK.AppHost\AWSCDK.AppHost.csproj", "{B5826732-7318-4179-9B85-870CB18AC533}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1602,6 +1604,10 @@ Global {125C081D-7E5B-4F35-B5CD-E2B56140380F}.Debug|Any CPU.Build.0 = Debug|Any CPU {125C081D-7E5B-4F35-B5CD-E2B56140380F}.Release|Any CPU.ActiveCfg = Release|Any CPU {125C081D-7E5B-4F35-B5CD-E2B56140380F}.Release|Any CPU.Build.0 = Release|Any CPU + {B5826732-7318-4179-9B85-870CB18AC533}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5826732-7318-4179-9B85-870CB18AC533}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5826732-7318-4179-9B85-870CB18AC533}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5826732-7318-4179-9B85-870CB18AC533}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1895,6 +1901,7 @@ Global {F0C976EF-EE26-4EA9-B324-0CD21DCEA140} = {3FF3F00C-95C0-46FC-B2BE-A3920C71E393} {1997067D-8EF2-43B3-AB13-9B2E12B52709} = {2CA6AB88-21EF-4488-BB1B-3A5BAD5FE2AD} {125C081D-7E5B-4F35-B5CD-E2B56140380F} = {2CA6AB88-21EF-4488-BB1B-3A5BAD5FE2AD} + {B5826732-7318-4179-9B85-870CB18AC533} = {EF91843F-C4AB-47F8-909B-C494EABB2BA2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6DCEDFEC-988E-4CB3-B45B-191EB5086E0C} diff --git a/Directory.Packages.props b/Directory.Packages.props index 9f516885a47..16d1986f4d5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,8 @@ + + diff --git a/playground/AWS/AWS.AppHost/AWS.AppHost.csproj b/playground/AWS/AWS.AppHost/AWS.AppHost.csproj index 94cf7dbd750..722ea3617ba 100644 --- a/playground/AWS/AWS.AppHost/AWS.AppHost.csproj +++ b/playground/AWS/AWS.AppHost/AWS.AppHost.csproj @@ -6,6 +6,7 @@ enable enable true + $(NoWarn);CS8002 diff --git a/playground/AWS/AWSCDK.AppHost/.gitignore b/playground/AWS/AWSCDK.AppHost/.gitignore new file mode 100644 index 00000000000..7c884c38240 --- /dev/null +++ b/playground/AWS/AWSCDK.AppHost/.gitignore @@ -0,0 +1,2 @@ +# AWS CDK +cdk.out \ No newline at end of file diff --git a/playground/AWS/AWSCDK.AppHost/AWSCDK.AppHost.csproj b/playground/AWS/AWSCDK.AppHost/AWSCDK.AppHost.csproj new file mode 100644 index 00000000000..c8d0246d7a0 --- /dev/null +++ b/playground/AWS/AWSCDK.AppHost/AWSCDK.AppHost.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0 + enable + enable + true + $(NoWarn);CS8002 + + + + + + + + + + + + + + diff --git a/playground/AWS/AWSCDK.AppHost/CustomStack.cs b/playground/AWS/AWSCDK.AppHost/CustomStack.cs new file mode 100644 index 00000000000..541d0b48f99 --- /dev/null +++ b/playground/AWS/AWSCDK.AppHost/CustomStack.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon.CDK; +using Amazon.CDK.AWS.S3; +using Constructs; + +namespace AWSCDK.AppHost; + +public class CustomStack : Stack +{ + + public IBucket Bucket { get; } + + public CustomStack(Construct scope, string id) + : base(scope, id) + { + Bucket = new Bucket(this, "Bucket"); + } + +} diff --git a/playground/AWS/AWSCDK.AppHost/Program.cs b/playground/AWS/AWSCDK.AppHost/Program.cs new file mode 100644 index 00000000000..dd792a5ac69 --- /dev/null +++ b/playground/AWS/AWSCDK.AppHost/Program.cs @@ -0,0 +1,24 @@ +using Amazon; +using AWSCDK.AppHost; + +var builder = DistributedApplication.CreateBuilder(args); + +// Setup a configuration for the AWS .NET SDK. +var awsConfig = builder.AddAWSSDKConfig() + .WithProfile("default") + .WithRegion(RegionEndpoint.EUWest1); + +var stack = builder.AddAWSCDKStack("stack", "Aspire-stack").WithReference(awsConfig); +var customStack = builder.AddAWSCDKStack("custom", scope => new CustomStack(scope, "Aspire-custom")); +customStack.AddOutput("BucketName", stack => stack.Bucket.BucketName).WithReference(awsConfig); + +var topic = stack.AddSNSTopic("topic"); +var queue = stack.AddSQSQueue("queue"); +topic.AddSubscription(queue); + +builder.AddProject("frontend") + //.WithReference(stack) // Reference all outputs of a construct + .WithEnvironment("AWS__Resources__BucketName", customStack.GetOutput("BucketName")) // Reference a construct/stack output + .WithEnvironment("AWS__Resources__ChatTopicArn", topic, t => t.TopicArn); + +builder.Build().Run(); diff --git a/playground/AWS/AWSCDK.AppHost/Properties/launchSettings.json b/playground/AWS/AWSCDK.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..a9449b4c094 --- /dev/null +++ b/playground/AWS/AWSCDK.AppHost/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:15887;http://localhost:15888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16175", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17037" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16175", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17038", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, + "generate-manifest": { + "commandName": "Project", + "commandLineArgs": "--publisher manifest --output-path ./aspire-manifest.json", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:15888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16175" + } + } + } +} diff --git a/playground/AWS/AWSCDK.AppHost/WebAppStack.cs b/playground/AWS/AWSCDK.AppHost/WebAppStack.cs new file mode 100644 index 00000000000..2c1b4e404cc --- /dev/null +++ b/playground/AWS/AWSCDK.AppHost/WebAppStack.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon.CDK; +using Amazon.CDK.AWS.DynamoDB; +using Constructs; +using Attribute = Amazon.CDK.AWS.DynamoDB.Attribute; + +namespace AWSCDK.AppHost; + +public class WebAppStackProps : StackProps; + +public class WebAppStack : Stack +{ + public ITable Table { get; } + + public WebAppStack(Construct scope, string id, WebAppStackProps props) + : base(scope, id, props) + { + Table = new Table(this, "Table", new TableProps + { + PartitionKey = new Attribute { Name = "id", Type = AttributeType.STRING }, + BillingMode = BillingMode.PAY_PER_REQUEST + }); + } +} diff --git a/playground/AWS/AWSCDK.AppHost/aspire-manifest.json b/playground/AWS/AWSCDK.AppHost/aspire-manifest.json new file mode 100644 index 00000000000..c3cf57f1743 --- /dev/null +++ b/playground/AWS/AWSCDK.AppHost/aspire-manifest.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json.schemastore.org/aspire-8.0.json", + "resources": { + "stack": { + "type": "aws.cloudformation.template.v0", + "stack-name": "Aspire-stack", + "template-path": "cdk.out/Aspire-stack.template.json", + "references": [ + { + "target-resource": "frontend" + } + ] + }, + "custom": { + "type": "aws.cloudformation.template.v0", + "stack-name": "Aspire-custom", + "template-path": "cdk.out/Aspire-custom.template.json", + "references": [ + { + "target-resource": "frontend" + } + ] + }, + "frontend": { + "type": "project.v0", + "path": "../Frontend/Frontend.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "HTTP_PORTS": "{frontend.bindings.http.targetPort}", + "AWS__Resources__BucketName": "{custom.output.BucketName}", + "AWS__Resources__ChatTopicArn": "{stack.output.topic8C050C71AWSResourcesChatTopicArn}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + } + } +} \ No newline at end of file diff --git a/playground/AWS/AWSCDK.AppHost/cdk.json b/playground/AWS/AWSCDK.AppHost/cdk.json new file mode 100644 index 00000000000..6baff08c68b --- /dev/null +++ b/playground/AWS/AWSCDK.AppHost/cdk.json @@ -0,0 +1,63 @@ +{ + "app": "dotnet run -- --publisher manifest --output-path ./aspire-manifest.json", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "src/*/obj", + "src/*/bin", + "src/*.sln", + "src/*/GlobalSuppressions.cs", + "src/*/*.csproj" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true + } +} diff --git a/src/Aspire.Hosting.AWS/AWSLifecycleHook.cs b/src/Aspire.Hosting.AWS/AWSLifecycleHook.cs new file mode 100644 index 00000000000..14c58d6824f --- /dev/null +++ b/src/Aspire.Hosting.AWS/AWSLifecycleHook.cs @@ -0,0 +1,178 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS.CDK; +using Aspire.Hosting.AWS.Provisioning; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.AWS; + +internal sealed class AWSLifecycleHook( + DistributedApplicationExecutionContext executionContext, + IServiceProvider serviceProvider, + ResourceNotificationService notificationService, + ResourceLoggerService loggerService) : IDistributedApplicationLifecycleHook +{ + public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + var awsResources = appModel.Resources.OfType().ToList(); + if (awsResources.Count == 0) // Skip when no AWS resources are found + { + return Task.CompletedTask; + } + + // Create a lookup for all resources implementing IResourceWithParent and have IAWSResource as parent in the tree. + // Typical children that are listed here are IStackResource with IConstructResource as children. + // This is important for state reporting so that a stack and it child resources are handled. + var parentChildLookup = appModel.Resources.OfType() + .Select(x => (Child: x, Root: x.Parent.TrySelectParentResource())) + .Where(x => x.Root is not null) + .ToLookup(x => x.Root, x => x.Child); + + // Synthesize AWS CDK resources before provisioning or writing the manifest + SynthesizeAWSCDKResources(awsResources, parentChildLookup); + + // Provisioning resources is fully async, so we can just fire and forget + _ = Task.Run(() => ProvisionAWSResourcesAsync(awsResources, parentChildLookup, cancellationToken), cancellationToken); + return Task.CompletedTask; + } + + private static void SynthesizeAWSCDKResources(IList awsResources, ILookup parentChildLookup) + { + // Only look at StackResources + var stackResources = awsResources.OfType().ToList(); + foreach (var stackResource in stackResources) + { + // Apply construct modifier annotations as some constructs needs te be altered after the fact, like adding outputs. + var constructResources = parentChildLookup[stackResource].OfType(); + foreach (var constructResource in constructResources.Concat([stackResource])) + { + // Find Construct Modifier Annotations + if (!constructResource.TryGetAnnotationsOfType(out var modifiers)) + { + continue; + } + + // Modify stack + foreach (var modifier in modifiers) + { + modifier.ChangeConstruct(constructResource.Construct); + } + } + } + + // Create a lookup for stack resources and their AWS CDK app + var appLookup = stackResources + .Select(r => (Child: r, r.App)) + .ToLookup(r => r.App, r => r.Child); + + foreach (var app in appLookup) + { + // Synthesize AWS CDK app + var cloudAssembly = app.Key.Synth(); + // Attach the stack artifact to the stack resources for provisioning + foreach (var stackResource in app) + { + var stackArtifact = cloudAssembly.Stacks.FirstOrDefault(stack => stack.StackName == stackResource.StackName) + ?? throw new InvalidOperationException($"Stack '{stackResource.StackName}' not found in synthesized cloud assembly."); + // Annotate the resource with information for writing the manifest and provisioning. + stackResource.Annotations.Add(new CloudAssemblyResourceAnnotation(stackArtifact)); + } + } + } + + #region Provisioning + + private async Task ProvisionAWSResourcesAsync(IList awsResources, ILookup parentChildLookup, CancellationToken cancellationToken) + { + // Skip when publishing, this is intended for provisioning only. + if (executionContext.IsPublishMode) + { + return; + } + + // Mark all resources as starting + foreach (var r in awsResources) + { + r.ProvisioningTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await UpdateStateAsync(r, parentChildLookup, s => s with + { + State = new ResourceStateSnapshot("Starting", KnownResourceStateStyles.Info) + }).ConfigureAwait(false); + } + + foreach (var resource in awsResources) + { + // Resolve a provisioner for the AWS Resource + var provisioner = SelectProvisioner(resource); + + var resourceLogger = loggerService.GetLogger(resource); + + if (provisioner is null) // Skip when no provisioner is found + { + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + + resourceLogger.LogWarning("No provisioner found for {ResourceType} skipping", resource.GetType().Name); + } + else + { + resourceLogger.LogInformation("Provisioning {ResourceName}...", resource.Name); + + try + { + // Provision resources + await provisioner.GetOrCreateResourceAsync(resource, cancellationToken).ConfigureAwait(false); + + // Mark resources as running + await UpdateStateAsync(resource, parentChildLookup, s => s with + { + State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success) + }).ConfigureAwait(false); + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + } + catch (Exception ex) + { + resourceLogger.LogError(ex, "Error provisioning {ResourceName}", resource.Name); + + // Mark resources as failed + await UpdateStateAsync(resource, parentChildLookup, s => s with + { + State = new ResourceStateSnapshot("Failed to Provision", KnownResourceStateStyles.Error) + }).ConfigureAwait(false); + resource.ProvisioningTaskCompletionSource?.TrySetException(ex); + } + } + } + } + + private IAWSResourceProvisioner? SelectProvisioner(IAWSResource resource) + { + var type = resource.GetType(); + while (type is not null) // Loop through all the base types to find a resource that as a provisioner + { + var provisioner = serviceProvider.GetKeyedService(type); + if (provisioner is not null) + { + return provisioner; + } + type = type.BaseType; + } + return null; + } + + private async Task UpdateStateAsync(IAWSResource resource, ILookup parentChildLookup, Func stateFactory) + { + // Update the state of the IAWSRsource and all it's children + await notificationService.PublishUpdateAsync(resource, stateFactory).ConfigureAwait(false); + foreach (var child in parentChildLookup[resource]) + { + await notificationService.PublishUpdateAsync(child, stateFactory).ConfigureAwait(false); + } + } + + #endregion +} diff --git a/src/Aspire.Hosting.AWS/Aspire.Hosting.AWS.csproj b/src/Aspire.Hosting.AWS/Aspire.Hosting.AWS.csproj index 2376b8377b3..0762bd0523f 100644 --- a/src/Aspire.Hosting.AWS/Aspire.Hosting.AWS.csproj +++ b/src/Aspire.Hosting.AWS/Aspire.Hosting.AWS.csproj @@ -7,8 +7,7 @@ true aspire integration hosting aws Add support for provisioning AWS application resources and configuring the AWS SDK for .NET. - - 8.0.1-preview.8.24267.1 + $(NoWarn);CS8002 @@ -21,6 +20,7 @@ + diff --git a/src/Aspire.Hosting.AWS/CDK/CDKExtensions.cs b/src/Aspire.Hosting.AWS/CDK/CDKExtensions.cs new file mode 100644 index 00000000000..256bd4d10c7 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/CDKExtensions.cs @@ -0,0 +1,229 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon.CDK; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS; +using Aspire.Hosting.AWS.CDK; +using Aspire.Hosting.AWS.CloudFormation; +using Constructs; +using Environment = System.Environment; + +namespace Aspire.Hosting; + +/// +/// Extension methods for adding AWS CDK as a provisioning resources. +/// +public static class CDKExtensions +{ + /// + /// Adds an AWS CDK stack as resource. The CloudFormation stack name will be the resource name prefixed with 'Aspire-' + /// + /// The . + /// The name of the stack resource. + public static IResourceBuilder AddAWSCDKStack(this IDistributedApplicationBuilder builder, string name) + => AddAWSCDKStack(builder, name, name); + + /// + /// Adds an AWS CDK stack as resource. + /// + /// The . + /// The name of the stack resource. + /// Cloud Formation stack same if different from the resource name. + /// + public static IResourceBuilder AddAWSCDKStack(this IDistributedApplicationBuilder builder, string name, + string stackName) + { + builder.AddAWSProvisioning(); + var resource = new StackResource(name, new Stack(ResolveCDKApp(builder), stackName)); + return builder + .AddResource(resource) + .WithInitialState(new() + { + Properties = [], + ResourceType = GetResourceType(resource), + }) + .WithManifestPublishingCallback(resource.WriteToManifest); + } + + /// + /// Adds and build an AWS CDK stack as resource. + /// + /// The . + /// The name of the resource. + /// The stack builder delegate. + /// + public static IResourceBuilder> AddAWSCDKStack(this IDistributedApplicationBuilder builder, + string name, ConstructBuilderDelegate stackBuilder) + where T : Stack + { + builder.AddAWSProvisioning(); + var resource = new StackResource(name, stackBuilder(ResolveCDKApp(builder))); + return builder + .AddResource(resource) + .WithInitialState(new() + { + Properties = [], + ResourceType = GetResourceType(resource), + }) + .WithManifestPublishingCallback(resource.WriteToManifest); + } + + /// + /// Adds and build an AWS CDK construct as resource. + /// + /// The construct resource builder. + /// The name of the resource. + /// The construct builder delegate. + /// + public static IResourceBuilder> AddConstruct( + this IResourceBuilder builder, string name, + ConstructBuilderDelegate constructBuilder) + where T : Construct + { + var parent = builder.Resource; + var resource = new ConstructResource(name, constructBuilder((Construct)parent.Construct), parent); + return builder.ApplicationBuilder + .AddResource(resource) + .WithInitialState(new() + { + Properties = [], + ResourceType = GetResourceType(resource), + }) + .ExcludeFromManifest(); + } + + /// + /// Adds a stack reference to an output from the CloudFormation stack. + /// + /// The stack resource builder. + /// The name of the output. + /// The construct output delegate. + /// + /// + /// The following example shows creating a custom stack and reference the exposed ServiceUrl property + /// in a project. + /// + /// var service = builder + /// .AddStack("service", scope => new ServiceStack(scope, "ServiceStack") + /// .AddOutput("ServiceUrl", stack => stack.Service.ServiceUrl); + /// var api = builder.AddProject<Projects.Api>("api") + /// .AddReference(service); + /// + /// + public static IResourceBuilder> AddOutput( + this IResourceBuilder> builder, + string name, ConstructOutputDelegate output) + where TStack : Stack + { + return builder.WithAnnotation(new ConstructOutputAnnotation(name, output)); + } + + /// + /// Adds a construct reference to an output from the CloudFormation stack. + /// + /// The construct resource builder. + /// The name of the output. + /// The construct output delegate. + /// + /// + /// The following example shows creating a custom construct and reference the exposed ServiceUrl property + /// in a project. + /// + /// var service = stack + /// .AddConstruct("service", scope => new Service(scope, "service") + /// .AddOutput("ServiceUrl", construct => construct.ServiceUrl); + /// var api = builder.AddProject<Projects.Api>("api") + /// .AddReference(service); + /// + /// + public static IResourceBuilder> AddOutput( + this IResourceBuilder> builder, + string name, ConstructOutputDelegate output) + where T : Construct + { + return builder.WithAnnotation(new ConstructOutputAnnotation(name, output)); + } + + /// + /// Gets a reference to an output from the CloudFormation stack. + /// + /// The construct resource builder. + /// The name of the output. + /// The construct output delegate. + public static StackOutputReference GetOutput(this IResourceBuilder> builder, string name, ConstructOutputDelegate output) + where T : Construct + { + builder.WithAnnotation(new ConstructOutputAnnotation(name, output)); + return new StackOutputReference(builder.Resource.Construct.GetStackUniqueId() + name, builder.Resource.Parent.SelectParentResource()); + } + + /// + /// Adds a reference of an AWS CDK construct to a project.The output parameters of the construct are added to the project IConfiguration. + /// + /// The builder for the resource. + /// The construct resource. + /// The construct output delegate. + /// The name of the construct output + /// The optional config section in IConfiguration to add the output parameters. + public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder> construct, ConstructOutputDelegate outputDelegate, string outputName, string? configSection = null) + where TConstruct : IConstruct + where TDestination : IResourceWithEnvironment + { + configSection ??= $"{Constants.DefaultConfigSection}:{construct.Resource.Name}"; + var prefix = configSection.ToEnvironmentVariables(); + return builder.WithEnvironment($"{prefix}__{outputName}", construct, outputDelegate, outputName); + } + + /// + /// Add an environment variable with a reference of a AWS CDK construct to a project. The output parameters of the CloudFormation stack are added to the project IConfiguration. + /// + /// The resource builder. + /// The name of the environment variable. + /// The construct resource. + /// The construct output delegate. + /// The name of the construct output + /// /// + /// The following example shows creating a custom construct and reference the exposed ServiceUrl property + /// in a project as environment variable. + /// + /// var service = stack.AddConstruct("service", scope => new Service(scope, "service"); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithEnvironment("Service_ServiceUrl", service, s => s.ServiceUrl); + /// + /// + public static IResourceBuilder WithEnvironment(this IResourceBuilder builder, string name, IResourceBuilder> construct, ConstructOutputDelegate outputDelegate, string? outputName = default) + where TConstruct : IConstruct + where TDestination : IResourceWithEnvironment + { + outputName ??= name.Replace("_", string.Empty); + if (construct.Resource.Annotations.OfType().All(annotation => annotation.OutputName != outputName)) + { + construct.WithAnnotation(new ConstructOutputAnnotation(outputName, outputDelegate)); + } + construct.WithAnnotation(new ConstructReferenceAnnotation(builder.Resource.Name, outputName)); + return builder.WithEnvironment(name, new StackOutputReference(construct.Resource.Construct.GetStackUniqueId() + outputName, construct.Resource.Parent.SelectParentResource())); + } + + private static string GetResourceType(IResourceWithConstruct constructResource) + where T : Construct + { + var constructType = constructResource.Construct.GetType(); + var baseConstructType = typeof(T); + return constructType == baseConstructType ? baseConstructType.Name : constructType.Name; + } + + /// + /// Lookup existing stack resources and reuse the AWS CDK app. + /// + /// + private static App ResolveCDKApp(IDistributedApplicationBuilder builder) + { + var stackResource = builder.Resources.OfType().FirstOrDefault(); + if (stackResource != null) + { + return (App)stackResource.Stack.Node.Root; + } + return new App(new AppProps() { Outdir = Path.Combine(Environment.CurrentDirectory, "cdk.out") }); + } +} diff --git a/src/Aspire.Hosting.AWS/CDK/CloudAssemblyResourceAnnotation.cs b/src/Aspire.Hosting.AWS/CDK/CloudAssemblyResourceAnnotation.cs new file mode 100644 index 00000000000..d63773e80f9 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/CloudAssemblyResourceAnnotation.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.Diagnostics; +using Amazon.CDK.CXAPI; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.AWS.CDK; + +/// +/// Annotations that stores the to the stack resource. +/// +/// +[DebuggerDisplay("Type = {GetType().Name,nq}, StackName = {StackArtifact.StackName}")] +internal sealed class CloudAssemblyResourceAnnotation(CloudFormationStackArtifact stackArtifact) : IResourceAnnotation +{ + public CloudFormationStackArtifact StackArtifact { get; } = stackArtifact; +} diff --git a/src/Aspire.Hosting.AWS/CDK/ConstructBuilderDelegate.cs b/src/Aspire.Hosting.AWS/CDK/ConstructBuilderDelegate.cs new file mode 100644 index 00000000000..a99587fb2df --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/ConstructBuilderDelegate.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 Constructs; + +namespace Aspire.Hosting.AWS.CDK; + +/// +/// Delegate for building an AWS CDK construct +/// +/// Construct type +public delegate T ConstructBuilderDelegate(Construct scope) where T : IConstruct; diff --git a/src/Aspire.Hosting.AWS/CDK/ConstructOutputAnnotation.cs b/src/Aspire.Hosting.AWS/CDK/ConstructOutputAnnotation.cs new file mode 100644 index 00000000000..428c30c3b4e --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/ConstructOutputAnnotation.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon.CDK; +using Constructs; + +namespace Aspire.Hosting.AWS.CDK; + +internal sealed class ConstructOutputAnnotation(string name, ConstructOutputDelegate output) + : IConstructModifierAnnotation, IConstructOutputAnnotation + where T : IConstruct +{ + public string OutputName { get; } = name; + + /// + public void ChangeConstruct(IConstruct construct) + { + // Find the stack where this construct belongs to. + if (construct is not Stack stack) + { + stack = construct.Node.Scopes.OfType().FirstOrDefault() ?? throw new InvalidOperationException("Construct is not part of a Stack"); + } + + // Add a CloudFormation output on the stack referencing the construct and the resolved value. + _ = new CfnOutput(stack, OutputName, new CfnOutputProps + { + Key = $"{construct.GetStackUniqueId()}{OutputName}", + Value = output((T)construct) + }); + } +} diff --git a/src/Aspire.Hosting.AWS/CDK/ConstructOutputDelegate.cs b/src/Aspire.Hosting.AWS/CDK/ConstructOutputDelegate.cs new file mode 100644 index 00000000000..6524e0cbfe0 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/ConstructOutputDelegate.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using Constructs; + +namespace Aspire.Hosting.AWS.CDK; + +/// +/// Delegate for resolving outputs of a construct. +/// +/// Construct type +public delegate string ConstructOutputDelegate(T construct) where T : IConstruct; diff --git a/src/Aspire.Hosting.AWS/CDK/ConstructReferenceAnnotation.cs b/src/Aspire.Hosting.AWS/CDK/ConstructReferenceAnnotation.cs new file mode 100644 index 00000000000..89ab12aa584 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/ConstructReferenceAnnotation.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.AWS.CDK; + +/// +/// Annotations that records a reference of Construct resources to target resources like projects. +/// +/// +/// +[DebuggerDisplay("Type = {GetType().Name,nq}, TargetResource = {TargetResource}, OutputName = {OutputName}")] +internal sealed class ConstructReferenceAnnotation(string targetResource, string outputName) : IResourceAnnotation +{ + /// + /// The name of the target resource. + /// + internal string TargetResource { get; } = targetResource; + + /// + /// The name of the output. + /// + internal string OutputName { get; } = outputName; +} diff --git a/src/Aspire.Hosting.AWS/CDK/ConstructResource.cs b/src/Aspire.Hosting.AWS/CDK/ConstructResource.cs new file mode 100644 index 00000000000..5263a37f8f8 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/ConstructResource.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Constructs; + +namespace Aspire.Hosting.AWS.CDK; + +/// +internal class ConstructResource(string name, IConstruct construct, IResourceWithConstruct parent) : Resource(name), IConstructResource +{ + /// + public IConstruct Construct { get; } = construct; + + /// + public IResourceWithConstruct Parent { get; } = parent; +} + +/// +internal sealed class ConstructResource(string name, T construct, IResourceWithConstruct parent) : ConstructResource(name, construct, parent), IConstructResource + where T : IConstruct +{ + public new T Construct { get; } = construct; +} diff --git a/src/Aspire.Hosting.AWS/CDK/IConstructModifierAnnotation.cs b/src/Aspire.Hosting.AWS/CDK/IConstructModifierAnnotation.cs new file mode 100644 index 00000000000..5c60248877d --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/IConstructModifierAnnotation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Constructs; + +namespace Aspire.Hosting.AWS.CDK; + +/// +/// Resource annotation to change an AWS CDK construct. When a construct is created it doesn't have all the information +/// that it requires. This interface is implemented on resource annotations like +/// to add additional outputs referencing construct. +/// +/// +/// This interface is internal and is intended for use by the AWS CDK framework only. +/// +internal interface IConstructModifierAnnotation : IResourceAnnotation +{ + /// + /// Changes the AWS CDK construct before starting the . This is useful for + /// altering the construct before it is synthesized, like adding additional outputs. + /// + /// Construct to be changed. + void ChangeConstruct(IConstruct construct); +} diff --git a/src/Aspire.Hosting.AWS/CDK/IConstructOutputAnnotation.cs b/src/Aspire.Hosting.AWS/CDK/IConstructOutputAnnotation.cs new file mode 100644 index 00000000000..95685f8f37c --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/IConstructOutputAnnotation.cs @@ -0,0 +1,17 @@ +// 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.CDK; + +/// +/// This interface is used to mark a construct resource so that it can be identified as an output +/// during synthesis. This is needed in to map the output to the environment variables after synthesizing +/// that would not normally be possible. +/// +/// +/// This interface is internal and is intended for use by the AWS CDK framework only. +/// +internal interface IConstructOutputAnnotation +{ + string OutputName { get; } +} diff --git a/src/Aspire.Hosting.AWS/CDK/IConstructResource.cs b/src/Aspire.Hosting.AWS/CDK/IConstructResource.cs new file mode 100644 index 00000000000..729ba57c7b2 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/IConstructResource.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.AWS.CDK; + +/// +/// Resource representing an AWS CDK construct. +/// +public interface IConstructResource : IResourceWithParent, IResourceWithConstruct; diff --git a/src/Aspire.Hosting.AWS/CDK/IConstructResourceOfT.cs b/src/Aspire.Hosting.AWS/CDK/IConstructResourceOfT.cs new file mode 100644 index 00000000000..dc2a1943b13 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/IConstructResourceOfT.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. + +using Constructs; + +namespace Aspire.Hosting.AWS.CDK; + +/// +public interface IConstructResource : IConstructResource, IResourceWithConstruct where T : IConstruct; diff --git a/src/Aspire.Hosting.AWS/CDK/IResourceWithConstruct.cs b/src/Aspire.Hosting.AWS/CDK/IResourceWithConstruct.cs new file mode 100644 index 00000000000..76adcb60a67 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/IResourceWithConstruct.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 Aspire.Hosting.ApplicationModel; +using Constructs; + +namespace Aspire.Hosting.AWS.CDK; + +/// +/// Represents a resource that has an AWS CDK construct. +/// +public interface IResourceWithConstruct : IResource +{ + /// + /// The AWS CDK construct + /// + IConstruct Construct { get; } +} diff --git a/src/Aspire.Hosting.AWS/CDK/IResourceWithConstructOfT.cs b/src/Aspire.Hosting.AWS/CDK/IResourceWithConstructOfT.cs new file mode 100644 index 00000000000..d3bb69b928c --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/IResourceWithConstructOfT.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Constructs; + +namespace Aspire.Hosting.AWS.CDK; + +/// +public interface IResourceWithConstruct : IResourceWithConstruct + where T : IConstruct +{ + /// + new T Construct { get; } +} diff --git a/src/Aspire.Hosting.AWS/CDK/IStackResource.cs b/src/Aspire.Hosting.AWS/CDK/IStackResource.cs new file mode 100644 index 00000000000..4021608d02f --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/IStackResource.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 Amazon.CDK; +using Aspire.Hosting.AWS.CloudFormation; + +namespace Aspire.Hosting.AWS.CDK; + +/// +/// Resource representing an AWS CDK stack. +/// +public interface IStackResource : ICloudFormationTemplateResource, IResourceWithConstruct +{ + /// + /// The AWS CDK stack + /// + Stack Stack { get; } +} diff --git a/src/Aspire.Hosting.AWS/CDK/IStackResourceOfT.cs b/src/Aspire.Hosting.AWS/CDK/IStackResourceOfT.cs new file mode 100644 index 00000000000..85ad43a1677 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/IStackResourceOfT.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon.CDK; + +namespace Aspire.Hosting.AWS.CDK; + +/// +public interface IStackResource : IStackResource, IResourceWithConstruct + where T : Stack +{ + /// + /// The AWS CDK stack + /// + new T Stack { get; } +} diff --git a/src/Aspire.Hosting.AWS/CDK/Resources/CognitoResourceExtensions.cs b/src/Aspire.Hosting.AWS/CDK/Resources/CognitoResourceExtensions.cs new file mode 100644 index 00000000000..37d5161794c --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/Resources/CognitoResourceExtensions.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon.CDK.AWS.Cognito; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS; +using Aspire.Hosting.AWS.CDK; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Amazon Cognito resources to the application model. +/// +public static class CognitoResourceExtensions +{ + + private const string UserPoolIdOutputName = "UserPoolId"; + + /// + /// Adds an Amazon Cognito user pool. + /// + /// The builder for the AWS CDK stack. + /// The name of the resource. + /// The properties of the userpool. + public static IResourceBuilder> AddCognitoUserPool(this IResourceBuilder builder, string name, IUserPoolProps? props = null) + { + return builder.AddConstruct(name, scope => new UserPool(scope, name, props)); + } + + /// + /// Adds an Amazon Cognito user pool client. + /// + /// The builder for the user pool. + /// the name of the resource. + /// The options of the client. + public static IResourceBuilder> AddClient(this IResourceBuilder> builder, string name, IUserPoolClientOptions? options = null) + { + return builder.AddConstruct(name, _ => builder.Resource.Construct.AddClient(name, options)); + } + + /// + /// Adds a reference of an Amazon Cognito user pool to a project. The output parameters of the user pool are added to the project IConfiguration. + /// + /// The builder for the resource. + /// The Amazon Cognito user pool resource. + /// The optional config section in IConfiguration to add the output parameters. + public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder> userPool, string? configSection = null) + where TDestination : IResourceWithEnvironment + { + configSection ??= $"{Constants.DefaultConfigSection}:{userPool.Resource.Name}"; + var prefix = configSection.ToEnvironmentVariables(); + return builder.WithEnvironment($"{prefix}__{UserPoolIdOutputName}", userPool, p => p.UserPoolId, UserPoolIdOutputName); + } +} diff --git a/src/Aspire.Hosting.AWS/CDK/Resources/DynamoDBResourceExtensions.cs b/src/Aspire.Hosting.AWS/CDK/Resources/DynamoDBResourceExtensions.cs new file mode 100644 index 00000000000..7a277f969f3 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/Resources/DynamoDBResourceExtensions.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon.CDK.AWS.DynamoDB; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS; +using Aspire.Hosting.AWS.CDK; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Amazon DynamoDB resources to the application model. +/// +public static class DynamoDBResourceExtensions +{ + + private const string TableNameOutputName = "TableName"; + + /// + /// Adds an Amazon DynamoDB table. + /// + /// The builder for the AWS CDK stack. + /// The name of the resource. + /// The properties of the table. + public static IResourceBuilder> AddDynamoDBTable(this IResourceBuilder builder, string name, ITableProps props) + { + return builder.AddConstruct(name, scope => new Table(scope, name, props)); + } + + /// + /// Adds an global secondary index to the table. + /// + /// The builder for the table resource. + /// The properties for the global secondary index. + public static IResourceBuilder> AddGlobalSecondaryIndex(this IResourceBuilder> builder, IGlobalSecondaryIndexProps props) + { + builder.Resource.Construct.AddGlobalSecondaryIndex(props); + return builder; + } + + /// + /// Adds a local secondary index to the table. + /// + /// The builder for the table resource. + /// The properties for the local secondary index. + public static IResourceBuilder> AddLocalSecondaryIndex(this IResourceBuilder> builder, ILocalSecondaryIndexProps props) + { + builder.Resource.Construct.AddLocalSecondaryIndex(props); + return builder; + } + + /// + /// Adds a reference of an Amazon DynamoDB table to a project. The output parameters of the table are added to the project IConfiguration. + /// + /// The builder for the resource. + /// The Amazon DynamoDB table resource. + /// The optional config section in IConfiguration to add the output parameters. + public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder> table, string? configSection = null) + where TDestination : IResourceWithEnvironment + { + configSection ??= $"{Constants.DefaultConfigSection}:{table.Resource.Name}"; + var prefix = configSection.ToEnvironmentVariables(); + return builder.WithEnvironment($"{prefix}__{TableNameOutputName}", table, t => t.TableName, TableNameOutputName); + } +} diff --git a/src/Aspire.Hosting.AWS/CDK/Resources/KinesisResourceExtensions.cs b/src/Aspire.Hosting.AWS/CDK/Resources/KinesisResourceExtensions.cs new file mode 100644 index 00000000000..ecfb14a8c5d --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/Resources/KinesisResourceExtensions.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon.CDK.AWS.Kinesis; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS; +using Aspire.Hosting.AWS.CDK; +using Stream = Amazon.CDK.AWS.Kinesis.Stream; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Amazon SNS resources to the application model. +/// +public static class KinesisResourceExtensions +{ + + private const string StreamArnOutputName = "StreamArn"; + + /// + /// Adds an Amazon Kinesis stream. + /// + /// The builder for the AWS CDK stack. + /// The name of the resource. + /// The properties of the stream. + public static IResourceBuilder> AddKinesisStream(this IResourceBuilder builder, string name, IStreamProps? props = null) + { + return builder.AddConstruct(name, scope => new Stream(scope, name, props)); + } + + /// + /// Adds a reference of an Amazon Kinesis stream to a project. The output parameters of the stream are added to the project IConfiguration. + /// + /// The builder for the resource. + /// The Amazon Kinesis stream resource. + /// The optional config section in IConfiguration to add the output parameters. + public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder> stream, string? configSection = null) + where TDestination : IResourceWithEnvironment + { + configSection ??= $"{Constants.DefaultConfigSection}:{stream.Resource.Name}"; + var prefix = configSection.ToEnvironmentVariables(); + return builder.WithEnvironment($"{prefix}__{StreamArnOutputName}", stream, s => s.StreamArn, StreamArnOutputName); + } +} diff --git a/src/Aspire.Hosting.AWS/CDK/Resources/S3ResourceExtensions.cs b/src/Aspire.Hosting.AWS/CDK/Resources/S3ResourceExtensions.cs new file mode 100644 index 00000000000..802df8da0f2 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/Resources/S3ResourceExtensions.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon.CDK.AWS.S3; +using Amazon.CDK.AWS.S3.Notifications; +using Amazon.CDK.AWS.SNS; +using Amazon.CDK.AWS.SQS; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS; +using Aspire.Hosting.AWS.CDK; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Amazon S3 resources to the application model. +/// +public static class S3ResourceExtensions +{ + + private const string BucketNameOutputName = "BucketName"; + + /// + /// Adds an Amazon S3 bucket. + /// + /// The builder for the AWS CDK stack. + /// The name of the resource. + /// The properties of the bucket. + public static IResourceBuilder> AddS3Bucket(this IResourceBuilder builder, string name, IBucketProps? props = null) + { + return builder.AddConstruct(name, scope => new Bucket(scope, name, props)); + } + + /// Subscribes a destination to receive notifications when an object is created in the bucket. + /// The builder for the bucket resource. + /// The notification destination queue. + /// The type of bucket event. + /// Filters. + public static IResourceBuilder> AddEventNotification(this IResourceBuilder> builder, IResourceBuilder> destination, EventType eventType, params INotificationKeyFilter[] filters) + { + builder.Resource.Construct.AddEventNotification(eventType, new SqsDestination(destination.Resource.Construct), filters); + return builder; + } + + /// Subscribes a destination to receive notifications when an object is created in the bucket. + /// The builder for the bucket resource. + /// The notification destination queue. + /// Filters. + public static IResourceBuilder> AddObjectCreatedNotification(this IResourceBuilder> builder, IResourceBuilder> destination, params INotificationKeyFilter[] filters) + { + builder.Resource.Construct.AddObjectCreatedNotification(new SqsDestination(destination.Resource.Construct), filters); + return builder; + } + + /// Subscribes a destination to receive notifications when an object is created in the bucket. + /// The builder for the bucket resource. + /// The notification destination topic. + /// Filters. + public static IResourceBuilder> AddObjectCreatedNotification(this IResourceBuilder> builder, IResourceBuilder> destination, params INotificationKeyFilter[] filters) + { + builder.Resource.Construct.AddObjectCreatedNotification(new SnsDestination(destination.Resource.Construct), filters); + return builder; + } + + /// Subscribes a destination to receive notifications when an object is removed from the bucket. + /// The builder for the bucket resource. + /// The notification destination queue. + /// Filters. + public static IResourceBuilder> AddObjectRemovedNotification(this IResourceBuilder> builder, IResourceBuilder> destination, params INotificationKeyFilter[] filters) + { + builder.Resource.Construct.AddObjectCreatedNotification(new SqsDestination(destination.Resource.Construct), filters); + return builder; + } + + /// Subscribes a destination to receive notifications when an object is removed from the bucket. + /// The builder for the bucket resource. + /// The notification destination topic. + /// Filters. + public static IResourceBuilder> AddObjectRemovedNotification(this IResourceBuilder> builder, IResourceBuilder> destination, params INotificationKeyFilter[] filters) + { + builder.Resource.Construct.AddObjectRemovedNotification(new SnsDestination(destination.Resource.Construct), filters); + return builder; + } + + /// + /// Adds a reference of an Amazon S3 bucket to a project. The output parameters of the bucket are added to the project IConfiguration. + /// + /// The builder for the resource. + /// The Amazon S3 bucket resource. + /// The optional config section in IConfiguration to add the output parameters. + public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder> bucket, string? configSection = null) + where TDestination : IResourceWithEnvironment + { + configSection ??= $"{Constants.DefaultConfigSection}:{bucket.Resource.Name}"; + var prefix = configSection.ToEnvironmentVariables(); + return builder.WithEnvironment($"{prefix}__{BucketNameOutputName}", bucket, b => b.BucketName, BucketNameOutputName); + } +} diff --git a/src/Aspire.Hosting.AWS/CDK/Resources/SNSResourceExtensions.cs b/src/Aspire.Hosting.AWS/CDK/Resources/SNSResourceExtensions.cs new file mode 100644 index 00000000000..c1403359543 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/Resources/SNSResourceExtensions.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon.CDK.AWS.SNS; +using Amazon.CDK.AWS.SNS.Subscriptions; +using Amazon.CDK.AWS.SQS; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS; +using Aspire.Hosting.AWS.CDK; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Amazon SNS resources to the application model. +/// +public static class SNSResourceExtensions +{ + + private const string TopicArnOutputName = "TopicArn"; + + /// + /// Adds an Amazon SNS topic. + /// + /// The builder for the AWS CDK stack. + /// The name of the resource. + /// The properties of the topic. + public static IResourceBuilder> AddSNSTopic(this IResourceBuilder builder, string name, ITopicProps? props = null) + { + return builder.AddConstruct(name, scope => new Topic(scope, name, props)); + } + + /// Subscribe some endpoint to this topic. + /// The builder for the topic resource. + /// The notification destination queue. + /// >Properties for an SQS subscription. + public static IResourceBuilder> AddSubscription(this IResourceBuilder> builder, IResourceBuilder> destination, SqsSubscriptionProps? props = null) + { + builder.Resource.Construct.AddSubscription(new SqsSubscription(destination.Resource.Construct, props)); + return builder; + } + + /// + /// Adds a reference of an Amazon SNS topic to a project. The output parameters of the topic are added to the project IConfiguration. + /// + /// The builder for the resource. + /// The Amazon SNS topic resource. + /// The optional config section in IConfiguration to add the output parameters. + public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder> topic, string? configSection = null) + where TDestination : IResourceWithEnvironment + { + configSection ??= $"{Constants.DefaultConfigSection}:{topic.Resource.Name}"; + var prefix = configSection.ToEnvironmentVariables(); + return builder.WithEnvironment($"{prefix}__{TopicArnOutputName}", topic, t => t.TopicArn, TopicArnOutputName); + } +} diff --git a/src/Aspire.Hosting.AWS/CDK/Resources/SQSResourceExtensions.cs b/src/Aspire.Hosting.AWS/CDK/Resources/SQSResourceExtensions.cs new file mode 100644 index 00000000000..ff6b4062fa6 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/Resources/SQSResourceExtensions.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 Amazon.CDK.AWS.SQS; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS; +using Aspire.Hosting.AWS.CDK; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Amazon SQS resources to the application model. +/// +public static class SQSResourceExtensions +{ + + private const string QueueUrlOutputName = "QueueUrl"; + + /// + /// Adds an Amazon SQS queue. + /// + /// The builder for the AWS CDK stack. + /// The name of the resource. + /// The properties of the queue. + public static IResourceBuilder> AddSQSQueue(this IResourceBuilder builder, string name, IQueueProps? props = null) + { + return builder.AddConstruct(name, scope => new Queue(scope, name, props)); + } + + /// + /// Adds a reference of an Amazon SQS queue to a project. The output parameters of the queue are added to the project IConfiguration. + /// + /// The builder for the resource. + /// The Amazon SQS queue resource. + /// The optional config section in IConfiguration to add the output parameters. + public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder> queue, string? configSection = null) + where TDestination : IResourceWithEnvironment + { + configSection ??= $"{Constants.DefaultConfigSection}:{queue.Resource.Name}"; + var prefix = configSection.ToEnvironmentVariables(); + return builder.WithEnvironment($"{prefix}__{QueueUrlOutputName}", queue, q => q.QueueUrl, QueueUrlOutputName); + } +} diff --git a/src/Aspire.Hosting.AWS/CDK/StackResource.cs b/src/Aspire.Hosting.AWS/CDK/StackResource.cs new file mode 100644 index 00000000000..203d7563a61 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/StackResource.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. + +using Amazon.CDK; +using Aspire.Hosting.AWS.CloudFormation; +using Constructs; +using Stack = Amazon.CDK.Stack; + +namespace Aspire.Hosting.AWS.CDK; + +/// +internal class StackResource(string name, Stack stack) : CloudFormationTemplateResource(name, stack.StackName, stack.GetTemplatePath()), IStackResource +{ + /// + public Stack Stack { get; } = stack; + + /// + public IConstruct Construct => Stack; + + /// + /// The AWS CDK App the stack belongs to. This is needed for building the AWS CDK app tree. + /// + public App App => (App)Stack.Node.Root; +} + +/// +internal sealed class StackResource(string name, T stack) : StackResource(name, stack), IStackResource + where T : Stack +{ + public new T Stack { get; } = stack; + public new T Construct => Stack; +} diff --git a/src/Aspire.Hosting.AWS/CDK/Utils/ConstructExtensions.cs b/src/Aspire.Hosting.AWS/CDK/Utils/ConstructExtensions.cs new file mode 100644 index 00000000000..fc363648ad4 --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/Utils/ConstructExtensions.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.CDK; +using Constructs; + +namespace Aspire.Hosting.AWS.CDK; + +internal static class ConstructExtensions +{ + /// + /// Returns the computed unique ID of a construct in the stack. + /// + /// The construct in the stack + public static string GetStackUniqueId(this IConstruct construct) + { + var stack = construct.GetStack(); + return stack is null ? Names.UniqueId(construct) : Names.UniqueId(construct).TrimStart(Names.UniqueId(stack)); + } + + /// + /// Returns the stack of the construct. + /// + /// The construct in the stack + private static Stack? GetStack(this IConstruct construct) + => construct.Node.Scopes.OfType().FirstOrDefault(); + + /// + /// Returns the path of the CloudFormation template file for this stack. + /// + /// The stack + /// The path of the CloudFormation template file for this stack + public static string GetTemplatePath(this Stack stack) + { + return Path.Combine(((App)stack.Node.Root).Outdir, $"{stack.StackName}.template.json"); + } +} diff --git a/src/Aspire.Hosting.AWS/CDK/Utils/ResourceExtensions.cs b/src/Aspire.Hosting.AWS/CDK/Utils/ResourceExtensions.cs new file mode 100644 index 00000000000..986334b36ce --- /dev/null +++ b/src/Aspire.Hosting.AWS/CDK/Utils/ResourceExtensions.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.Diagnostics.CodeAnalysis; +using Amazon.CDK.CXAPI; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.AWS.CDK; + +internal static class ResourceExtensions +{ + /// + /// Resolves a from a resource. + /// + /// + /// + /// False when no is not found as annotation of the resource. + public static bool TryGetStackArtifact(this IStackResource resource, [NotNullWhen(true)] out CloudFormationStackArtifact? stackArtifact) + { + stackArtifact = default; + if (!resource.TryGetAnnotationsOfType(out var annotations)) + { + return false; + } + stackArtifact = annotations.First().StackArtifact; + return true; + } +} diff --git a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationExtensions.cs b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationExtensions.cs index 88161e528e9..d0a60dbf5fc 100644 --- a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationExtensions.cs +++ b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationExtensions.cs @@ -5,7 +5,6 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.AWS; using Aspire.Hosting.AWS.CloudFormation; -using Aspire.Hosting.Lifecycle; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -19,17 +18,22 @@ public static class CloudFormationExtensions /// Add a CloudFormation stack for provisioning application resources. /// /// The . - /// The name of the CloudFormation stack. + /// The name of the resource. + /// The name of the CloudFormation stack. If not specified, the CloudFormation stack name will be the resource name prefixed with 'Aspire-' /// The path to the CloudFormation template that defines the CloudFormation stack. /// - public static IResourceBuilder AddAWSCloudFormationTemplate(this IDistributedApplicationBuilder builder, string stackName, string templatePath) + public static IResourceBuilder AddAWSCloudFormationTemplate(this IDistributedApplicationBuilder builder, string name, string templatePath, string? stackName = null) { - var resource = new CloudFormationTemplateResource(stackName, templatePath); - var cfBuilder = builder.AddResource(resource) - .WithManifestPublishingCallback(resource.WriteToManifest); - - builder.Services.TryAddLifecycleHook(); - return cfBuilder; + builder.AddAWSProvisioning(); + var resource = new CloudFormationTemplateResource(name, stackName ?? name, templatePath); + return builder + .AddResource(resource) + .WithInitialState(new() + { + Properties = [], + ResourceType = "CloudFormationTemplate", + }) + .WithManifestPublishingCallback(resource.WriteToManifest); } /// @@ -48,17 +52,22 @@ public static IResourceBuilder WithParameter(th /// /// Add a CloudFormation stack for provisioning application resources. /// - /// - /// The name of the CloudFormation stack. + /// The . + /// The name of the resource. + /// The name of the CloudFormation stack. If not specified, the CloudFormation stack name will be the resource name prefixed with 'Aspire-' /// - public static IResourceBuilder AddAWSCloudFormationStack(this IDistributedApplicationBuilder builder, string stackName) + public static IResourceBuilder AddAWSCloudFormationStack(this IDistributedApplicationBuilder builder, string name, string? stackName = null) { - var resource = new CloudFormationStackResource(stackName); - var cfBuilder = builder.AddResource(resource) - .WithManifestPublishingCallback(resource.WriteToManifest); - - builder.Services.TryAddLifecycleHook(); - return cfBuilder; + builder.AddAWSProvisioning(); + var resource = new CloudFormationStackResource(name, stackName ?? name); + return builder + .AddResource(resource) + .WithInitialState(new() + { + Properties = [], + ResourceType = "CloudFormationStack", + }) + .WithManifestPublishingCallback(resource.WriteToManifest); } /// @@ -83,6 +92,8 @@ public static StackOutputReference GetOutput(this IResourceBuilder WithEnvironment(this IResourceBuilder builder, string name, StackOutputReference stackOutputReference) where T : IResourceWithEnvironment { + stackOutputReference.Resource.Annotations.Add(new CloudFormationReferenceAnnotation(builder.Resource.Name)); + return builder.WithEnvironment(async ctx => { if (ctx.ExecutionContext.IsPublishMode) @@ -107,18 +118,8 @@ public static IResourceBuilder WithEnvironment(this IResourceBuilder bu /// /// /// The name of the AWS credential profile. - public static IResourceBuilder WithReference(this IResourceBuilder builder, IAWSSDKConfig awsSdkConfig) - { - builder.Resource.AWSSDKConfig = awsSdkConfig; - return builder; - } - - /// - /// The AWS SDK service client configuration used to create the CloudFormation service client. - /// - /// - /// The name of the AWS credential profile. - public static IResourceBuilder WithReference(this IResourceBuilder builder, IAWSSDKConfig awsSdkConfig) + public static IResourceBuilder WithReference(this IResourceBuilder builder, IAWSSDKConfig awsSdkConfig) + where TDestination : ICloudFormationResource { builder.Resource.AWSSDKConfig = awsSdkConfig; return builder; @@ -130,19 +131,8 @@ public static IResourceBuilder WithReference(th /// /// /// The AWS CloudFormation service client. - public static IResourceBuilder WithReference(this IResourceBuilder builder, IAmazonCloudFormation cloudFormationClient) - { - builder.Resource.CloudFormationClient = cloudFormationClient; - return builder; - } - - /// - /// Override the CloudFormation service client the ICloudFormationTemplateResource would create to interact with the CloudFormation service. This can be used for pointing the - /// CloudFormation service client to a non-standard CloudFormation endpoint like an emulator. - /// - /// - /// The AWS CloudFormation service client. - public static IResourceBuilder WithReference(this IResourceBuilder builder, IAmazonCloudFormation cloudFormationClient) + public static IResourceBuilder WithReference(this IResourceBuilder builder, IAmazonCloudFormation cloudFormationClient) + where TDestination : ICloudFormationResource { builder.Resource.CloudFormationClient = cloudFormationClient; return builder; @@ -156,7 +146,7 @@ public static IResourceBuilder WithReference(th /// The CloudFormation resource. /// The config section in IConfiguration to add the output parameters. /// - public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder cloudFormationResourceBuilder, string configSection = "AWS::Resources") + public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder cloudFormationResourceBuilder, string configSection = Constants.DefaultConfigSection) where TDestination : IResourceWithEnvironment { cloudFormationResourceBuilder.WithAnnotation(new CloudFormationReferenceAnnotation(builder.Resource.Name)); @@ -184,7 +174,7 @@ public static IResourceBuilder WithReference(this IR return; } - configSection = configSection.Replace(':', '_'); + configSection = configSection.ToEnvironmentVariables(); foreach (var output in cloudFormationResourceBuilder.Resource.Outputs) { diff --git a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationLifecycleHook.cs b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationLifecycleHook.cs deleted file mode 100644 index 7edeecfc332..00000000000 --- a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationLifecycleHook.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Lifecycle; - -namespace Aspire.Hosting.AWS.CloudFormation; - -/// -/// The lifecycle hook that handles deploying the CloudFormation template to a CloudFormation stack. -/// -/// -/// -/// -internal sealed class CloudFormationLifecycleHook( - DistributedApplicationExecutionContext executionContext, - ResourceNotificationService notificationService, - ResourceLoggerService loggerService) : IDistributedApplicationLifecycleHook -{ - - public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) - { - if (executionContext.IsPublishMode) - { - return; - } - - foreach (CloudFormationResource cloudFormationResource in appModel.Resources.OfType()) - { - await notificationService.PublishUpdateAsync(cloudFormationResource, (state) => state with { State = Constants.ResourceStateStarting }).ConfigureAwait(false); - cloudFormationResource.ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); - } - - _ = Task.Run(() => new CloudFormationProvisioner(appModel, notificationService, loggerService).ConfigureCloudFormation(), cancellationToken); - } -} - diff --git a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationProvisioner.cs b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationProvisioner.cs deleted file mode 100644 index 16d542bce89..00000000000 --- a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationProvisioner.cs +++ /dev/null @@ -1,180 +0,0 @@ -// 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 Amazon.CloudFormation; -using Amazon.CloudFormation.Model; -using Amazon.Runtime; -using Aspire.Hosting.ApplicationModel; -using Microsoft.Extensions.Logging; - -namespace Aspire.Hosting.AWS.CloudFormation; -internal sealed class CloudFormationProvisioner( - DistributedApplicationModel appModel, - ResourceNotificationService notificationService, - ResourceLoggerService loggerService) -{ - internal async Task ConfigureCloudFormation(CancellationToken cancellationToken = default) - { - await ProcessCloudFormationStackResourceAsync(cancellationToken).ConfigureAwait(false); - await ProcessCloudFormationTemplateResourceAsync(cancellationToken).ConfigureAwait(false); - } - - private async Task ProcessCloudFormationStackResourceAsync(CancellationToken cancellationToken = default) - { - foreach (var cloudFormationResource in appModel.Resources.OfType()) - { - var logger = loggerService.GetLogger(cloudFormationResource); - - await PublishCloudFormationUpdateStateAsync(cloudFormationResource, Constants.ResourceStateStarting).ConfigureAwait(false); - - try - { - using var cfClient = GetCloudFormationClient(cloudFormationResource); - - var request = new DescribeStacksRequest { StackName = cloudFormationResource.Name }; - var response = await cfClient.DescribeStacksAsync(request, cancellationToken).ConfigureAwait(false); - - // If the stack didn't exist then a StackNotFoundException would have been thrown. - var stack = response.Stacks[0]; - - // Capture the CloudFormation stack output parameters on to the Aspire CloudFormation resource. This - // allows projects that have a reference to the stack have the output parameters applied to the - // projects IConfiguration. - cloudFormationResource.Outputs = stack!.Outputs; - - await PublishCloudFormationUpdateStateAsync(cloudFormationResource, Constants.ResourceStateRunning, ConvertOutputToProperties(stack)).ConfigureAwait(false); - - cloudFormationResource.ProvisioningTaskCompletionSource?.TrySetResult(); - } - catch (Exception e) - { - if (e is AmazonCloudFormationException ce && string.Equals(ce.ErrorCode, "ValidationError")) - { - logger.LogError("Stack {StackName} does not exists to add as a resource.", cloudFormationResource.Name); - } - else - { - logger.LogError(e, "Error reading {StackName}.", cloudFormationResource.Name); - } - - await PublishCloudFormationUpdateStateAsync(cloudFormationResource, Constants.ResourceStateFailedToStart).ConfigureAwait(false); - cloudFormationResource.ProvisioningTaskCompletionSource?.TrySetException(e); - } - } - } - - private async Task ProcessCloudFormationTemplateResourceAsync(CancellationToken cancellationToken = default) - { - foreach (var cloudFormationResource in appModel.Resources.OfType()) - { - var logger = loggerService.GetLogger(cloudFormationResource); - - try - { - await PublishCloudFormationUpdateStateAsync(cloudFormationResource, Constants.ResourceStateStarting).ConfigureAwait(false); - - using var cfClient = GetCloudFormationClient(cloudFormationResource); - - var executor = new CloudFormationStackExecutor(cfClient, cloudFormationResource, logger); - var stack = await executor.ExecuteTemplateAsync(cancellationToken).ConfigureAwait(false); - - if (stack != null) - { - logger.LogInformation("CloudFormation stack has {Count} output parameters", stack.Outputs.Count); - if (logger.IsEnabled(LogLevel.Information)) - { - foreach (var output in stack.Outputs) - { - logger.LogInformation("Output Name: {Name}, Value {Value}", output.OutputKey, output.OutputValue); - } - } - - logger.LogInformation("CloudFormation provisioning complete"); - - cloudFormationResource.Outputs = stack.Outputs; - await PublishCloudFormationUpdateStateAsync(cloudFormationResource, Constants.ResourceStateRunning, ConvertOutputToProperties(stack, cloudFormationResource.TemplatePath)).ConfigureAwait(false); - cloudFormationResource.ProvisioningTaskCompletionSource?.TrySetResult(); - } - else - { - logger.LogError("CloudFormation provisioning failed"); - - await PublishCloudFormationUpdateStateAsync(cloudFormationResource, Constants.ResourceStateFailedToStart).ConfigureAwait(false); - cloudFormationResource.ProvisioningTaskCompletionSource?.TrySetException(new AWSProvisioningException("Failed to apply CloudFormation template", null)); - } - } - catch (Exception ex) - { - logger.LogError(ex, "Error provisioning {ResourceName} CloudFormation resource", cloudFormationResource.Name); - await PublishCloudFormationUpdateStateAsync(cloudFormationResource, Constants.ResourceStateFailedToStart).ConfigureAwait(false); - cloudFormationResource.ProvisioningTaskCompletionSource?.TrySetException(ex); - } - } - } - - private async Task PublishCloudFormationUpdateStateAsync(CloudFormationResource resource, string status, ImmutableArray? properties = null) - { - if (properties == null) - { - properties = ImmutableArray.Create(); - } - - await notificationService.PublishUpdateAsync(resource, state => state with - { - State = status, - Properties = state.Properties.AddRange(properties) - }).ConfigureAwait(false); - } - - private static ImmutableArray ConvertOutputToProperties(Stack stack, string? templateFile = null) - { - var list = ImmutableArray.CreateBuilder(); - - foreach (var output in stack.Outputs) - { - list.Add(new("aws.cloudformation.output." + output.OutputKey, output.OutputValue)); - } - - list.Add(new(CustomResourceKnownProperties.Source, stack.StackId)); - - if (!string.IsNullOrEmpty(templateFile)) - { - list.Add(new("aws.cloudformation.template", templateFile)); - } - - return list.ToImmutableArray(); - } - - private static IAmazonCloudFormation GetCloudFormationClient(ICloudFormationResource resource) - { - if (resource.CloudFormationClient != null) - { - return resource.CloudFormationClient; - } - - try - { - AmazonCloudFormationClient client; - if (resource.AWSSDKConfig != null) - { - var config = resource.AWSSDKConfig.CreateServiceConfig(); - - var awsCredentials = FallbackCredentialsFactory.GetCredentials(config); - client = new AmazonCloudFormationClient(awsCredentials, config); - } - else - { - client = new AmazonCloudFormationClient(); - } - - client.BeforeRequestEvent += SdkUtilities.ConfigureUserAgentString; - - return client; - } - catch (Exception e) - { - throw new AWSProvisioningException("Failed to construct AWS CloudFormation service client to provision AWS resources.", e); - } - } -} diff --git a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationResource.cs b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationResource.cs index a334af29bbd..2f86f4653d0 100644 --- a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationResource.cs +++ b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationResource.cs @@ -4,12 +4,15 @@ using Amazon.CloudFormation; using Amazon.CloudFormation.Model; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Publishing; namespace Aspire.Hosting.AWS.CloudFormation; -/// -internal abstract class CloudFormationResource(string name) : Resource(name), ICloudFormationResource +/// +internal abstract class CloudFormationResource(string name, string stackName) : Resource(name), ICloudFormationResource { + public string StackName { get; } = stackName; + /// public IAWSSDKConfig? AWSSDKConfig { get; set; } @@ -21,4 +24,6 @@ internal abstract class CloudFormationResource(string name) : Resource(name), IC /// public TaskCompletionSource? ProvisioningTaskCompletionSource { get; set; } + + internal abstract void WriteToManifest(ManifestPublishingContext context); } diff --git a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationStackResource.cs b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationStackResource.cs index f836ffc458b..5e99fae38c1 100644 --- a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationStackResource.cs +++ b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationStackResource.cs @@ -6,13 +6,14 @@ namespace Aspire.Hosting.AWS.CloudFormation; -/// -internal sealed class CloudFormationStackResource(string name) : CloudFormationResource(name), ICloudFormationStackResource +/// +internal sealed class CloudFormationStackResource(string name, string stackName) + : CloudFormationResource(name, stackName), ICloudFormationStackResource { - internal void WriteToManifest(ManifestPublishingContext context) + internal override void WriteToManifest(ManifestPublishingContext context) { context.Writer.WriteString("type", "aws.cloudformation.stack.v0"); - context.Writer.TryWriteString("stack-name", Name); + context.Writer.TryWriteString("stack-name", StackName); context.Writer.WritePropertyName("references"); context.Writer.WriteStartArray(); @@ -22,6 +23,7 @@ internal void WriteToManifest(ManifestPublishingContext context) context.Writer.WriteString("target-resource", cloudFormationResource.TargetResource); context.Writer.WriteEndObject(); } + context.Writer.WriteEndArray(); } } diff --git a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationTemplateResource.cs b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationTemplateResource.cs index 268c5069378..a9e5551ac18 100644 --- a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationTemplateResource.cs +++ b/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationTemplateResource.cs @@ -6,11 +6,9 @@ namespace Aspire.Hosting.AWS.CloudFormation; -/// -internal sealed class CloudFormationTemplateResource(string name, string templatePath) : CloudFormationResource(name), ICloudFormationTemplateResource +/// +internal class CloudFormationTemplateResource(string name, string stackName, string templatePath) : CloudFormationResource(name, stackName), ICloudFormationTemplateResource { - public IDictionary CloudFormationParameters { get; } = new Dictionary(); - /// public string TemplatePath { get; } = templatePath; @@ -24,7 +22,9 @@ internal sealed class CloudFormationTemplateResource(string name, string templat public bool DisableDiffCheck { get; set; } /// - public IList DisabledCapabilities { get; } = new List(); + public IList DisabledCapabilities { get; } = []; + + public IDictionary CloudFormationParameters { get; } = new Dictionary(); /// public ICloudFormationTemplateResource AddParameter(string parameterName, string parameterValue) @@ -33,10 +33,10 @@ public ICloudFormationTemplateResource AddParameter(string parameterName, string return this; } - internal void WriteToManifest(ManifestPublishingContext context) + internal override void WriteToManifest(ManifestPublishingContext context) { context.Writer.WriteString("type", "aws.cloudformation.template.v0"); - context.Writer.TryWriteString("stack-name", Name); + context.Writer.TryWriteString("stack-name", StackName); context.Writer.TryWriteString("template-path", context.GetManifestRelativePath(TemplatePath)); context.Writer.WritePropertyName("references"); diff --git a/src/Aspire.Hosting.AWS/CloudFormation/ICloudFormationResource.cs b/src/Aspire.Hosting.AWS/CloudFormation/ICloudFormationResource.cs index 2926b3ef91a..6bbe491a91d 100644 --- a/src/Aspire.Hosting.AWS/CloudFormation/ICloudFormationResource.cs +++ b/src/Aspire.Hosting.AWS/CloudFormation/ICloudFormationResource.cs @@ -3,20 +3,14 @@ using Amazon.CloudFormation; using Amazon.CloudFormation.Model; -using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.AWS.CloudFormation; /// /// Resource representing an AWS CloudFormation stack. /// -public interface ICloudFormationResource : IResource +public interface ICloudFormationResource : IAWSResource { - /// - /// Configuration for creating service clients from the AWS .NET SDK. - /// - IAWSSDKConfig? AWSSDKConfig { get; set; } - /// /// The configured Amazon CloudFormation service client used to make service calls. If this property set /// then AWSSDKConfig is ignored. @@ -24,12 +18,12 @@ public interface ICloudFormationResource : IResource IAmazonCloudFormation? CloudFormationClient { get; set; } /// - /// The output parameters of the CloudFormation stack. + /// The name of the Amazon CloudFormation stack /// - List? Outputs { get; } + string StackName { get; } /// - /// The task completion source for the provisioning operation. + /// The output parameters of the CloudFormation stack. /// - TaskCompletionSource? ProvisioningTaskCompletionSource { get; set; } + List? Outputs { get; } } diff --git a/src/Aspire.Hosting.AWS/CloudFormation/ICloudFormationStackResource.cs b/src/Aspire.Hosting.AWS/CloudFormation/ICloudFormationStackResource.cs index ffc08c178c0..a88224bb4a8 100644 --- a/src/Aspire.Hosting.AWS/CloudFormation/ICloudFormationStackResource.cs +++ b/src/Aspire.Hosting.AWS/CloudFormation/ICloudFormationStackResource.cs @@ -6,7 +6,4 @@ namespace Aspire.Hosting.AWS.CloudFormation; /// /// Resource representing an AWS CloudFormation stack. /// -public interface ICloudFormationStackResource : ICloudFormationResource -{ - -} +public interface ICloudFormationStackResource : ICloudFormationResource; diff --git a/src/Aspire.Hosting.AWS/Constants.cs b/src/Aspire.Hosting.AWS/Constants.cs index c15ade83ec7..2cb07004e07 100644 --- a/src/Aspire.Hosting.AWS/Constants.cs +++ b/src/Aspire.Hosting.AWS/Constants.cs @@ -19,4 +19,9 @@ internal static class Constants /// Success state for Aspire resource dashboard /// public const string ResourceStateRunning = "Running"; + + /// + /// Default Configuration Section + /// + public const string DefaultConfigSection = "AWS:Resources"; } diff --git a/src/Aspire.Hosting.AWS/IAWSResource.cs b/src/Aspire.Hosting.AWS/IAWSResource.cs new file mode 100644 index 00000000000..3aeb6ea89a2 --- /dev/null +++ b/src/Aspire.Hosting.AWS/IAWSResource.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.AWS; + +/// +/// Represents an AWS resource, as a marker interface for 's. +/// +public interface IAWSResource : IResource +{ + /// + /// Configuration for creating service clients from the AWS .NET SDK. + /// + IAWSSDKConfig? AWSSDKConfig { get; set; } + + /// + /// Set by the AWSProvisioner to indicate the task that is provisioning the resource. + /// + public TaskCompletionSource? ProvisioningTaskCompletionSource { get; set; } +} diff --git a/src/Aspire.Hosting.AWS/Provisioning/AWSProvisionerExtensions.cs b/src/Aspire.Hosting.AWS/Provisioning/AWSProvisionerExtensions.cs new file mode 100644 index 00000000000..fa0956e9c8f --- /dev/null +++ b/src/Aspire.Hosting.AWS/Provisioning/AWSProvisionerExtensions.cs @@ -0,0 +1,38 @@ +// 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; +using Aspire.Hosting.AWS.CDK; +using Aspire.Hosting.AWS.CloudFormation; +using Aspire.Hosting.AWS.Provisioning; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding support for generating AWS resources dynamically during application startup. +/// +internal static class AWSProvisionerExtensions +{ + /// + /// Adds support for generating azure resources dynamically during application startup. + /// The application must configure the appropriate subscription, location. + /// + public static IDistributedApplicationBuilder AddAWSProvisioning(this IDistributedApplicationBuilder builder) + { + builder.Services.TryAddLifecycleHook(); + builder.AddAWSProvisioner();; + builder.AddAWSProvisioner(); + builder.AddAWSProvisioner(); + return builder; + } + + internal static IDistributedApplicationBuilder AddAWSProvisioner(this IDistributedApplicationBuilder builder) + where TResource : IAWSResource + where TProvisioner : AWSResourceProvisioner + { + builder.Services.AddKeyedSingleton(typeof(TResource)); + return builder; + } +} diff --git a/src/Aspire.Hosting.AWS/AWSProvisioningException.cs b/src/Aspire.Hosting.AWS/Provisioning/AWSProvisioningException.cs similarity index 91% rename from src/Aspire.Hosting.AWS/AWSProvisioningException.cs rename to src/Aspire.Hosting.AWS/Provisioning/AWSProvisioningException.cs index 8f97208ff9d..01f1fa6f46e 100644 --- a/src/Aspire.Hosting.AWS/AWSProvisioningException.cs +++ b/src/Aspire.Hosting.AWS/Provisioning/AWSProvisioningException.cs @@ -1,7 +1,7 @@ // 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; +namespace Aspire.Hosting.AWS.Provisioning; /// /// Exception for errors provisioning AWS application resources diff --git a/src/Aspire.Hosting.AWS/Provisioning/AWSResourceProvisionerOfT.cs b/src/Aspire.Hosting.AWS/Provisioning/AWSResourceProvisionerOfT.cs new file mode 100644 index 00000000000..e072c31f71a --- /dev/null +++ b/src/Aspire.Hosting.AWS/Provisioning/AWSResourceProvisionerOfT.cs @@ -0,0 +1,19 @@ +// 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.Provisioning; + +internal interface IAWSResourceProvisioner +{ + Task GetOrCreateResourceAsync(IAWSResource resource, CancellationToken cancellationToken = default); +} + +internal abstract class AWSResourceProvisioner : IAWSResourceProvisioner + where TResource : IAWSResource +{ + public Task GetOrCreateResourceAsync( + IAWSResource resource, + CancellationToken cancellationToken) + => GetOrCreateResourceAsync((TResource)resource, cancellationToken); + + protected abstract Task GetOrCreateResourceAsync(TResource resource, CancellationToken cancellationToken); +} diff --git a/src/Aspire.Hosting.AWS/Provisioning/CDKStackResourceProvisioner.cs b/src/Aspire.Hosting.AWS/Provisioning/CDKStackResourceProvisioner.cs new file mode 100644 index 00000000000..b8725d6bd1a --- /dev/null +++ b/src/Aspire.Hosting.AWS/Provisioning/CDKStackResourceProvisioner.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 Amazon.CDK.CXAPI; +using Amazon.CloudFormation; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS.CDK; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.AWS.Provisioning; + +internal sealed class CDKStackResourceProvisioner( + ResourceLoggerService loggerService, + ResourceNotificationService notificationService) + : CloudFormationTemplateResourceProvisioner(loggerService, notificationService) +{ + protected override async Task GetOrCreateResourceAsync(StackResource resource, CancellationToken cancellationToken) + { + var logger = LoggerService.GetLogger(resource); + await ProvisionCDKStackAssetsAsync((StackResource)resource, logger).ConfigureAwait(false); + await base.GetOrCreateResourceAsync(resource, cancellationToken).ConfigureAwait(false); + } + + private static Task ProvisionCDKStackAssetsAsync(StackResource resource, ILogger logger) + { + // Currently CDK Stack Assets like S3 and Container images are not supported. When a stack contains those assets + // we stop provisioning as it can introduce unwanted issues. + if (!resource.TryGetStackArtifact(out var artifact)) + { + throw new AWSProvisioningException("Failed to provision stack assets. Could not retrieve stack artifact."); + } + + if (!artifact.Dependencies + .OfType() + .Any(dependency => + dependency.Contents.Files?.Count > 1 + || dependency.Contents.DockerImages?.Count > 0)) + { + return Task.CompletedTask; + } + + logger.LogError("File or container image assets are currently not supported"); + throw new AWSProvisioningException("Failed to provision stack assets. Provisioning file or container image assets are currently not supported."); + } + + protected override void HandleTemplateProvisioningException(Exception ex, StackResource resource, ILogger logger) + { + if (ex.InnerException is AmazonCloudFormationException inner && inner.Message.StartsWith(@"Unable to fetch parameters [/cdk-bootstrap/")) + { + logger.LogError("The environment doesn't have the CDK toolkit stack installed. Use 'cdk boostrap' to setup your environment for use AWS CDK with Aspire"); + } + } +} diff --git a/src/Aspire.Hosting.AWS/Provisioning/CloudFormationResourceProvisioner.cs b/src/Aspire.Hosting.AWS/Provisioning/CloudFormationResourceProvisioner.cs new file mode 100644 index 00000000000..92dcf5cd88b --- /dev/null +++ b/src/Aspire.Hosting.AWS/Provisioning/CloudFormationResourceProvisioner.cs @@ -0,0 +1,117 @@ +// 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.Text.RegularExpressions; +using Amazon; +using Amazon.CloudFormation; +using Amazon.CloudFormation.Model; +using Amazon.Runtime; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS.CloudFormation; + +namespace Aspire.Hosting.AWS.Provisioning; + +internal abstract partial class CloudFormationResourceProvisioner(ResourceLoggerService loggerService, ResourceNotificationService notificationService) : AWSResourceProvisioner + where T : ICloudFormationResource +{ + protected ResourceLoggerService LoggerService => loggerService; + + protected ResourceNotificationService NotificationService => notificationService; + + protected async Task PublishCloudFormationUpdatePropertiesAsync(T resource, ImmutableArray? properties = null, ImmutableArray? urls = default) + { + if (properties == null) + { + properties = ImmutableArray.Create(); + } + + await NotificationService.PublishUpdateAsync(resource, state => state with + { + Properties = state.Properties.AddRange(properties), + Urls = urls ?? [] + }).ConfigureAwait(false); + } + + protected static ImmutableArray ConvertOutputToProperties(Stack stack, string? templateFile = null) + { + var list = ImmutableArray.CreateBuilder(); + + list.Add(new ResourcePropertySnapshot(CustomResourceKnownProperties.Source, stack.StackId)); + + if (!string.IsNullOrEmpty(templateFile)) + { + list.Add(new("aws.cloudformation.template", templateFile)); + } + + foreach (var output in stack.Outputs) + { + list.Add(new ResourcePropertySnapshot("aws.cloudformation.output." + output.OutputKey, output.OutputValue)); + } + + return list.ToImmutableArray(); + } + + [GeneratedRegex("^(us|eu|ap|sa|ca|me|af|il)-\\w+-\\d+$", RegexOptions.Singleline)] + private static partial Regex AwsRegionRegex(); + + internal static ImmutableArray? MapCloudFormationStackUrl(IAmazonCloudFormation client, string stackId) + { + try + { + var endpointUrl = client.DetermineServiceOperationEndpoint(new DescribeStacksRequest { StackName = stackId })?.URL; + if (endpointUrl == null || !endpointUrl.Contains(".amazonaws.", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (!Arn.TryParse(stackId, out var arn) || !AwsRegionRegex().IsMatch(arn.Region)) + { + return null; + } + + var url = $"https://console.aws.amazon.com/cloudformation/home?region={Uri.EscapeDataString(arn.Region)}#/stacks/resources?stackId={Uri.EscapeDataString(stackId)}"; + + return + [ + new UrlSnapshot("aws-console", url, IsInternal: false) + ]; + } + catch (Exception) + { + return null; + } + } + + protected static IAmazonCloudFormation GetCloudFormationClient(ICloudFormationResource resource) + { + if (resource.CloudFormationClient != null) + { + return resource.CloudFormationClient; + } + + try + { + AmazonCloudFormationClient client; + if (resource.AWSSDKConfig != null) + { + var config = resource.AWSSDKConfig.CreateServiceConfig(); + + var awsCredentials = FallbackCredentialsFactory.GetCredentials(config); + client = new AmazonCloudFormationClient(awsCredentials, config); + } + else + { + client = new AmazonCloudFormationClient(); + } + + client.BeforeRequestEvent += SdkUtilities.ConfigureUserAgentString; + + return client; + } + catch (Exception e) + { + throw new AWSProvisioningException("Failed to construct AWS CloudFormation service client to provision AWS resources.", e); + } + } +} diff --git a/src/Aspire.Hosting.AWS/Provisioning/CloudFormationStackExecutionContext.cs b/src/Aspire.Hosting.AWS/Provisioning/CloudFormationStackExecutionContext.cs new file mode 100644 index 00000000000..c7d49e5b77c --- /dev/null +++ b/src/Aspire.Hosting.AWS/Provisioning/CloudFormationStackExecutionContext.cs @@ -0,0 +1,23 @@ +// 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.Provisioning; + +internal sealed class CloudFormationStackExecutionContext( + string stackName, + string template) +{ + public string Template { get; } = template; + + public string StackName { get; } = stackName; + + public IDictionary CloudFormationParameters { get; set; } = new Dictionary(); + + public string? RoleArn { get; set; } + + public int StackPollingInterval { get; set; } = 3; + + public bool DisableDiffCheck { get; set; } + + public IList DisabledCapabilities { get; set; } = []; +} diff --git a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationStackExecutor.cs b/src/Aspire.Hosting.AWS/Provisioning/CloudFormationStackExecutor.cs similarity index 91% rename from src/Aspire.Hosting.AWS/CloudFormation/CloudFormationStackExecutor.cs rename to src/Aspire.Hosting.AWS/Provisioning/CloudFormationStackExecutor.cs index a59043c3809..f98cd38b1ae 100644 --- a/src/Aspire.Hosting.AWS/CloudFormation/CloudFormationStackExecutor.cs +++ b/src/Aspire.Hosting.AWS/Provisioning/CloudFormationStackExecutor.cs @@ -8,10 +8,11 @@ using Amazon.CloudFormation.Model; using Microsoft.Extensions.Logging; -namespace Aspire.Hosting.AWS.CloudFormation; +namespace Aspire.Hosting.AWS.Provisioning; + internal sealed class CloudFormationStackExecutor( IAmazonCloudFormation cloudFormationClient, - CloudFormationTemplateResource cloudFormationResource, + CloudFormationStackExecutionContext context, ILogger logger) { // Name of the Tag for the stack to store the SHA256 of the CloudFormation template @@ -21,7 +22,7 @@ internal sealed class CloudFormationStackExecutor( const string IN_PROGRESS_SUFFIX = "IN_PROGRESS"; // Polling interval for checking status of CloudFormation stack when creating or updating the stack. - TimeSpan StackPollingDelay { get; } = TimeSpan.FromSeconds(cloudFormationResource.StackPollingInterval); + TimeSpan StackPollingDelay { get; } = TimeSpan.FromSeconds(context.StackPollingInterval); /// /// Using the template and configuration from the CloudFormationTemplateResource create or update @@ -33,18 +34,18 @@ internal sealed class CloudFormationStackExecutor( /// internal async Task ExecuteTemplateAsync(CancellationToken cancellationToken = default) { - var existingStack = await FindstackAsync().ConfigureAwait(false); + var existingStack = await FindStackAsync().ConfigureAwait(false); var changeSetType = await DetermineChangeSetTypeAsync(existingStack, cancellationToken).ConfigureAwait(false); - var templateBody = File.ReadAllText(cloudFormationResource.TemplatePath); - var computedSha256 = ComputeSHA256(templateBody, cloudFormationResource.CloudFormationParameters); + var templateBody = context.Template; + var computedSha256 = ComputeSHA256(templateBody, context.CloudFormationParameters); (var tags, var existingSha256) = SetupTags(existingStack, changeSetType, computedSha256); // Check to see if the template hasn't change. If it hasn't short circuit out. - if (!cloudFormationResource.DisableDiffCheck && string.Equals(computedSha256, existingSha256)) + if (!context.DisableDiffCheck && string.Equals(computedSha256, existingSha256)) { - logger.LogInformation("CloudFormation Template for CloudFormation stack {StackName} has not changed", cloudFormationResource.Name); + logger.LogInformation("CloudFormation Template for CloudFormation stack {StackName} has not changed", context.StackName); return existingStack; } @@ -99,7 +100,7 @@ private static (List tags, string? existingSha256) SetupTags(Stack? existin private List SetupTemplateParameters() { var templateParameters = new List(); - foreach (var kvp in cloudFormationResource.CloudFormationParameters) + foreach (var kvp in context.CloudFormationParameters) { templateParameters.Add(new Parameter { @@ -129,28 +130,28 @@ private List SetupTemplateParameters() logger.LogInformation("Creating CloudFormation change set."); var capabilities = new List(); - if (cloudFormationResource.DisabledCapabilities.FirstOrDefault(x => string.Equals(x, "CAPABILITY_IAM", StringComparison.OrdinalIgnoreCase)) == null) + if (context.DisabledCapabilities.FirstOrDefault(x => string.Equals(x, "CAPABILITY_IAM", StringComparison.OrdinalIgnoreCase)) == null) { capabilities.Add("CAPABILITY_IAM"); } - if (cloudFormationResource.DisabledCapabilities.FirstOrDefault(x => string.Equals(x, "CAPABILITY_NAMED_IAM", StringComparison.OrdinalIgnoreCase)) == null) + if (context.DisabledCapabilities.FirstOrDefault(x => string.Equals(x, "CAPABILITY_NAMED_IAM", StringComparison.OrdinalIgnoreCase)) == null) { capabilities.Add("CAPABILITY_NAMED_IAM"); } - if (cloudFormationResource.DisabledCapabilities.FirstOrDefault(x => string.Equals(x, "CAPABILITY_AUTO_EXPAND", StringComparison.OrdinalIgnoreCase)) == null) + if (context.DisabledCapabilities.FirstOrDefault(x => string.Equals(x, "CAPABILITY_AUTO_EXPAND", StringComparison.OrdinalIgnoreCase)) == null) { capabilities.Add("CAPABILITY_AUTO_EXPAND"); } var changeSetRequest = new CreateChangeSetRequest { - StackName = cloudFormationResource.Name, + StackName = context.StackName, Parameters = templateParameters, // Change set name needs to be unique. Since the changeset isn't be created directly by the user the name isn't really important. ChangeSetName = "Aspire-AppHost-" + DateTime.Now.Ticks, ChangeSetType = changeSetType, Capabilities = capabilities, - RoleARN = cloudFormationResource.RoleArn, + RoleARN = context.RoleArn, TemplateBody = templateBody, Tags = tags }; @@ -183,7 +184,7 @@ private async Task ExecuteChangeSetAsync(string changeSetId, ChangeSetTyp { var executeChangeSetRequest = new ExecuteChangeSetRequest { - StackName = cloudFormationResource.Name, + StackName = context.StackName, ChangeSetName = changeSetId }; @@ -195,11 +196,11 @@ private async Task ExecuteChangeSetAsync(string changeSetId, ChangeSetTyp await cloudFormationClient.ExecuteChangeSetAsync(executeChangeSetRequest, cancellationToken).ConfigureAwait(false); if (changeSetType == ChangeSetType.CREATE) { - logger.LogInformation($"Initiated CloudFormation stack creation for {cloudFormationResource.Name}"); + logger.LogInformation("Initiated CloudFormation stack creation for {cloudFormationTemplate.StackName}", context.StackName); } else { - logger.LogInformation($"Initiated CloudFormation stack update on {cloudFormationResource.Name}"); + logger.LogInformation("Initiated CloudFormation stack update on {cloudFormationTemplate.StackName}", context.StackName); } } catch (Exception e) @@ -244,7 +245,7 @@ private async Task DetermineChangeSetTypeAsync(Stack? stack, Canc changeSetType = ChangeSetType.CREATE; } - // If the status was DELETE_IN_PROGRESS then just wait for delete to complete + // If the status was DELETE_IN_PROGRESS then just wait for delete to complete else if (stack.StackStatus == StackStatus.DELETE_IN_PROGRESS) { await WaitForNoLongerInProgress(cancellationToken).ConfigureAwait(false); @@ -327,7 +328,7 @@ private async Task DeleteRollbackCompleteStackAsync(Stack stack, CancellationTok } await Task.Delay(StackPollingDelay, cancellation).ConfigureAwait(false); - currentStack = await FindstackAsync().ConfigureAwait(false); + currentStack = await FindStackAsync().ConfigureAwait(false); } while (currentStack != null && currentStack.StackStatus.ToString(CultureInfo.InvariantCulture).EndsWith(IN_PROGRESS_SUFFIX)); return currentStack; @@ -396,7 +397,7 @@ private async Task WaitStackToCompleteAsync(DateTimeOffset minTimeStampFo const int RESOURCE_STATUS = 40; var mostRecentEventId = string.Empty; - var waitingMessage = $"... Waiting for CloudFormation stack {cloudFormationResource.Name} to be ready"; + var waitingMessage = $"... Waiting for CloudFormation stack {context.StackName} to be ready"; logger.LogInformation(waitingMessage); logger.LogInformation(new string('-', waitingMessage.Length)); @@ -406,7 +407,7 @@ private async Task WaitStackToCompleteAsync(DateTimeOffset minTimeStampFo await Task.Delay(StackPollingDelay, cancellationToken).ConfigureAwait(false); // If we are in the WaitStackToCompleteAsync then we already know the stack exists. - stack = (await FindstackAsync().ConfigureAwait(false))!; + stack = (await FindStackAsync().ConfigureAwait(false))!; var events = await GetLatestEventsAsync(minTimeStampForEvents, mostRecentEventId, cancellationToken).ConfigureAwait(false); if (events.Count > 0) @@ -449,7 +450,7 @@ private async Task> GetLatestEventsAsync(DateTimeOffset minTime DescribeStackEventsResponse? response = null; do { - var request = new DescribeStackEventsRequest() { StackName = cloudFormationResource.Name }; + var request = new DescribeStackEventsRequest() { StackName = context.StackName }; if (response != null) { request.NextToken = response.NextToken; @@ -491,11 +492,11 @@ private static string ComputeSHA256(string templateBody, IDictionary FindstackAsync() + private async Task FindStackAsync() { await foreach (var stack in cloudFormationClient.Paginators.DescribeStacks(new DescribeStacksRequest()).Stacks.ConfigureAwait(false)) { - if (string.Equals(cloudFormationResource.Name, stack.StackName, StringComparison.Ordinal)) + if (string.Equals(context.StackName, stack.StackName, StringComparison.Ordinal)) { return stack; } diff --git a/src/Aspire.Hosting.AWS/Provisioning/CloudFormationStackResourceProvisioner.cs b/src/Aspire.Hosting.AWS/Provisioning/CloudFormationStackResourceProvisioner.cs new file mode 100644 index 00000000000..c42f2d0ca90 --- /dev/null +++ b/src/Aspire.Hosting.AWS/Provisioning/CloudFormationStackResourceProvisioner.cs @@ -0,0 +1,50 @@ +// 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 Amazon.CloudFormation.Model; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS.CloudFormation; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.AWS.Provisioning; + +internal sealed class CloudFormationStackResourceProvisioner( + ResourceLoggerService loggerService, + ResourceNotificationService notificationService) + : CloudFormationResourceProvisioner(loggerService, notificationService) +{ + protected override async Task GetOrCreateResourceAsync(CloudFormationStackResource resource, CancellationToken cancellationToken) + { + var logger = LoggerService.GetLogger(resource); + try + { + using var cfClient = GetCloudFormationClient(resource); + + var request = new DescribeStacksRequest { StackName = resource.Name }; + var response = await cfClient.DescribeStacksAsync(request, cancellationToken).ConfigureAwait(false); + + // If the stack didn't exist then a StackNotFoundException would have been thrown. + var stack = response.Stacks[0]; + + // Capture the CloudFormation stack output parameters on to the Aspire CloudFormation resource. This + // allows projects that have a reference to the stack have the output parameters applied to the + // projects IConfiguration. + resource.Outputs = stack!.Outputs; + + await PublishCloudFormationUpdatePropertiesAsync(resource, ConvertOutputToProperties(stack)).ConfigureAwait(false); + } + catch (Exception e) + { + if (e is AmazonCloudFormationException ce && string.Equals(ce.ErrorCode, "ValidationError")) + { + logger.LogError("Stack {StackName} does not exists to add as a resource", resource.Name); + } + else + { + logger.LogError(e, "Error reading {StackName}", resource.Name); + } + throw; + } + } +} diff --git a/src/Aspire.Hosting.AWS/Provisioning/CloudFormationTemplateResourceProvisioner.cs b/src/Aspire.Hosting.AWS/Provisioning/CloudFormationTemplateResourceProvisioner.cs new file mode 100644 index 00000000000..4ade446f48b --- /dev/null +++ b/src/Aspire.Hosting.AWS/Provisioning/CloudFormationTemplateResourceProvisioner.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS.CloudFormation; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.AWS.Provisioning; + +internal class CloudFormationTemplateResourceProvisioner( + ResourceLoggerService loggerService, + ResourceNotificationService notificationService) + : CloudFormationTemplateResourceProvisioner(loggerService, notificationService); + +internal class CloudFormationTemplateResourceProvisioner( + ResourceLoggerService loggerService, + ResourceNotificationService notificationService) + : CloudFormationResourceProvisioner(loggerService, notificationService) + where T : CloudFormationTemplateResource +{ + protected override async Task GetOrCreateResourceAsync(T resource, + CancellationToken cancellationToken) + { + var logger = LoggerService.GetLogger(resource); + + using var cfClient = GetCloudFormationClient(resource); + + try + { + var context = await CreateCloudFormationExecutionContextAsync(resource, cancellationToken) + .ConfigureAwait(false); + var executor = new CloudFormationStackExecutor(cfClient, context, logger); + var stack = await executor.ExecuteTemplateAsync(cancellationToken).ConfigureAwait(false); + + if (stack != null) + { + logger.LogInformation("CloudFormation stack has {Count} output parameters", stack.Outputs.Count); + if (logger.IsEnabled(LogLevel.Information)) + { + foreach (var output in stack.Outputs) + { + logger.LogInformation("Output Name: {Name}, Value {Value}", output.OutputKey, output.OutputValue); + } + } + + logger.LogInformation("CloudFormation provisioning complete"); + + if (resource is CloudFormationResource cloudformationResource) + { + cloudformationResource.Outputs = stack.Outputs; + } + + var templatePath = resource.TemplatePath; + await PublishCloudFormationUpdatePropertiesAsync(resource, ConvertOutputToProperties(stack, templatePath), + MapCloudFormationStackUrl(cfClient, stack.StackId)).ConfigureAwait(false); + } + else + { + logger.LogError("CloudFormation provisioning failed"); + + throw new AWSProvisioningException("Failed to apply CloudFormation template"); + } + } + catch (Exception ex) + { + HandleTemplateProvisioningException(ex, resource, logger); + throw; + } + } + + private static async Task CreateCloudFormationExecutionContextAsync(T resource, CancellationToken cancellationToken) + { + var template = await File.ReadAllTextAsync(resource.TemplatePath, cancellationToken).ConfigureAwait(false); + return new CloudFormationStackExecutionContext(resource.StackName, template) + { + RoleArn = resource.RoleArn, + DisableDiffCheck = resource.DisableDiffCheck, + StackPollingInterval = resource.StackPollingInterval, + DisabledCapabilities = resource.DisabledCapabilities, + CloudFormationParameters = resource.CloudFormationParameters + }; + } + + protected virtual void HandleTemplateProvisioningException(Exception ex, T resource, ILogger logger) { } +} diff --git a/src/Aspire.Hosting.AWS/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.AWS/PublicAPI.Unshipped.txt index bff9114f326..b9286f9dd5c 100644 --- a/src/Aspire.Hosting.AWS/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting.AWS/PublicAPI.Unshipped.txt @@ -1,14 +1,21 @@ #nullable enable -Aspire.Hosting.AWS.AWSProvisioningException -Aspire.Hosting.AWS.AWSProvisioningException.AWSProvisioningException(string! message, System.Exception? innerException = null) -> void +Aspire.Hosting.AWS.CDK.ConstructBuilderDelegate +Aspire.Hosting.AWS.CDK.ConstructOutputDelegate +Aspire.Hosting.AWS.CDK.IConstructResource +Aspire.Hosting.AWS.CDK.IConstructResource +Aspire.Hosting.AWS.CDK.IResourceWithConstruct +Aspire.Hosting.AWS.CDK.IResourceWithConstruct.Construct.get -> Constructs.IConstruct! +Aspire.Hosting.AWS.CDK.IResourceWithConstruct +Aspire.Hosting.AWS.CDK.IResourceWithConstruct.Construct.get -> T +Aspire.Hosting.AWS.CDK.IStackResource +Aspire.Hosting.AWS.CDK.IStackResource.Stack.get -> Amazon.CDK.Stack! +Aspire.Hosting.AWS.CDK.IStackResource +Aspire.Hosting.AWS.CDK.IStackResource.Stack.get -> T! Aspire.Hosting.AWS.CloudFormation.ICloudFormationResource -Aspire.Hosting.AWS.CloudFormation.ICloudFormationResource.AWSSDKConfig.get -> Aspire.Hosting.AWS.IAWSSDKConfig? -Aspire.Hosting.AWS.CloudFormation.ICloudFormationResource.AWSSDKConfig.set -> void Aspire.Hosting.AWS.CloudFormation.ICloudFormationResource.CloudFormationClient.get -> Amazon.CloudFormation.IAmazonCloudFormation? Aspire.Hosting.AWS.CloudFormation.ICloudFormationResource.CloudFormationClient.set -> void Aspire.Hosting.AWS.CloudFormation.ICloudFormationResource.Outputs.get -> System.Collections.Generic.List? -Aspire.Hosting.AWS.CloudFormation.ICloudFormationResource.ProvisioningTaskCompletionSource.get -> System.Threading.Tasks.TaskCompletionSource? -Aspire.Hosting.AWS.CloudFormation.ICloudFormationResource.ProvisioningTaskCompletionSource.set -> void +Aspire.Hosting.AWS.CloudFormation.ICloudFormationResource.StackName.get -> string! Aspire.Hosting.AWS.CloudFormation.ICloudFormationStackResource Aspire.Hosting.AWS.CloudFormation.ICloudFormationTemplateResource Aspire.Hosting.AWS.CloudFormation.ICloudFormationTemplateResource.AddParameter(string! parameterName, string! parameterValue) -> Aspire.Hosting.AWS.CloudFormation.ICloudFormationTemplateResource! @@ -27,24 +34,66 @@ Aspire.Hosting.AWS.CloudFormation.StackOutputReference.Resource.get -> Aspire.Ho Aspire.Hosting.AWS.CloudFormation.StackOutputReference.StackOutputReference(string! name, Aspire.Hosting.AWS.CloudFormation.ICloudFormationResource! resource) -> void Aspire.Hosting.AWS.CloudFormation.StackOutputReference.Value.get -> string? Aspire.Hosting.AWS.CloudFormation.StackOutputReference.ValueExpression.get -> string! +Aspire.Hosting.AWS.IAWSResource +Aspire.Hosting.AWS.IAWSResource.AWSSDKConfig.get -> Aspire.Hosting.AWS.IAWSSDKConfig? +Aspire.Hosting.AWS.IAWSResource.AWSSDKConfig.set -> void +Aspire.Hosting.AWS.IAWSResource.ProvisioningTaskCompletionSource.get -> System.Threading.Tasks.TaskCompletionSource? +Aspire.Hosting.AWS.IAWSResource.ProvisioningTaskCompletionSource.set -> void Aspire.Hosting.AWS.IAWSSDKConfig Aspire.Hosting.AWS.IAWSSDKConfig.Profile.get -> string? Aspire.Hosting.AWS.IAWSSDKConfig.Profile.set -> void Aspire.Hosting.AWS.IAWSSDKConfig.Region.get -> Amazon.RegionEndpoint? Aspire.Hosting.AWS.IAWSSDKConfig.Region.set -> void +Aspire.Hosting.AWS.Provisioning.AWSProvisioningException +Aspire.Hosting.AWS.Provisioning.AWSProvisioningException.AWSProvisioningException(string! message, System.Exception? innerException = null) -> void +Aspire.Hosting.CDKExtensions Aspire.Hosting.CloudFormationExtensions +Aspire.Hosting.CognitoResourceExtensions +Aspire.Hosting.DynamoDBResourceExtensions +Aspire.Hosting.KinesisResourceExtensions +Aspire.Hosting.S3ResourceExtensions Aspire.Hosting.SDKResourceExtensions -static Aspire.Hosting.CloudFormationExtensions.AddAWSCloudFormationStack(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! stackName) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! -static Aspire.Hosting.CloudFormationExtensions.AddAWSCloudFormationTemplate(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! stackName, string! templatePath) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +Aspire.Hosting.SNSResourceExtensions +Aspire.Hosting.SQSResourceExtensions +static Aspire.Hosting.CDKExtensions.AddAWSCDKStack(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.CDKExtensions.AddAWSCDKStack(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! stackName) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.CDKExtensions.AddAWSCDKStack(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, Aspire.Hosting.AWS.CDK.ConstructBuilderDelegate! stackBuilder) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.CDKExtensions.AddConstruct(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Aspire.Hosting.AWS.CDK.ConstructBuilderDelegate! constructBuilder) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.CDKExtensions.AddOutput(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, string! name, Aspire.Hosting.AWS.CDK.ConstructOutputDelegate! output) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.CDKExtensions.AddOutput(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, string! name, Aspire.Hosting.AWS.CDK.ConstructOutputDelegate! output) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.CDKExtensions.GetOutput(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, string! name, Aspire.Hosting.AWS.CDK.ConstructOutputDelegate! output) -> Aspire.Hosting.AWS.CloudFormation.StackOutputReference! +static Aspire.Hosting.CDKExtensions.WithEnvironment(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! construct, Aspire.Hosting.AWS.CDK.ConstructOutputDelegate! outputDelegate, string? outputName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.CDKExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! construct, Aspire.Hosting.AWS.CDK.ConstructOutputDelegate! outputDelegate, string! outputName, string? configSection = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.CloudFormationExtensions.AddAWSCloudFormationStack(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string? stackName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.CloudFormationExtensions.AddAWSCloudFormationTemplate(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! templatePath, string? stackName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.CloudFormationExtensions.GetOutput(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name) -> Aspire.Hosting.AWS.CloudFormation.StackOutputReference! static Aspire.Hosting.CloudFormationExtensions.WithEnvironment(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Aspire.Hosting.AWS.CloudFormation.StackOutputReference! stackOutputReference) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.CloudFormationExtensions.WithParameter(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! parameterName, string! parameterValue) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! -static Aspire.Hosting.CloudFormationExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Amazon.CloudFormation.IAmazonCloudFormation! cloudFormationClient) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! -static Aspire.Hosting.CloudFormationExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.AWS.IAWSSDKConfig! awsSdkConfig) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! -static Aspire.Hosting.CloudFormationExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Amazon.CloudFormation.IAmazonCloudFormation! cloudFormationClient) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! -static Aspire.Hosting.CloudFormationExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.AWS.IAWSSDKConfig! awsSdkConfig) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! -static Aspire.Hosting.CloudFormationExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder! cloudFormationResourceBuilder, string! configSection = "AWS::Resources") -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.CloudFormationExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Amazon.CloudFormation.IAmazonCloudFormation! cloudFormationClient) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.CloudFormationExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder! cloudFormationResourceBuilder, string! configSection = "AWS:Resources") -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.CloudFormationExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.AWS.IAWSSDKConfig! awsSdkConfig) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.CognitoResourceExtensions.AddClient(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, string! name, Amazon.CDK.AWS.Cognito.IUserPoolClientOptions? options = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.CognitoResourceExtensions.AddCognitoUserPool(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Amazon.CDK.AWS.Cognito.IUserPoolProps? props = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.CognitoResourceExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! userPool, string? configSection = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.DynamoDBResourceExtensions.AddDynamoDBTable(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Amazon.CDK.AWS.DynamoDB.ITableProps! props) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.DynamoDBResourceExtensions.AddGlobalSecondaryIndex(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, Amazon.CDK.AWS.DynamoDB.IGlobalSecondaryIndexProps! props) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.DynamoDBResourceExtensions.AddLocalSecondaryIndex(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, Amazon.CDK.AWS.DynamoDB.ILocalSecondaryIndexProps! props) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.DynamoDBResourceExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! table, string? configSection = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.KinesisResourceExtensions.AddKinesisStream(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Amazon.CDK.AWS.Kinesis.IStreamProps? props = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.KinesisResourceExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! stream, string? configSection = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.S3ResourceExtensions.AddEventNotification(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! destination, Amazon.CDK.AWS.S3.EventType eventType, params Amazon.CDK.AWS.S3.INotificationKeyFilter![]! filters) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.S3ResourceExtensions.AddObjectCreatedNotification(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! destination, params Amazon.CDK.AWS.S3.INotificationKeyFilter![]! filters) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.S3ResourceExtensions.AddObjectCreatedNotification(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! destination, params Amazon.CDK.AWS.S3.INotificationKeyFilter![]! filters) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.S3ResourceExtensions.AddObjectRemovedNotification(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! destination, params Amazon.CDK.AWS.S3.INotificationKeyFilter![]! filters) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.S3ResourceExtensions.AddObjectRemovedNotification(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! destination, params Amazon.CDK.AWS.S3.INotificationKeyFilter![]! filters) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.S3ResourceExtensions.AddS3Bucket(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Amazon.CDK.AWS.S3.IBucketProps? props = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.S3ResourceExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! bucket, string? configSection = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.SDKResourceExtensions.AddAWSSDKConfig(this Aspire.Hosting.IDistributedApplicationBuilder! builder) -> Aspire.Hosting.AWS.IAWSSDKConfig! static Aspire.Hosting.SDKResourceExtensions.WithProfile(this Aspire.Hosting.AWS.IAWSSDKConfig! config, string! profile) -> Aspire.Hosting.AWS.IAWSSDKConfig! static Aspire.Hosting.SDKResourceExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.AWS.IAWSSDKConfig! awsSdkConfig) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.SDKResourceExtensions.WithRegion(this Aspire.Hosting.AWS.IAWSSDKConfig! config, Amazon.RegionEndpoint! region) -> Aspire.Hosting.AWS.IAWSSDKConfig! +static Aspire.Hosting.SNSResourceExtensions.AddSNSTopic(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Amazon.CDK.AWS.SNS.ITopicProps? props = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.SNSResourceExtensions.AddSubscription(this Aspire.Hosting.ApplicationModel.IResourceBuilder!>! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! destination, Amazon.CDK.AWS.SNS.Subscriptions.SqsSubscriptionProps? props = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.SNSResourceExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! topic, string? configSection = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.SQSResourceExtensions.AddSQSQueue(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Amazon.CDK.AWS.SQS.IQueueProps? props = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder!>! +static Aspire.Hosting.SQSResourceExtensions.WithReference(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder!>! queue, string? configSection = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! diff --git a/src/Aspire.Hosting.AWS/README.md b/src/Aspire.Hosting.AWS/README.md index b72d4fc856c..fd9e513b5d0 100644 --- a/src/Aspire.Hosting.AWS/README.md +++ b/src/Aspire.Hosting.AWS/README.md @@ -5,6 +5,7 @@ Provides extension methods and resources definition for a .NET Aspire AppHost to ## Prerequisites - [Configure AWS credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) +- [Node.js](https://nodejs.org) _(AWS CDK only)_ ## Install the package @@ -83,7 +84,8 @@ In the AppHost project create either a JSON or YAML CloudFormation template. Her ``` In the AppHost the `AddAWSCloudFormationTemplate` method is used to register the CloudFormation resource. The first parameter, -which is the Aspire resource name, is used as the CloudFormation stack name. If the template defines parameters the value can be provided using +which is the Aspire resource name, is used as the CloudFormation stack name when the `stackName` parameter is not set. +If the template defines parameters the value can be provided using the `WithParameter` method. To configure what AWS account and region to deploy the CloudFormation stack, the `WithReference` method is used to associate a SDK configuration. @@ -116,8 +118,8 @@ builder.AddProject("Frontend") ## Importing existing AWS resources -To import AWS resources that were created by a CloudFormation stack outside of the AppHost the `AddAWSCloudFormationStack` method can be used. -It will associated the outputs of the CloudFormation stack the same as the provisioning method `AddAWSCloudFormationTemplate`. +To import AWS resources that were created by a CloudFormation stack outside the AppHost the `AddAWSCloudFormationStack` method can be used. +It will associate the outputs of the CloudFormation stack the same as the provisioning method `AddAWSCloudFormationTemplate`. ```csharp var awsResources = builder.AddAWSCloudFormationStack("ExistingStackName") @@ -127,6 +129,34 @@ builder.AddProject("Frontend") .WithReference(awsResources); ``` +## Provisioning application resources with AWS CDK + +Adding [AWS CDK](https://aws.amazon.com/cdk/) to the AppHost makes it possible to provision AWS resources using code. Under the hood AWS CDK is using CloudFormation to create the resources in AWS. + +In the AppHost the `AddAWSCDK` methods is used to create a CDK Resources which will hold the constructs for describing the AWS resources. + +A number of methods are available to add common resources to the AppHost like S3 Buckets, DynamoDB Tables, SQS Queues, SNS Topics, Kinesis Streams and Cognito User Pools. These resources can be added either the CDK resource or a dedicated stack that can be created. + +```csharp +var stack = builder.AddAWSCDKStack("Stack"); +var bucket = stack.AddS3Bucket("Bucket"); + +builder.AddProject("Frontend") + .WithReference(bucket); +``` + +Resources created with these methods can be directly referenced by project resources and common properties like resource names, ARNs or URLs will be made available as configuration environment variables. The default config section will be `AWS:Resources` + +Alternative constructs can be created in free form using the `AddConstruct` methods. These constructs can be references with the `WithReference` method and need to be provided with a property selector and an output name. This will make this property available as configuration environment variable + +```csharp +var stack = builder.AddAWSCDKStack("Stack"); +var constuct = stack.AddConstruct("Construct", scope => new CustomConstruct(scope, "Construct")); + +builder.AddProject("Frontend") + .WithReference(construct, c => c.Url, "Url"); +``` + ## Feedback & contributing https://github.com/dotnet/aspire diff --git a/src/Aspire.Hosting.AWS/SDKResourceExtensions.cs b/src/Aspire.Hosting.AWS/SDKResourceExtensions.cs index ed453350201..c93fc63e32d 100644 --- a/src/Aspire.Hosting.AWS/SDKResourceExtensions.cs +++ b/src/Aspire.Hosting.AWS/SDKResourceExtensions.cs @@ -50,7 +50,7 @@ public static IAWSSDKConfig WithRegion(this IAWSSDKConfig config, RegionEndpoint /// /// Add a reference to an AWS SDK configuration to the resource. /// - /// An for + /// An for /// The AWS SDK configuration /// public static IResourceBuilder WithReference(this IResourceBuilder builder, IAWSSDKConfig awsSdkConfig) diff --git a/src/Aspire.Hosting.AWS/SdkUtilities.cs b/src/Aspire.Hosting.AWS/SdkUtilities.cs index ec92f50155c..4debb91af3c 100644 --- a/src/Aspire.Hosting.AWS/SdkUtilities.cs +++ b/src/Aspire.Hosting.AWS/SdkUtilities.cs @@ -5,7 +5,6 @@ using System.Text; using Amazon.Runtime; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.AWS.CloudFormation; namespace Aspire.Hosting.AWS; internal static class SdkUtilities @@ -18,7 +17,7 @@ private static string GetUserAgentStringSuffix() if (s_userAgentHeader == null) { var builder = new StringBuilder("lib/aspire.hosting.aws"); - var attribute = typeof(CloudFormationProvisioner).Assembly.GetCustomAttribute(); + var attribute = typeof(AWSLifecycleHook).Assembly.GetCustomAttribute(); if (attribute != null) { builder.Append('#'); diff --git a/src/Aspire.Hosting.AWS/Utils/ResourceExtensions.cs b/src/Aspire.Hosting.AWS/Utils/ResourceExtensions.cs new file mode 100644 index 00000000000..70b6f2ae83e --- /dev/null +++ b/src/Aspire.Hosting.AWS/Utils/ResourceExtensions.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.AWS; + +internal static class ResourceExtensions +{ + /// + /// Attempts to find a parent resource of type by recursively matching the type with the parent. + /// When the type doesn't match or doesn't have any parents it will use the default. + /// + /// The resource to evaluate + /// Type of the parent that needs to be found + /// /// The found parent resource or default if the parent is not found. + public static T? TrySelectParentResource(this IResource resource) where T : IResource + => resource switch + { + T ar => ar, + IResourceWithParent rp => TrySelectParentResource(rp.Parent), + _ => default + }; + + /// + /// Finds a parent resource of type by recursively matching the type with the parent. + /// When the type doesn't match or doesn't have any parents it will throw an exception. + /// + /// The resource to evaluate + /// Type of the parent that needs to be found + /// Thrown when the parent resource is not found + /// The found parent resource + public static T SelectParentResource(this IResource resource) + where T : IResource + => resource.TrySelectParentResource() + ?? throw new ArgumentException( + $@"Resource with parent '{resource.GetType().FullName}' not found", + nameof(resource)); +} diff --git a/src/Aspire.Hosting.AWS/Utils/StringExtensions.cs b/src/Aspire.Hosting.AWS/Utils/StringExtensions.cs new file mode 100644 index 00000000000..b5592319bd9 --- /dev/null +++ b/src/Aspire.Hosting.AWS/Utils/StringExtensions.cs @@ -0,0 +1,41 @@ +// 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; + +internal static class StringExtensions +{ + /// + /// Trims a string from the start of the target + /// + /// Target string + /// String to trim + /// Trimmed string + public static string TrimStart(this string target, string trimString) + { + if (string.IsNullOrEmpty(trimString)) + { + return target; + } + + var result = target; + while (result.StartsWith(trimString)) + { + result = result[trimString.Length..]; + } + + return result; + + } + + /// + /// Replaces : characters to __ for environment variables support with Microsoft.Extensions.Configuration + /// + /// Configuration key + public static string ToEnvironmentVariables(this string configuration) + { + return configuration + .Replace("::", "__") + .Replace(":", "__"); + } +} diff --git a/src/Components/Directory.Build.targets b/src/Components/Directory.Build.targets index 966fc28e293..fcbb56191d1 100644 --- a/src/Components/Directory.Build.targets +++ b/src/Components/Directory.Build.targets @@ -85,8 +85,8 @@ Outputs="$(GeneratedConfigurationSchemaOutputPath)"> - "$(DotNetTool)" exec $(ConfigurationSchemaGeneratorPath) - $(GeneratorCommandLine) @$(ConfigurationSchemaGeneratorRspPath) + "$(DotNetTool)" exec "$(ConfigurationSchemaGeneratorPath)" + $(GeneratorCommandLine) @"$(ConfigurationSchemaGeneratorRspPath)" diff --git a/tests/Aspire.Hosting.AWS.Tests/AWSCDKResourceTests.cs b/tests/Aspire.Hosting.AWS.Tests/AWSCDKResourceTests.cs new file mode 100644 index 00000000000..96fe31caa33 --- /dev/null +++ b/tests/Aspire.Hosting.AWS.Tests/AWSCDKResourceTests.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon; +using Amazon.CDK.AWS.S3; +using Aspire.Components.Common.Tests; +using Aspire.Hosting.Utils; +using Constructs; +using Xunit; + +namespace Aspire.Hosting.AWS.Tests; + +public class AWSCDKResourceTests +{ + [Fact] + [RequiresTools(["node"])] + [ActiveIssue("https://github.com/dotnet/aspire/issues/4508", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))] + public void AddAWSCDKResourceTest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var awsSdkConfig = builder.AddAWSSDKConfig() + .WithRegion(RegionEndpoint.EUWest1) + .WithProfile("test-profile"); + + var resource = builder.AddAWSCDKStack("Stack") + .WithReference(awsSdkConfig) + .Resource; + + Assert.Equal("Stack", resource.Name); + Assert.NotNull(resource.AWSSDKConfig); + Assert.Equal(RegionEndpoint.EUWest1, resource.AWSSDKConfig.Region); + Assert.Equal("test-profile", resource.AWSSDKConfig.Profile); + } + + [Fact] + [RequiresTools(["node"])] + [ActiveIssue("https://github.com/dotnet/aspire/issues/4508", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))] + public void AddAWSCDKResourceWithAdditionalStackTest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var awsSdkConfig = builder.AddAWSSDKConfig() + .WithRegion(RegionEndpoint.EUWest1) + .WithProfile("test-profile"); + + var cdk = builder + .AddAWSCDKStack("Stack") + .WithReference(awsSdkConfig); + var resource = builder + .AddAWSCDKStack("Other") + .WithReference(awsSdkConfig).Resource; + + Assert.Equal("Other", resource.Name); + Assert.NotNull(resource.AWSSDKConfig); + Assert.Equal(RegionEndpoint.EUWest1, resource.AWSSDKConfig.Region); + Assert.Equal("test-profile", resource.AWSSDKConfig.Profile); + } + + [Fact] + [RequiresTools(["node"])] + [ActiveIssue("https://github.com/dotnet/aspire/issues/4508", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))] + public void AddAWSCDKResourceWithAdditionalStackAndConfigTest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var awsSdkConfig = builder.AddAWSSDKConfig() + .WithRegion(RegionEndpoint.EUWest1) + .WithProfile("test-profile"); + var awsSdkStackConfig = builder.AddAWSSDKConfig() + .WithRegion(RegionEndpoint.EUWest2) + .WithProfile("other-test-profile"); + + var cdk = builder.AddAWSCDKStack("Stack") + .WithReference(awsSdkConfig); + var cdkResource = cdk.Resource; + var stackResource = builder.AddAWSCDKStack("Other").WithReference(awsSdkStackConfig).Resource; + + // Assert Stack resource + Assert.Equal("Other", stackResource.Name); + Assert.NotNull(stackResource.AWSSDKConfig); + Assert.Equal(RegionEndpoint.EUWest2, stackResource.AWSSDKConfig.Region); + Assert.Equal("other-test-profile", stackResource.AWSSDKConfig.Profile); + + // Assert CDK resource + Assert.Equal("Stack", cdkResource.Name); + Assert.NotNull(cdkResource.AWSSDKConfig); + Assert.Equal(RegionEndpoint.EUWest1, cdkResource.AWSSDKConfig.Region); + Assert.Equal("test-profile", cdkResource.AWSSDKConfig.Profile); + } + + [Fact] + [RequiresTools(["node"])] + [ActiveIssue("https://github.com/dotnet/aspire/issues/4508", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))] + public void AddAWSCDKResourceWithConstructTest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cdk = builder.AddAWSCDKStack("Stack"); + var resource = cdk.AddConstruct("Construct", scope => new Construct(scope, "Construct")).Resource; + + Assert.Equal("Construct", resource.Name); + Assert.Equal(cdk.Resource, resource.Parent); + } + + [Fact] + [RequiresTools(["node"])] + [ActiveIssue("https://github.com/dotnet/aspire/issues/4508", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))] + public async Task ManifestAWSCDKResourceTest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cdk = builder.AddAWSCDKStack("Stack"); + var resourceBuilder = cdk.AddConstruct("Construct", scope => new Bucket(scope, "Bucket")); + + builder.AddProject("ServiceA") + .WithReference(resourceBuilder, bucket => bucket.BucketName, "BucketName"); + + var resource = cdk.Resource; + Assert.NotNull(resource); + + const string expectedManifest = """ + { + "type": "aws.cloudformation.template.v0", + "stack-name": "Stack", + "template-path": "cdk.out/Stack.template.json", + "references": [ + { + "target-resource": "ServiceA" + } + ] + } + """; + + var manifest = await ManifestUtils.GetManifest(resource); + Assert.Equal(expectedManifest, manifest.ToString()); + } +} diff --git a/tests/Aspire.Hosting.AWS.Tests/Aspire.Hosting.AWS.Tests.csproj b/tests/Aspire.Hosting.AWS.Tests/Aspire.Hosting.AWS.Tests.csproj index dfa86dfb11c..91b3a7de7a2 100644 --- a/tests/Aspire.Hosting.AWS.Tests/Aspire.Hosting.AWS.Tests.csproj +++ b/tests/Aspire.Hosting.AWS.Tests/Aspire.Hosting.AWS.Tests.csproj @@ -1,6 +1,7 @@ $(DefaultTargetFramework) + $(NoWarn);CS8002 diff --git a/tests/Aspire.Hosting.AWS.Tests/CloudFormationAWSConsoleUrlTests.cs b/tests/Aspire.Hosting.AWS.Tests/CloudFormationAWSConsoleUrlTests.cs new file mode 100644 index 00000000000..76b6d552ee8 --- /dev/null +++ b/tests/Aspire.Hosting.AWS.Tests/CloudFormationAWSConsoleUrlTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Amazon; +using Amazon.CloudFormation; +using Aspire.Hosting.AWS.CloudFormation; +using Aspire.Hosting.AWS.Provisioning; +using Xunit; + +namespace Aspire.Hosting.AWS.Tests; + +public class CloudFormationAWSConsoleUrlTests +{ + [Fact] + public void ConsoleUrlCreated_RegionEndpoint() + { + const string stackId = "arn:aws:cloudformation:eu-west-1:111111111111:stack/Stack1/abcdef-example"; + using var client = new AmazonCloudFormationClient(RegionEndpoint.EUWest1); + + var urls = CloudFormationResourceProvisioner.MapCloudFormationStackUrl(client, stackId); + + Assert.Equal( + "https://console.aws.amazon.com/cloudformation/home?region=eu-west-1#/stacks/resources?stackId=arn%3Aaws%3Acloudformation%3Aeu-west-1%3A111111111111%3Astack%2FStack1%2Fabcdef-example", + urls!.Value.Single().Url); + } + + [Fact] + public void ConsoleUrlCreated_ServiceUrl() + { + const string stackId = "arn:aws:cloudformation:ap-southeast-1:111111111111:stack/Stack1/abcdef-example"; + using var client = new AmazonCloudFormationClient(new AmazonCloudFormationConfig + { + ServiceURL = "https://cloudformation.ap-southeast-1.amazonaws.com/" + }); + + var urls = CloudFormationResourceProvisioner.MapCloudFormationStackUrl(client, stackId); + + Assert.Equal( + "https://console.aws.amazon.com/cloudformation/home?region=ap-southeast-1#/stacks/resources?stackId=arn%3Aaws%3Acloudformation%3Aap-southeast-1%3A111111111111%3Astack%2FStack1%2Fabcdef-example", + urls!.Value.Single().Url); + } + + [Fact] + public void ConsoleUrlNotCreated_LocalStackServiceUrl() + { + using var client = new AmazonCloudFormationClient(new AmazonCloudFormationConfig + { + ServiceURL = "http://localhost:4566", + }); + + const string stackId = "arn:aws:cloudformation:eu-west-1:111111111111:stack/Stack1/abcdef-example"; + + var urls = CloudFormationResourceProvisioner.MapCloudFormationStackUrl(client, stackId); + Assert.Null(urls); + } + + [Fact] + public void ConsoleUrlNotCreated_UnknownRegion() + { + using var client = new AmazonCloudFormationClient(new AmazonCloudFormationConfig + { + ServiceURL = "https://cloudformation.example-north-1.amazonaws.example.com/", + }); + + const string stackId = "arn:aws:cloudformation:example-north-1:111111111111:stack/Stack1/abcdef-example"; + + var urls = CloudFormationResourceProvisioner.MapCloudFormationStackUrl(client, stackId); + Assert.Null(urls); + } +}