internal class LambdaLifecycleHook()

in src/Aspire.Hosting.AWS/Lambda/LambdaLifecycleHook.cs [18:288]


internal class LambdaLifecycleHook(ILogger<LambdaEmulatorResource> logger, IProcessCommandService processCommandService) : IDistributedApplicationLifecycleHook
{
    public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
    {
        SdkUtilities.BackgroundSDKDefaultConfigValidation(logger);

        // The Lambda function handler for a Class Library contains "::".
        // This is an example of a class library function handler "WebCalculatorFunctions::WebCalculatorFunctions.Functions::AddFunctionHandler".
        var classLibraryProjectPaths = 
            appModel.Resources
                .OfType<LambdaProjectResource>()
                .Where(x =>
                {
                    if (!x.TryGetLastAnnotation<LambdaFunctionAnnotation>(out var functionAnnotation))
                        return false;

                    if (!functionAnnotation.Handler.Contains("::"))
                        return false;

                    return true;
                })
                .ToList();
        
        LambdaEmulatorAnnotation? emulatorAnnotation = null;
        if (appModel.Resources.FirstOrDefault(x => x.TryGetLastAnnotation<LambdaEmulatorAnnotation>(out emulatorAnnotation)) != null && emulatorAnnotation != null)
        {
            await ApplyLambdaEmulatorAnnotationAsync(emulatorAnnotation, cancellationToken);

            foreach (var projectResource in classLibraryProjectPaths)
            {
                var projectMetadata = projectResource.Annotations
                    .OfType<IProjectMetadata>()
                    .First();
                var lambdaFunctionAnnotation = projectResource.Annotations
                    .OfType<LambdaFunctionAnnotation>()
                    .First();
                
                // If we are running Aspire through an IDE where a debugger is attached,
                // we want to configure the Aspire resource to use a Launch Setting Profile that will be able to run the class library Lambda function.
                if (AspireUtilities.IsRunningInDebugger)
                {
                    var installPath = await GetCurrentInstallPathAsync(cancellationToken);
                    if (string.IsNullOrEmpty(installPath))
                    {
                        logger.LogError("Failed to determine The location of Amazon.Lambda.TestTool on disk which is required for running class library Lambda functions.");
                        return;
                    }
                    var contentFolder = new DirectoryInfo(installPath).Parent?.Parent?.Parent?.FullName;
                    if (string.IsNullOrEmpty(contentFolder))
                    {
                        logger.LogError("Failed to determine the content folder of Amazon.Lambda.TestTool NuGet package which is required for running class library Lambda functions.");
                        return;
                    }
                    var targetFramework = await GetProjectTargetFrameworkAsync(projectMetadata.ProjectPath, cancellationToken);
                    if (string.IsNullOrEmpty(targetFramework))
                    {
                        logger.LogError("Cannot determine the target framework of the project '{ProjectPath}'", projectMetadata.ProjectPath);
                        continue;
                    }
                    var assemblyName = await GetProjectAssemblyNameAsync(projectMetadata.ProjectPath, cancellationToken);
                    if (string.IsNullOrEmpty(assemblyName))
                    {
                        logger.LogError("Cannot determine the assembly name of the project '{ProjectPath}'", projectMetadata.ProjectPath);
                        continue;
                    }
                    var runtimeSupportAssemblyPath = Path.Combine(contentFolder, "content", "Amazon.Lambda.RuntimeSupport",
                        targetFramework, "Amazon.Lambda.RuntimeSupport.dll");
                    if (!File.Exists(runtimeSupportAssemblyPath))
                    {
                        logger.LogError("Cannot find a version of Amazon.Lambda.RuntimeSupport that supports your project's target framework '{Framework}'. The following directory does not exist '{RuntimeSupportPath}'.", targetFramework, runtimeSupportAssemblyPath);
                        continue;
                    }
                    ProjectUtilities.UpdateLaunchSettingsWithLambdaTester(
                        resourceName: projectResource.Name,
                        functionHandler: lambdaFunctionAnnotation.Handler,
                        assemblyName: assemblyName,
                        projectPath: projectMetadata.ProjectPath,
                        runtimeSupportAssemblyPath: runtimeSupportAssemblyPath,
                        targetFramework: targetFramework,
                        logger: logger);
                }
                // If we are running outside an IDE, the Launch Setting Profile approach does not work.
                // We need to create a wrapper executable project that runs the class library project and add the wrapper project as the Aspire resource.
                else
                {
                    var targetFramework = await GetProjectTargetFrameworkAsync(projectMetadata.ProjectPath, cancellationToken);
                    if (string.IsNullOrEmpty(targetFramework))
                    {
                        logger.LogError("Cannot determine the target framework of the project '{ProjectPath}'", projectMetadata.ProjectPath);
                        continue;
                    }
                    
                    projectResource.Annotations.Remove(projectMetadata);

                    var projectPath =
                        ProjectUtilities.CreateExecutableWrapperProject(projectMetadata.ProjectPath, lambdaFunctionAnnotation.Handler, targetFramework);
                
                    projectResource.Annotations.Add(new LambdaProjectMetadata(projectPath));
                    
                    var projectName = new FileInfo(projectPath).Name;
                    var workingDirectory = Directory.GetParent(projectPath)!.FullName;
                    processCommandService.RunProcess(logger, "dotnet", $"build {projectName}", workingDirectory);
                    processCommandService.RunProcess(logger, "dotnet", $"build -c Release {projectName}", workingDirectory);
                }
            }
        }
        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 ?? Constants.DefaultLambdaTestToolVersion;
        var installedVersion = await GetCurrentInstalledVersionAsync(cancellationToken);

        if (ShouldInstall(installedVersion, expectedVersion, emulatorAnnotation.AllowDowngrade))
        {
            logger.LogDebug("Installing .NET Tool Amazon.Lambda.TestTool ({version})", expectedVersion);

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

    internal async Task<string> GetCurrentInstallPathAsync(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 installPathDoc = JsonNode.Parse(results.Output);
            if (installPathDoc == null)
            {
                logger.LogWarning("Error parsing install path information from Amazon.Lambda.TestTool: {versionInfo}", results.Output);
                return string.Empty;

            }
            var installPath = installPathDoc["InstallPath"]?.ToString();
            logger.LogDebug("Install path of Amazon.Lambda.TestTool is {version}", installPath);
            return installPath ?? string.Empty;
        }
        catch (JsonException ex)
        {
            logger.LogWarning(ex, "Error parsing install path information from Amazon.Lambda.TestTool: {versionInfo}", results.Output);
            return string.Empty;
        }
    }

    internal async Task<string> GetProjectAssemblyNameAsync(string projectPath, CancellationToken cancellationToken)
    {
        try
        {
            var results = await processCommandService.RunProcessAndCaptureOuputAsync(logger, "dotnet", $"msbuild \"{projectPath}\" -nologo -v:q -getProperty:AssemblyName", cancellationToken);
            if (results.ExitCode != 0)
            {
                return string.Empty;
            }

            logger.LogDebug("The assembly name of '{projectPath}' is {assemblyName}", projectPath, results.Output);
            return results.Output;
        }
        catch (JsonException ex)
        {
            logger.LogWarning(ex, "Error retrieving the assembly name of '{projectPath}'", projectPath);
            return string.Empty;
        }
    }

    internal async Task<string> GetProjectTargetFrameworkAsync(string projectPath, CancellationToken cancellationToken)
    {
        try
        {
            var results = await processCommandService.RunProcessAndCaptureOuputAsync(logger, "dotnet", $"msbuild \"{projectPath}\" -nologo -v:q -getProperty:TargetFramework", cancellationToken);
            if (results.ExitCode != 0)
            {
                return string.Empty;
            }

            logger.LogDebug("The target framework of '{projectPath}' is {targetFramework}", projectPath, results.Output);
            return results.Output;
        }
        catch (JsonException ex)
        {
            logger.LogWarning(ex, "Error retrieving the target framework of '{projectPath}'", projectPath);
            return string.Empty;
        }
    }
}