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 a8fd1d4..f9d7824 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -41,5 +41,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..fc5dc59 --- /dev/null +++ b/src/Aspire.Hosting.AWS/Lambda/APIGatewayEmulatorResource.cs @@ -0,0 +1,27 @@ +// 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("start"); + 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..28807fd 100644 --- a/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorAnnotation.cs +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorAnnotation.cs @@ -14,4 +14,24 @@ internal class LambdaEmulatorAnnotation(EndpointReference endpoint) : IResourceA /// The HTTP endpoint for the Lambda runtime emulator. /// public EndpointReference Endpoint { get; init; } = endpoint; + + /// + /// 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; } + + /// + /// 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..0852d2c --- /dev/null +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorOptions.cs @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +namespace Aspire.Hosting.AWS.Lambda; + +/// +/// Options that can be added to the Lambda emulator resource. +/// +public class LambdaEmulatorOptions +{ + /// + /// 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; } + + /// + /// 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..40aa15b --- /dev/null +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaEmulatorResource.cs @@ -0,0 +1,23 @@ +// 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("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 2e8565c..0eade31 100644 --- a/src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs +++ b/src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs @@ -4,6 +4,10 @@ using Aspire.Hosting.AWS; using Aspire.Hosting.AWS.Lambda; using Microsoft.Extensions.Hosting; +using Aspire.Hosting.AWS.Utils.Internal; +using Aspire.Hosting.Lifecycle; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection.Extensions; using System.Diagnostics; using System.Net.Sockets; using System.Runtime.Versioning; @@ -54,6 +58,7 @@ public static class LambdaExtensions // Add the Lambda function resource on the path so the emulator can distingish request // for each Lambda function. var apiPath = $"{serviceEmulatorEndpoint.Host}:{serviceEmulatorEndpoint.Port}/{name}"; + context.EnvironmentVariables["AWS_EXECUTION_ENV"] = $"aspire.hosting.aws#{SdkUtilities.GetAssemblyVersion()}"; context.EnvironmentVariables["AWS_LAMBDA_RUNTIME_API"] = apiPath; context.EnvironmentVariables["AWS_LAMBDA_FUNCTION_NAME"] = name; context.EnvironmentVariables["_HANDLER"] = lambdaHandler; @@ -96,6 +101,67 @@ public static class LambdaExtensions return resource; } + /// + /// 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. + /// + /// 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) + { + 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."); + } + + builder.Services.TryAddSingleton(); + + var lambdaEmulator = builder.AddResource(new LambdaEmulatorResource("LambdaServiceEmulator")).ExcludeFromManifest(); + lambdaEmulator.WithArgs(context => + { + lambdaEmulator.Resource.AddCommandLineArguments(context.Args); + }); + + var annotation = new EndpointAnnotation( + protocol: ProtocolType.Tcp, + uriScheme: "http"); + + lambdaEmulator.WithAnnotation(annotation); + var endpointReference = new EndpointReference(lambdaEmulator.Resource, annotation); + + 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; + } + /// /// This method is adapted from the Aspire WithProjectDefaults method. /// https://github.com/dotnet/aspire/blob/157f312e39300912b37a14f59beda217c8195e14/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs#L287 @@ -122,35 +188,4 @@ private static IResourceBuilder WithOpenTelemetry(this IR return builder; } - - private static ExecutableResource AddOrGetLambdaServiceEmulatorResource(IDistributedApplicationBuilder builder) - { - if (builder.Resources.FirstOrDefault(x => x.TryGetAnnotationsOfType(out _)) is not ExecutableResource serviceEmulator) - { - var serviceEmulatorBuilder = builder.AddExecutable($"Lambda-ServiceEmulator", - "dotnet-lambda-test-tool", - Environment.CurrentDirectory, - "--no-launch-window") - .ExcludeFromManifest(); - - var annotation = new EndpointAnnotation( - protocol: ProtocolType.Tcp, - uriScheme: "http"); - - serviceEmulatorBuilder.WithAnnotation(annotation); - var endpointReference = new EndpointReference(serviceEmulatorBuilder.Resource, annotation); - - serviceEmulatorBuilder.WithAnnotation(new LambdaEmulatorAnnotation(endpointReference)); - - serviceEmulatorBuilder.WithAnnotation(new EnvironmentCallbackAnnotation(context => - { - context.EnvironmentVariables[Constants.IsAspireHostedEnvVariable] = "true"; - context.EnvironmentVariables["LAMBDA_RUNTIME_API_PORT"] = endpointReference.Property(EndpointProperty.TargetPort); - })); - - serviceEmulator = serviceEmulatorBuilder.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..c4704c7 --- /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 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 info --format json", 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/SdkUtilities.cs b/src/Aspire.Hosting.AWS/SdkUtilities.cs index aae3be6..7f4d53b 100644 --- a/src/Aspire.Hosting.AWS/SdkUtilities.cs +++ b/src/Aspire.Hosting.AWS/SdkUtilities.cs @@ -15,20 +15,19 @@ private static string GetUserAgentStringSuffix() { if (s_userAgentHeader == null) { - var builder = new StringBuilder("lib/aspire.hosting.aws"); - var attribute = typeof(AWSLifecycleHook).Assembly.GetCustomAttribute(); - if (attribute != null) - { - builder.Append('#'); - builder.Append(attribute.InformationalVersion); - } - + var builder = new StringBuilder($"lib/aspire.hosting.aws#{GetAssemblyVersion()}"); s_userAgentHeader = builder.ToString(); } return s_userAgentHeader; } + internal static string GetAssemblyVersion() + { + var attribute = typeof(AWSLifecycleHook).Assembly.GetCustomAttribute(); + return attribute != null ? attribute.InformationalVersion.Split('+')[0] : "Unknown"; + } + internal static void ConfigureUserAgentString(object sender, RequestEventArgs e) { var suffix = GetUserAgentStringSuffix(); 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..8909912 --- /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 info --format json", + $"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 info --format json" + ); + } + + [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 info --format json", + $"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 info --format json" + ); + } + + [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 info --format json", + $"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 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}\"}}"; + + 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 {