From ffbb42bc5cb77e4fe6b4170fa0ba59386cd9686e Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Thu, 6 Feb 2025 00:09:11 -0800 Subject: [PATCH 1/3] For the Lambda integration automatically install Amazon.Lambda.TestTool which is the Lambda service emulator --- .../b2019317-5414-4315-b539-7735b04f8e63.json | 11 ++ Directory.Packages.props | 1 + .../Lambda/APIGatewayApiResource.cs | 17 -- .../Lambda/APIGatewayEmulatorResource.cs | 26 +++ .../Lambda/APIGatewayExtensions.cs | 11 +- .../Lambda/LambdaEmulatorAnnotation.cs | 18 ++ .../Lambda/LambdaEmulatorOptions.cs | 27 +++ .../Lambda/LambdaEmulatorResource.cs | 22 +++ .../Lambda/LambdaExtensions.cs | 76 +++++--- .../Lambda/LambdaLifecycleHook.cs | 126 +++++++++++++ .../Utils/Internal/ProcessCommandService.cs | 101 ++++++++++ .../AWSCDKResourceTests.cs | 4 +- .../AWSCloudFormationResourceTests.cs | 5 +- .../AWSSchemaTests.cs | 2 +- .../Aspire.Hosting.AWS.UnitTests.csproj | 1 + .../CloudFormationAWSConsoleUrlTests.cs | 2 +- .../DynamoDBLocalCommandLineArgumentTests.cs | 2 +- .../InstallLambdaTestToolTests.cs | 176 ++++++++++++++++++ .../ManifestUtils.cs | 2 +- .../StackOutputReferenceTests.cs | 2 +- 20 files changed, 575 insertions(+), 57 deletions(-) create mode 100644 .autover/changes/b2019317-5414-4315-b539-7735b04f8e63.json delete mode 100644 src/Aspire.Hosting.AWS/Lambda/APIGatewayApiResource.cs create mode 100644 src/Aspire.Hosting.AWS/Lambda/APIGatewayEmulatorResource.cs create mode 100644 src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorOptions.cs create mode 100644 src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorResource.cs create mode 100644 src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs create mode 100644 src/Aspire.Hosting.AWS/Utils/Internal/ProcessCommandService.cs create mode 100644 tests/Aspire.Hosting.AWS.UnitTests/InstallLambdaTestToolTests.cs diff --git a/.autover/changes/b2019317-5414-4315-b539-7735b04f8e63.json b/.autover/changes/b2019317-5414-4315-b539-7735b04f8e63.json new file mode 100644 index 0000000..9386907 --- /dev/null +++ b/.autover/changes/b2019317-5414-4315-b539-7735b04f8e63.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Aspire.Hosting.AWS", + "Type": "Patch", + "ChangelogMessages": [ + "Automatically install .NET Tool Amazon.Lambda.TestTool when running Lambda functions" + ] + } + ] +} \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 4f70933..1240f71 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -39,5 +39,6 @@ + \ No newline at end of file diff --git a/src/Aspire.Hosting.AWS/Lambda/APIGatewayApiResource.cs b/src/Aspire.Hosting.AWS/Lambda/APIGatewayApiResource.cs deleted file mode 100644 index 15fc64e..0000000 --- a/src/Aspire.Hosting.AWS/Lambda/APIGatewayApiResource.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - -using Aspire.Hosting.ApplicationModel; - -namespace Aspire.Hosting.AWS.Lambda; - - -/// -/// Resource representing the Amazon API Gateway emulator. -/// -/// Aspire resource name -public class APIGatewayApiResource(string name) : ExecutableResource(name, - "dotnet-lambda-test-tool", - Environment.CurrentDirectory - ) -{ -} diff --git a/src/Aspire.Hosting.AWS/Lambda/APIGatewayEmulatorResource.cs b/src/Aspire.Hosting.AWS/Lambda/APIGatewayEmulatorResource.cs new file mode 100644 index 0000000..bb1edf4 --- /dev/null +++ b/src/Aspire.Hosting.AWS/Lambda/APIGatewayEmulatorResource.cs @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +using Aspire.Hosting.ApplicationModel; +using k8s.KubeConfigModels; + +namespace Aspire.Hosting.AWS.Lambda; + + +/// +/// Resource representing the Amazon API Gateway emulator. +/// +/// Aspire resource name +public class APIGatewayEmulatorResource(string name, APIGatewayType apiGatewayType) : ExecutableResource(name, + "dotnet", + Environment.CurrentDirectory + ) +{ + internal void AddCommandLineArguments(IList arguments) + { + arguments.Add("lambda-test-tool"); + arguments.Add("--no-launch-window"); + + arguments.Add("--api-gateway-emulator-mode"); + arguments.Add(apiGatewayType.ToString()); + } +} diff --git a/src/Aspire.Hosting.AWS/Lambda/APIGatewayExtensions.cs b/src/Aspire.Hosting.AWS/Lambda/APIGatewayExtensions.cs index f5af15a..94e2226 100644 --- a/src/Aspire.Hosting.AWS/Lambda/APIGatewayExtensions.cs +++ b/src/Aspire.Hosting.AWS/Lambda/APIGatewayExtensions.cs @@ -24,15 +24,12 @@ public static class APIGatewayExtensions /// Aspire resource name /// The type of API Gateway API. For example Rest, HttpV1 or HttpV2 /// - public static IResourceBuilder AddAWSAPIGatewayEmulator(this IDistributedApplicationBuilder builder, string name, APIGatewayType apiGatewayType) + public static IResourceBuilder AddAWSAPIGatewayEmulator(this IDistributedApplicationBuilder builder, string name, APIGatewayType apiGatewayType) { - var apiGatewayEmulator = builder.AddResource(new APIGatewayApiResource(name)).ExcludeFromManifest(); - + var apiGatewayEmulator = builder.AddResource(new APIGatewayEmulatorResource(name, apiGatewayType)).ExcludeFromManifest(); apiGatewayEmulator.WithArgs(context => { - context.Args.Add("--api-gateway-emulator-mode"); - context.Args.Add(apiGatewayType.ToString()); - context.Args.Add("--no-launch-window"); + apiGatewayEmulator.Resource.AddCommandLineArguments(context.Args); }); var annotation = new EndpointAnnotation( @@ -62,7 +59,7 @@ public static IResourceBuilder AddAWSAPIGatewayEmulator(t /// The HTTP method the Lambda function should be called for. /// The resource path the Lambda function should be called for. /// - public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder lambda, Method httpMethod, string path) + public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder lambda, Method httpMethod, string path) { LambdaEmulatorAnnotation? lambdaEmulatorAnnotation = null; if (builder.ApplicationBuilder.Resources.FirstOrDefault(x => x.TryGetLastAnnotation(out lambdaEmulatorAnnotation)) == null || diff --git a/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorAnnotation.cs b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorAnnotation.cs index 0da7ab3..a405cce 100644 --- a/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorAnnotation.cs +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorAnnotation.cs @@ -14,4 +14,22 @@ internal class LambdaEmulatorAnnotation(EndpointReference endpoint) : IResourceA /// The HTTP endpoint for the Lambda runtime emulator. /// public EndpointReference Endpoint { get; init; } = endpoint; + + /// + /// If set to true Amazon.Lambda.TestTool will updated/installed during AppHost startup. Amazon.Lambda.TestTool is + /// a .NET Tool that will be installed globally. + /// + public bool DisableAutoInstall { get; set; } + + /// + /// Override the minimum version of Amazon.Lambda.TestTool that will be installed. If a newer vesion is already installed + /// it will be used unless AllowDowngrade is set to true. + /// + public string? OverrideMinimumInstallVersion { get; set; } + + /// + /// If set to true and a newer version of the Amazon.Lambda.TestTool is installed then expected the installed version will be downgraded + /// to match the expected version. + /// + public bool AllowDowngrade { get; set; } } diff --git a/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorOptions.cs b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorOptions.cs new file mode 100644 index 0000000..bd481f1 --- /dev/null +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorOptions.cs @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +namespace Aspire.Hosting.AWS.Lambda; + +/// +/// Options that can be added the Lambda emulator resource. +/// +public class LambdaEmulatorOptions +{ + /// + /// If set to true, Amazon.Lambda.TestTool will updated/installed during AppHost startup. Amazon.Lambda.TestTool is + /// a .NET Tool that will be installed globally. + /// + public bool DisableAutoInstall { get; set; } + + /// + /// Override the minimum version of Amazon.Lambda.TestTool that will be installed. If a newer version is already installed + /// it will be used unless AllowDowngrade is set to true. + /// + public string? OverrideMinimumInstallVersion { get; set; } + + /// + /// If set to true, and a newer version of Amazon.Lambda.TestTool is already installed then the requested version, the installed version + /// will be downgraded to the request version. + /// + public bool AllowDowngrade { get; set; } +} diff --git a/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorResource.cs b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorResource.cs new file mode 100644 index 0000000..f384d81 --- /dev/null +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorResource.cs @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.AWS.Lambda; + + +/// +/// Resource representing the Lambda Runtime API service emulator. +/// +/// Aspire resource name +public class LambdaEmulatorResource(string name) : ExecutableResource(name, + "dotnet", + Environment.CurrentDirectory + ) +{ + internal void AddCommandLineArguments(IList arguments) + { + arguments.Add("lambda-test-tool"); + arguments.Add("--no-launch-window"); + } +} diff --git a/src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs b/src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs index c0447f8..757ef9e 100644 --- a/src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs @@ -1,12 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -using Amazon.Runtime.Internal.Endpoints.StandardLibrary; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.AWS; using Aspire.Hosting.AWS.Lambda; +using Aspire.Hosting.AWS.Utils.Internal; using Aspire.Hosting.Lifecycle; -using Microsoft.Extensions.DependencyInjection; -using System.Collections.Immutable; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection.Extensions; using System.Diagnostics; using System.Net.Sockets; using System.Runtime.Versioning; @@ -97,32 +97,62 @@ public static class LambdaExtensions return resource; } - private static ExecutableResource AddOrGetLambdaServiceEmulatorResource(IDistributedApplicationBuilder builder) - { - if (builder.Resources.FirstOrDefault(x => x.TryGetAnnotationsOfType(out _)) is not ExecutableResource serviceEmulator) + /// + /// Add the Lambda service emulator resource. The AddAWSLambdaFunction method will automatically add the Lambda service emulator if it hasn't + /// already been added. This method only needs to be called if the emulator needs to be customized with the LambdaEmulatorOptions. If + /// this method is called it must be called only once and before any AddAWSLambdaFunction calls. + /// + /// + /// The options to configure the emulator with. + /// + /// Thrown if the Lambda service emulator has already been added. + public static IResourceBuilder AddAWSLambdaServiceEmulator(this IDistributedApplicationBuilder builder, LambdaEmulatorOptions? options = null) + { + if (builder.Resources.FirstOrDefault(x => x.TryGetAnnotationsOfType(out _)) is ExecutableResource serviceEmulator) { - var serviceEmulatorBuilder = builder.AddExecutable($"Lambda-ServiceEmulator", - "dotnet-lambda-test-tool", - Environment.CurrentDirectory, - "--no-launch-window") - .ExcludeFromManifest(); + throw new InvalidOperationException("A Lambda service emulator has already been added. The AddAWSLambdaFunction will add the emulator " + + "if it hasn't already been added. This method must be called before AddAWSLambdaFunction if the Lambda service emulator needs to be customized."); + } - var annotation = new EndpointAnnotation( - protocol: ProtocolType.Tcp, - uriScheme: "http"); + builder.Services.TryAddSingleton(); - serviceEmulatorBuilder.WithAnnotation(annotation); - var endpointReference = new EndpointReference(serviceEmulatorBuilder.Resource, annotation); + var lambdaEmulator = builder.AddResource(new LambdaEmulatorResource("LambdaServiceEmulator")).ExcludeFromManifest(); + lambdaEmulator.WithArgs(context => + { + lambdaEmulator.Resource.AddCommandLineArguments(context.Args); + }); - serviceEmulatorBuilder.WithAnnotation(new LambdaEmulatorAnnotation(endpointReference)); + var annotation = new EndpointAnnotation( + protocol: ProtocolType.Tcp, + uriScheme: "http"); - serviceEmulatorBuilder.WithAnnotation(new EnvironmentCallbackAnnotation(context => - { - context.EnvironmentVariables[Constants.IsAspireHostedEnvVariable] = "true"; - context.EnvironmentVariables["LAMBDA_RUNTIME_API_PORT"] = endpointReference.Property(EndpointProperty.TargetPort); - })); + lambdaEmulator.WithAnnotation(annotation); + var endpointReference = new EndpointReference(lambdaEmulator.Resource, annotation); - serviceEmulator = serviceEmulatorBuilder.Resource; + lambdaEmulator.WithAnnotation(new LambdaEmulatorAnnotation(endpointReference) + { + DisableAutoInstall = options?.DisableAutoInstall ?? false, + OverrideMinimumInstallVersion = options?.OverrideMinimumInstallVersion, + AllowDowngrade = options?.AllowDowngrade ?? false, + }); + + lambdaEmulator.WithAnnotation(new EnvironmentCallbackAnnotation(context => + { + context.EnvironmentVariables[Constants.IsAspireHostedEnvVariable] = "true"; + context.EnvironmentVariables["LAMBDA_RUNTIME_API_PORT"] = endpointReference.Property(EndpointProperty.TargetPort); + })); + + serviceEmulator = lambdaEmulator.Resource; + builder.Services.TryAddLifecycleHook(); + + return lambdaEmulator; + } + + private static ExecutableResource AddOrGetLambdaServiceEmulatorResource(IDistributedApplicationBuilder builder) + { + if (builder.Resources.FirstOrDefault(x => x.TryGetAnnotationsOfType(out _)) is not ExecutableResource serviceEmulator) + { + serviceEmulator = builder.AddAWSLambdaServiceEmulator().Resource; } return serviceEmulator; diff --git a/src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs b/src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs new file mode 100644 index 0000000..3f28ea6 --- /dev/null +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs @@ -0,0 +1,126 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS.Utils.Internal; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using static Google.Protobuf.Reflection.GeneratedCodeInfo.Types; + +namespace Aspire.Hosting.AWS.Lambda; + +/// +/// Lambda lifecycle hook takes care of getting Amazon.Lambda.TestTool is installed if there was +/// a Lambda service emulator added to the resources. +/// +/// +internal class LambdaLifecycleHook(ILogger logger, IProcessCommandService processCommandService) : IDistributedApplicationLifecycleHook +{ + internal const string DefaultLambdaTestToolVersion = "0.0.2-preview"; + + public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + LambdaEmulatorAnnotation? emulatorAnnotation = null; + if (appModel.Resources.FirstOrDefault(x => x.TryGetLastAnnotation(out emulatorAnnotation)) != null && emulatorAnnotation != null) + { + await ApplyLambdaEmulatorAnnotationAsync(emulatorAnnotation, cancellationToken); + } + else + { + logger.LogDebug("Skipping installing Amazon.Lambda.TestTool since no Lambda emulator resource was found"); + } + } + + internal async Task ApplyLambdaEmulatorAnnotationAsync(LambdaEmulatorAnnotation emulatorAnnotation, CancellationToken cancellationToken = default) + { + if (emulatorAnnotation.DisableAutoInstall) + { + return; + } + + var expectedVersion = emulatorAnnotation.OverrideMinimumInstallVersion ?? DefaultLambdaTestToolVersion; + var installedVersion = await GetCurrentInstalledVersionAsync(cancellationToken); + + if (ShouldInstall(installedVersion, expectedVersion, emulatorAnnotation.AllowDowngrade)) + { + logger.LogDebug("Installing .NET Tool Amazon.Lambda.TestTool ({version})", installedVersion); + + var commandLineArgument = $"tool install -g Amazon.Lambda.TestTool --version {expectedVersion}"; + if (emulatorAnnotation.AllowDowngrade) + { + commandLineArgument += " --allow-downgrade"; + } + + var result = await processCommandService.RunProcessAndCaptureOuputAsync(logger, "dotnet", commandLineArgument, cancellationToken); + if (result.ExitCode == 0) + { + if (!string.IsNullOrEmpty(installedVersion)) + { + logger.LogInformation("Successfully Updated .NET Tool Amazon.Lambda.TestTool from version {installedVersion} to {newVersion}", installedVersion, expectedVersion); + } + else + { + logger.LogInformation("Successfully installed .NET Tool Amazon.Lambda.TestTool ({version})", expectedVersion); + } + } + else + { + if (!string.IsNullOrEmpty(installedVersion)) + { + logger.LogWarning("Failed to update Amazon.Lambda.TestTool from {installedVersion} to {expectedVersion}:\n{output}", installedVersion, expectedVersion, result.Output); + } + else + { + logger.LogError("Fail to install Amazon.Lambda.TestTool ({version}) required for running Lambda functions locally:\n{output}", expectedVersion, result.Output); + } + } + } + else + { + logger.LogInformation("Amazon.Lambda.TestTool version {version} already installed", installedVersion); + } + } + + internal static bool ShouldInstall(string currentInstalledVersion, string expectedVersionStr, bool allowDowngrading) + { + if (string.IsNullOrEmpty(currentInstalledVersion)) + { + return true; + } + + var installedVersion = Version.Parse(currentInstalledVersion.Replace("-preview", string.Empty)); + var expectedVersion = Version.Parse(expectedVersionStr.Replace("-preview", string.Empty)); + + return (installedVersion < expectedVersion) || (allowDowngrading && installedVersion != expectedVersion); + } + + private async Task GetCurrentInstalledVersionAsync(CancellationToken cancellationToken) + { + var results = await processCommandService.RunProcessAndCaptureOuputAsync(logger, "dotnet", "lambda-test-tool --tool-info", cancellationToken); + if (results.ExitCode != 0) + { + return string.Empty; + } + + try + { + var versionDoc = JsonNode.Parse(results.Output); + if (versionDoc == null) + { + logger.LogWarning("Error parsing version information from Amazon.Lambda.TestTool: {versionInfo}", results.Output); + return string.Empty; + + } + var version = versionDoc["version"]?.ToString(); + logger.LogDebug("Installed version of Amazon.Lambda.TestTool is {version}", version); + return version ?? string.Empty; + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Error parsing version information from Amazon.Lambda.TestTool: {versionInfo}", results.Output); + return string.Empty; + } + } +} diff --git a/src/Aspire.Hosting.AWS/Utils/Internal/ProcessCommandService.cs b/src/Aspire.Hosting.AWS/Utils/Internal/ProcessCommandService.cs new file mode 100644 index 0000000..422c7c7 --- /dev/null +++ b/src/Aspire.Hosting.AWS/Utils/Internal/ProcessCommandService.cs @@ -0,0 +1,101 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Text; + +namespace Aspire.Hosting.AWS.Utils.Internal; + +/// +/// An internal service interface for shelling out commands +/// +public interface IProcessCommandService +{ + /// + /// Record capturing the exit code and console output. The Output will be the combined stdout and stderr. + /// + /// + /// + public record RunProcessAndCaptureStdOutResult(int ExitCode, string Output); + + /// + /// Method to shell out commands. + /// + /// + /// + /// + /// + /// + Task RunProcessAndCaptureOuputAsync(ILogger logger, string path, string arguments, CancellationToken cancellationToken); +} + +internal class ProcessCommandService : IProcessCommandService +{ + + /// + /// Utility method for running a command on the commandline. It returns backs the exit code and anything written to stdout or stderr. + /// + /// + /// + /// + /// + /// + public async Task RunProcessAndCaptureOuputAsync(ILogger logger, string path, string arguments, CancellationToken cancellationToken) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + WorkingDirectory = Directory.GetCurrentDirectory(), + FileName = path, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WindowStyle = ProcessWindowStyle.Hidden + } + }; + + var output = new StringBuilder(); + + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + output.Append(e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + output.Append(e.Data); + } + }; + + try + { + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + catch (Exception ex) + { + // If this fails then it most likey means the executable being invoked does not exist. + logger.LogDebug(ex, "Failed to start process {process}.", path); + return new IProcessCommandService.RunProcessAndCaptureStdOutResult(-404, string.Empty); + } + + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + logger.LogDebug("Process {process} exited with code {exitCode}.", path, process.ExitCode); + return new IProcessCommandService.RunProcessAndCaptureStdOutResult(process.ExitCode, output.ToString()); + } + + return new IProcessCommandService.RunProcessAndCaptureStdOutResult(process.ExitCode, output.ToString()); + + } +} diff --git a/tests/Aspire.Hosting.AWS.UnitTests/AWSCDKResourceTests.cs b/tests/Aspire.Hosting.AWS.UnitTests/AWSCDKResourceTests.cs index 71b402e..c4386fa 100644 --- a/tests/Aspire.Hosting.AWS.UnitTests/AWSCDKResourceTests.cs +++ b/tests/Aspire.Hosting.AWS.UnitTests/AWSCDKResourceTests.cs @@ -2,11 +2,11 @@ using Amazon; using Amazon.CDK.AWS.S3; -using Aspire.Hosting.Utils; +using Aspire.Hosting.AWS.UnitTests.Utils; using Constructs; using Xunit; -namespace Aspire.Hosting.AWS.Tests; +namespace Aspire.Hosting.AWS.UnitTests; public class AWSCDKResourceTests { diff --git a/tests/Aspire.Hosting.AWS.UnitTests/AWSCloudFormationResourceTests.cs b/tests/Aspire.Hosting.AWS.UnitTests/AWSCloudFormationResourceTests.cs index 2b01315..1a3283c 100644 --- a/tests/Aspire.Hosting.AWS.UnitTests/AWSCloudFormationResourceTests.cs +++ b/tests/Aspire.Hosting.AWS.UnitTests/AWSCloudFormationResourceTests.cs @@ -1,12 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. using Amazon; -using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.AWS.CloudFormation; -using Aspire.Hosting.Utils; +using Aspire.Hosting.AWS.UnitTests.Utils; using Xunit; -namespace Aspire.Hosting.AWS.Tests; +namespace Aspire.Hosting.AWS.UnitTests; public class AWSCloudFormationResourceTests { diff --git a/tests/Aspire.Hosting.AWS.UnitTests/AWSSchemaTests.cs b/tests/Aspire.Hosting.AWS.UnitTests/AWSSchemaTests.cs index 85d06fd..f0ad207 100644 --- a/tests/Aspire.Hosting.AWS.UnitTests/AWSSchemaTests.cs +++ b/tests/Aspire.Hosting.AWS.UnitTests/AWSSchemaTests.cs @@ -6,7 +6,7 @@ using Xunit; using Xunit.Abstractions; -namespace Aspire.Hosting.AWS.Tests; +namespace Aspire.Hosting.AWS.UnitTests; public class AWSSchemaTests(ITestOutputHelper output) { diff --git a/tests/Aspire.Hosting.AWS.UnitTests/Aspire.Hosting.AWS.UnitTests.csproj b/tests/Aspire.Hosting.AWS.UnitTests/Aspire.Hosting.AWS.UnitTests.csproj index fef49c7..2fa2089 100644 --- a/tests/Aspire.Hosting.AWS.UnitTests/Aspire.Hosting.AWS.UnitTests.csproj +++ b/tests/Aspire.Hosting.AWS.UnitTests/Aspire.Hosting.AWS.UnitTests.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/Aspire.Hosting.AWS.UnitTests/CloudFormationAWSConsoleUrlTests.cs b/tests/Aspire.Hosting.AWS.UnitTests/CloudFormationAWSConsoleUrlTests.cs index f870ae7..7176aae 100644 --- a/tests/Aspire.Hosting.AWS.UnitTests/CloudFormationAWSConsoleUrlTests.cs +++ b/tests/Aspire.Hosting.AWS.UnitTests/CloudFormationAWSConsoleUrlTests.cs @@ -6,7 +6,7 @@ using Aspire.Hosting.AWS.Provisioning; using Xunit; -namespace Aspire.Hosting.AWS.Tests; +namespace Aspire.Hosting.AWS.UnitTests; public class CloudFormationAWSConsoleUrlTests { diff --git a/tests/Aspire.Hosting.AWS.UnitTests/DynamoDBLocalCommandLineArgumentTests.cs b/tests/Aspire.Hosting.AWS.UnitTests/DynamoDBLocalCommandLineArgumentTests.cs index 853a085..9a4492c 100644 --- a/tests/Aspire.Hosting.AWS.UnitTests/DynamoDBLocalCommandLineArgumentTests.cs +++ b/tests/Aspire.Hosting.AWS.UnitTests/DynamoDBLocalCommandLineArgumentTests.cs @@ -3,7 +3,7 @@ using Aspire.Hosting.AWS.DynamoDB; using Xunit; -namespace Aspire.Hosting.AWS.Tests; +namespace Aspire.Hosting.AWS.UnitTests; public class DynamoDBLocalCommandLineArgumentTests { diff --git a/tests/Aspire.Hosting.AWS.UnitTests/InstallLambdaTestToolTests.cs b/tests/Aspire.Hosting.AWS.UnitTests/InstallLambdaTestToolTests.cs new file mode 100644 index 0000000..f51eac5 --- /dev/null +++ b/tests/Aspire.Hosting.AWS.UnitTests/InstallLambdaTestToolTests.cs @@ -0,0 +1,176 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.AWS.Lambda; +using Aspire.Hosting.AWS.Utils.Internal; +using Microsoft.Extensions.Logging; +using Xunit; +using Moq; + +namespace Aspire.Hosting.AWS.UnitTests; + +#pragma warning disable CA2252 +public class InstallLambdaTestToolTests +{ + [Fact] + public async Task InstallWithNothingCurrentlyInstalled() + { + var loggerMock = new Mock>(); + + var processCommandService = new MockProcessCommandService( + new IProcessCommandService.RunProcessAndCaptureStdOutResult (-404, "Command not found" ), + new IProcessCommandService.RunProcessAndCaptureStdOutResult(0, "Installed successfully") + ); + + var lambdaHook = new LambdaLifecycleHook(loggerMock.Object, processCommandService); + await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference())); + + processCommandService.AssertCommands( + "lambda-test-tool --tool-info", + $"tool install -g Amazon.Lambda.TestTool --version {LambdaLifecycleHook.DefaultLambdaTestToolVersion}" + ); + } + + [Fact] + public async Task ToolAlreadyInstalled() + { + var loggerMock = new Mock>(); + + var processCommandService = new MockProcessCommandService( + new IProcessCommandService.RunProcessAndCaptureStdOutResult(0, GenerateVersionJson(LambdaLifecycleHook.DefaultLambdaTestToolVersion)) + ); + + var lambdaHook = new LambdaLifecycleHook(loggerMock.Object, processCommandService); + await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference())); + + processCommandService.AssertCommands( + "lambda-test-tool --tool-info" + ); + } + + [Fact] + public async Task ToolNeedsToUpdated() + { + var loggerMock = new Mock>(); + + var processCommandService = new MockProcessCommandService( + new IProcessCommandService.RunProcessAndCaptureStdOutResult(0, GenerateVersionJson("0.0.1-preview")), + new IProcessCommandService.RunProcessAndCaptureStdOutResult(0, "Installed successfully") + ); + + var lambdaHook = new LambdaLifecycleHook(loggerMock.Object, processCommandService); + await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference())); + + processCommandService.AssertCommands( + "lambda-test-tool --tool-info", + $"tool install -g Amazon.Lambda.TestTool --version {LambdaLifecycleHook.DefaultLambdaTestToolVersion}" + ); + } + + [Fact] + public async Task NewerVersionAlreadyInstalled() + { + var loggerMock = new Mock>(); + + var processCommandService = new MockProcessCommandService( + new IProcessCommandService.RunProcessAndCaptureStdOutResult(0, GenerateVersionJson("99.99.99")), + new IProcessCommandService.RunProcessAndCaptureStdOutResult(0, "Installed successfully") + ); + + + var lambdaHook = new LambdaLifecycleHook(loggerMock.Object, processCommandService); + await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference())); + + processCommandService.AssertCommands( + "lambda-test-tool --tool-info" + ); + } + + [Fact] + public async Task OverrideVersionToNewerVersion() + { + var loggerMock = new Mock>(); + + var processCommandService = new MockProcessCommandService( + new IProcessCommandService.RunProcessAndCaptureStdOutResult(0, GenerateVersionJson(LambdaLifecycleHook.DefaultLambdaTestToolVersion)), + new IProcessCommandService.RunProcessAndCaptureStdOutResult(0, "Installed successfully") + ); + + const string overrideVersion = "99.99.99"; + var lambdaHook = new LambdaLifecycleHook(loggerMock.Object, processCommandService); + await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference()) { OverrideMinimumInstallVersion = overrideVersion}); + + processCommandService.AssertCommands( + "lambda-test-tool --tool-info", + $"tool install -g Amazon.Lambda.TestTool --version {overrideVersion}" + ); + } + + [Fact] + public async Task DisableAutoInstall() + { + var loggerMock = new Mock>(); + + var processCommandService = new MockProcessCommandService(); + + var lambdaHook = new LambdaLifecycleHook(loggerMock.Object, processCommandService); + await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference()) { DisableAutoInstall = true }); + + processCommandService.AssertCommands(); + } + + [Fact] + public async Task AllowDowngrading() + { + var loggerMock = new Mock>(); + + var processCommandService = new MockProcessCommandService( + new IProcessCommandService.RunProcessAndCaptureStdOutResult(0, GenerateVersionJson("99.99.99")), + new IProcessCommandService.RunProcessAndCaptureStdOutResult(0, "Installed successfully") + ); + + var lambdaHook = new LambdaLifecycleHook(loggerMock.Object, processCommandService); + await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference()) { AllowDowngrade = true }); + + processCommandService.AssertCommands( + "lambda-test-tool --tool-info", + $"tool install -g Amazon.Lambda.TestTool --version {LambdaLifecycleHook.DefaultLambdaTestToolVersion} --allow-downgrade" + ); + } + + private EndpointReference CreateFakeEndpointReference() => new EndpointReference(new Mock().Object, "http"); + + private string GenerateVersionJson(string toolVersion) => $"{{\"version\":\"{toolVersion}\"}}"; + + public class MockProcessCommandService(params IProcessCommandService.RunProcessAndCaptureStdOutResult[] results) : IProcessCommandService + { + + public int CallCount { get; private set; } + + public IList> CommandsExecuted { get; } = new List>(); + + public Task RunProcessAndCaptureOuputAsync(ILogger logger, string path, string arguments, CancellationToken cancellationToken) + { + if (CallCount == results.Length) + { + throw new InvalidOperationException("The process command was called more times than expected"); + } + CommandsExecuted.Add(new Tuple(path, arguments)); + + var result = results[CallCount]; + CallCount++; + return Task.FromResult(result); + } + + public void AssertCommands(params string[] commandArguments) + { + Assert.Equal(commandArguments.Length, CommandsExecuted.Count); + + for (int i = 0; i < commandArguments.Length; i++) + { + Assert.Equal("dotnet", CommandsExecuted[i].Item1); + Assert.Equal(commandArguments[i], CommandsExecuted[i].Item2); + } + } + } +} diff --git a/tests/Aspire.Hosting.AWS.UnitTests/ManifestUtils.cs b/tests/Aspire.Hosting.AWS.UnitTests/ManifestUtils.cs index 49a68d8..f999c8e 100644 --- a/tests/Aspire.Hosting.AWS.UnitTests/ManifestUtils.cs +++ b/tests/Aspire.Hosting.AWS.UnitTests/ManifestUtils.cs @@ -7,7 +7,7 @@ using System.Text.Json.Nodes; using Xunit; -namespace Aspire.Hosting.Utils; +namespace Aspire.Hosting.AWS.UnitTests.Utils; // copied from https://github.com/dotnet/aspire/blob/13bcaa03819f5e342d96bdffed92a50c7175198d/tests/Aspire.Hosting.Tests/Utils/ManifestUtils.cs diff --git a/tests/Aspire.Hosting.AWS.UnitTests/StackOutputReferenceTests.cs b/tests/Aspire.Hosting.AWS.UnitTests/StackOutputReferenceTests.cs index eb16e27..29f1ae4 100644 --- a/tests/Aspire.Hosting.AWS.UnitTests/StackOutputReferenceTests.cs +++ b/tests/Aspire.Hosting.AWS.UnitTests/StackOutputReferenceTests.cs @@ -4,7 +4,7 @@ using Aspire.Hosting.AWS.CloudFormation; using Xunit; -namespace Aspire.Hosting.AWS.Tests; +namespace Aspire.Hosting.AWS.UnitTests; public class StackOutputReferenceTests { From 1662376d325ce89a00d31c802a2312f778cbb404 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Thu, 6 Feb 2025 11:18:53 -0800 Subject: [PATCH 2/3] Address PR comment --- src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs b/src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs index 3f28ea6..f571c8f 100644 --- a/src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs @@ -12,7 +12,7 @@ namespace Aspire.Hosting.AWS.Lambda; /// -/// Lambda lifecycle hook takes care of getting Amazon.Lambda.TestTool is installed if there was +/// Lambda lifecycle hook takes care of getting Amazon.Lambda.TestTool installed if there was /// a Lambda service emulator added to the resources. /// /// From 21c6ad8d5f2d4d4aa5f4dd2075d4bab29d24341f Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Thu, 6 Feb 2025 15:37:38 -0800 Subject: [PATCH 3/3] Responding to changes on Amazon.Lambda.TestTool for changing to use commands. --- .../Lambda/APIGatewayEmulatorResource.cs | 1 + .../Lambda/LambdaEmulatorAnnotation.cs | 4 +++- .../Lambda/LambdaEmulatorOptions.cs | 6 ++++-- .../Lambda/LambdaEmulatorResource.cs | 1 + src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs | 6 +++--- .../Lambda/LambdaLifecycleHook.cs | 4 ++-- .../InstallLambdaTestToolTests.cs | 14 +++++++------- 7 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Hosting.AWS/Lambda/APIGatewayEmulatorResource.cs b/src/Aspire.Hosting.AWS/Lambda/APIGatewayEmulatorResource.cs index bb1edf4..fc5dc59 100644 --- a/src/Aspire.Hosting.AWS/Lambda/APIGatewayEmulatorResource.cs +++ b/src/Aspire.Hosting.AWS/Lambda/APIGatewayEmulatorResource.cs @@ -18,6 +18,7 @@ public class APIGatewayEmulatorResource(string name, APIGatewayType apiGatewayTy internal void AddCommandLineArguments(IList arguments) { arguments.Add("lambda-test-tool"); + arguments.Add("start"); arguments.Add("--no-launch-window"); arguments.Add("--api-gateway-emulator-mode"); diff --git a/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorAnnotation.cs b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorAnnotation.cs index a405cce..28807fd 100644 --- a/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorAnnotation.cs +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorAnnotation.cs @@ -16,8 +16,10 @@ internal class LambdaEmulatorAnnotation(EndpointReference endpoint) : IResourceA public EndpointReference Endpoint { get; init; } = endpoint; /// - /// If set to true Amazon.Lambda.TestTool will updated/installed during AppHost startup. Amazon.Lambda.TestTool is + /// By default Amazon.Lambda.TestTool will be updated/installed during AppHost startup. Amazon.Lambda.TestTool is /// a .NET Tool that will be installed globally. + /// + /// When DisableAutoInstall is set to true the auto installation is disabled. /// public bool DisableAutoInstall { get; set; } diff --git a/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorOptions.cs b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorOptions.cs index bd481f1..0852d2c 100644 --- a/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorOptions.cs +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorOptions.cs @@ -3,13 +3,15 @@ namespace Aspire.Hosting.AWS.Lambda; /// -/// Options that can be added the Lambda emulator resource. +/// Options that can be added to the Lambda emulator resource. /// public class LambdaEmulatorOptions { /// - /// If set to true, Amazon.Lambda.TestTool will updated/installed during AppHost startup. Amazon.Lambda.TestTool is + /// By default Amazon.Lambda.TestTool will be updated/installed during AppHost startup. Amazon.Lambda.TestTool is /// a .NET Tool that will be installed globally. + /// + /// When DisableAutoInstall is set to true the auto installation is disabled. /// public bool DisableAutoInstall { get; set; } diff --git a/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorResource.cs b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorResource.cs index f384d81..40aa15b 100644 --- a/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorResource.cs +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorResource.cs @@ -17,6 +17,7 @@ public class LambdaEmulatorResource(string name) : ExecutableResource(name, internal void AddCommandLineArguments(IList arguments) { arguments.Add("lambda-test-tool"); + arguments.Add("start"); arguments.Add("--no-launch-window"); } } diff --git a/src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs b/src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs index 757ef9e..46a4929 100644 --- a/src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs @@ -98,9 +98,9 @@ public static class LambdaExtensions } /// - /// Add the Lambda service emulator resource. The AddAWSLambdaFunction method will automatically add the Lambda service emulator if it hasn't - /// already been added. This method only needs to be called if the emulator needs to be customized with the LambdaEmulatorOptions. If - /// this method is called it must be called only once and before any AddAWSLambdaFunction calls. + /// Add the Lambda service emulator resource. The method will automatically add the Lambda service emulator if it hasn't + /// already been added. This method only needs to be called if the emulator needs to be customized with the . If + /// this method is called it must be called only once and before any calls. /// /// /// The options to configure the emulator with. diff --git a/src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs b/src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs index f571c8f..c4704c7 100644 --- a/src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs @@ -98,7 +98,7 @@ internal static bool ShouldInstall(string currentInstalledVersion, string expect private async Task GetCurrentInstalledVersionAsync(CancellationToken cancellationToken) { - var results = await processCommandService.RunProcessAndCaptureOuputAsync(logger, "dotnet", "lambda-test-tool --tool-info", cancellationToken); + var results = await processCommandService.RunProcessAndCaptureOuputAsync(logger, "dotnet", "lambda-test-tool info --format json", cancellationToken); if (results.ExitCode != 0) { return string.Empty; @@ -113,7 +113,7 @@ private async Task GetCurrentInstalledVersionAsync(CancellationToken can return string.Empty; } - var version = versionDoc["version"]?.ToString(); + var version = versionDoc["Version"]?.ToString(); logger.LogDebug("Installed version of Amazon.Lambda.TestTool is {version}", version); return version ?? string.Empty; } diff --git a/tests/Aspire.Hosting.AWS.UnitTests/InstallLambdaTestToolTests.cs b/tests/Aspire.Hosting.AWS.UnitTests/InstallLambdaTestToolTests.cs index f51eac5..8909912 100644 --- a/tests/Aspire.Hosting.AWS.UnitTests/InstallLambdaTestToolTests.cs +++ b/tests/Aspire.Hosting.AWS.UnitTests/InstallLambdaTestToolTests.cs @@ -26,7 +26,7 @@ public async Task InstallWithNothingCurrentlyInstalled() await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference())); processCommandService.AssertCommands( - "lambda-test-tool --tool-info", + "lambda-test-tool info --format json", $"tool install -g Amazon.Lambda.TestTool --version {LambdaLifecycleHook.DefaultLambdaTestToolVersion}" ); } @@ -44,7 +44,7 @@ public async Task ToolAlreadyInstalled() await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference())); processCommandService.AssertCommands( - "lambda-test-tool --tool-info" + "lambda-test-tool info --format json" ); } @@ -62,7 +62,7 @@ public async Task ToolNeedsToUpdated() await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference())); processCommandService.AssertCommands( - "lambda-test-tool --tool-info", + "lambda-test-tool info --format json", $"tool install -g Amazon.Lambda.TestTool --version {LambdaLifecycleHook.DefaultLambdaTestToolVersion}" ); } @@ -82,7 +82,7 @@ public async Task NewerVersionAlreadyInstalled() await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference())); processCommandService.AssertCommands( - "lambda-test-tool --tool-info" + "lambda-test-tool info --format json" ); } @@ -101,7 +101,7 @@ public async Task OverrideVersionToNewerVersion() await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference()) { OverrideMinimumInstallVersion = overrideVersion}); processCommandService.AssertCommands( - "lambda-test-tool --tool-info", + "lambda-test-tool info --format json", $"tool install -g Amazon.Lambda.TestTool --version {overrideVersion}" ); } @@ -133,14 +133,14 @@ public async Task AllowDowngrading() await lambdaHook.ApplyLambdaEmulatorAnnotationAsync(new LambdaEmulatorAnnotation(CreateFakeEndpointReference()) { AllowDowngrade = true }); processCommandService.AssertCommands( - "lambda-test-tool --tool-info", + "lambda-test-tool info --format json", $"tool install -g Amazon.Lambda.TestTool --version {LambdaLifecycleHook.DefaultLambdaTestToolVersion} --allow-downgrade" ); } private EndpointReference CreateFakeEndpointReference() => new EndpointReference(new Mock().Object, "http"); - private string GenerateVersionJson(string toolVersion) => $"{{\"version\":\"{toolVersion}\"}}"; + private string GenerateVersionJson(string toolVersion) => $"{{\"Version\":\"{toolVersion}\"}}"; public class MockProcessCommandService(params IProcessCommandService.RunProcessAndCaptureStdOutResult[] results) : IProcessCommandService {