diff --git a/.autover/changes/b30ca600-2c50-4ed1-91d0-610f711b9d88.json b/.autover/changes/b30ca600-2c50-4ed1-91d0-610f711b9d88.json new file mode 100644 index 00000000..bbf26001 --- /dev/null +++ b/.autover/changes/b30ca600-2c50-4ed1-91d0-610f711b9d88.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Tools", + "Type": "Minor", + "ChangelogMessages": [ + "Add support for packaging and deploying C# file-based Lambda functions" + ] + } + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 47e5a1fe..b35298af 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ **/node_modules/ **/TestGenerations/ +**/artifacts/ **/.vscode **/.idea diff --git a/aws-extensions-for-dotnet-cli.sln b/aws-extensions-for-dotnet-cli.sln index 28335904..449f0585 100644 --- a/aws-extensions-for-dotnet-cli.sln +++ b/aws-extensions-for-dotnet-cli.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32228.430 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11222.16 d18.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A5EA2C7D-846F-4266-8423-EFC4BA0FE4B6}" EndProject @@ -69,6 +69,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestIntegerFunction", "test EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestFunctionBuildProps", "testapps\TestFunctionBuildProps\TestFunctionBuildProps\TestFunctionBuildProps.csproj", "{AFA71B8E-F0AA-4704-8C4E-C11130F82B13}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SingleFileLambdaFunctions", "SingleFileLambdaFunctions", "{5711F48F-5491-4C8A-92B2-4D6D849483B5}" + ProjectSection(SolutionItems) = preProject + testapps\SingleFileLambdaFunctions\serverless.template = testapps\SingleFileLambdaFunctions\serverless.template + testapps\SingleFileLambdaFunctions\ToUpperFunctionImplicitAOT.cs = testapps\SingleFileLambdaFunctions\ToUpperFunctionImplicitAOT.cs + testapps\SingleFileLambdaFunctions\ToUpperFunctionNoAOT.cs = testapps\SingleFileLambdaFunctions\ToUpperFunctionNoAOT.cs + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -207,6 +214,7 @@ Global {69FFA03C-D29F-40E0-9E7F-572D5E10AF77} = {BB3CF729-8213-4DDD-85AE-A5E7754F3944} {D7F1DFA4-066B-469C-B04C-DF032CF152C1} = {BB3CF729-8213-4DDD-85AE-A5E7754F3944} {AFA71B8E-F0AA-4704-8C4E-C11130F82B13} = {BB3CF729-8213-4DDD-85AE-A5E7754F3944} + {5711F48F-5491-4C8A-92B2-4D6D849483B5} = {BB3CF729-8213-4DDD-85AE-A5E7754F3944} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DBFC70D6-49A2-40A1-AB08-5D9504AB7112} diff --git a/buildtools/ci.buildspec.yml b/buildtools/ci.buildspec.yml index 7591a753..c76d6971 100644 --- a/buildtools/ci.buildspec.yml +++ b/buildtools/ci.buildspec.yml @@ -5,8 +5,9 @@ env: DOTNET_RUNNING_IN_CONTAINER: "true" phases: install: - runtime-versions: - dotnet: 8.x + commands: + - curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 + - dotnet new globaljson --sdk-version 10.0.0 --roll-forward minor build: commands: - dotnet test --verbosity normal test/Amazon.Common.DotNetCli.Tools.Test/Amazon.Common.DotNetCli.Tools.Test.csproj --configuration Release --logger trx --results-directory ./testresults diff --git a/src/Amazon.Common.DotNetCli.Tools/Commands/BaseCommand.cs b/src/Amazon.Common.DotNetCli.Tools/Commands/BaseCommand.cs index a431e006..ea4be32d 100644 --- a/src/Amazon.Common.DotNetCli.Tools/Commands/BaseCommand.cs +++ b/src/Amazon.Common.DotNetCli.Tools/Commands/BaseCommand.cs @@ -31,7 +31,17 @@ public abstract class BaseCommand : ICommand public BaseCommand(IToolLogger logger, string workingDirectory) { this.Logger = logger; - this.WorkingDirectory = workingDirectory; + + // In case working directory was set to a file then use the parent directory as the working directory. + // This can happen in the C# single file scenario. + if (File.Exists(workingDirectory)) + { + this.WorkingDirectory = Directory.GetParent(workingDirectory).FullName; + } + else + { + this.WorkingDirectory = workingDirectory; + } } public BaseCommand(IToolLogger logger, string workingDirectory, IList possibleOptions, string[] args) @@ -756,7 +766,11 @@ private int WaitForIndexResponse(int min, int max) public IToolLogger Logger { get; protected set; } - public string WorkingDirectory { get; set; } + public string WorkingDirectory + { + get; + set; + } protected string GetUserAgentString() { diff --git a/src/Amazon.Common.DotNetCli.Tools/Utilities.cs b/src/Amazon.Common.DotNetCli.Tools/Utilities.cs index 49754b47..263f45e0 100644 --- a/src/Amazon.Common.DotNetCli.Tools/Utilities.cs +++ b/src/Amazon.Common.DotNetCli.Tools/Utilities.cs @@ -2,7 +2,9 @@ using Amazon.S3; using Amazon.S3.Model; using Amazon.S3.Transfer; +using Amazon.Util; using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -11,14 +13,11 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Xml.Linq; using System.Xml.XPath; -using Amazon.Util; -using System.Text.RegularExpressions; -using System.Collections; -using System.Xml; -using System.Text.Json; namespace Amazon.Common.DotNetCli.Tools { @@ -143,7 +142,7 @@ public static string RelativePathTo(string start, string relativeTo) for (index = lastCommonRoot + 1; index < relativeToDirs.Length; index++) { relativePath.Append(relativeToDirs[index]); - if(index + 1 < relativeToDirs.Length) + if (index + 1 < relativeToDirs.Length) { relativePath.Append("/"); } @@ -161,10 +160,16 @@ public static string GetSolutionDirectoryFullPath(string workingDirectory, strin { return Path.Combine(workingDirectory, givenSolutionDirectory).TrimEnd('\\', '/'); } - + return givenSolutionDirectory.TrimEnd('\\', '/'); } + // If we are using .NET 10 or later file based C# files then treat the parent directory as the solution folder. + if (IsSingleFileCSharpFile(projectLocation)) + { + return Directory.GetParent(projectLocation).FullName.TrimEnd('\\', '/'); + } + // If we weren't given a solution path, try to find one looking up from the project file. var currentDirectory = projectLocation; @@ -179,9 +184,9 @@ public static string GetSolutionDirectoryFullPath(string workingDirectory, strin { return currentDirectory.TrimEnd('\\', '/'); } - + DirectoryInfo dirInfo = Directory.GetParent(currentDirectory); - if ((dirInfo == null) || !dirInfo.Exists) + if ((dirInfo == null) || !dirInfo.Exists) { break; } @@ -203,11 +208,26 @@ public static string GetSolutionDirectoryFullPath(string workingDirectory, strin /// public static string DeterminePublishLocation(string workingDirectory, string projectLocation, string configuration, string targetFramework) { - var path = Path.Combine(DetermineProjectLocation(workingDirectory, projectLocation), - "bin", - configuration, - targetFramework, - "publish"); + string path; + if (IsSingleFileCSharpFile(projectLocation)) // For example projectLocation = /path/to/file.cs + { + // Do not rely on Directory.GetParent() because if this value will eventually run inside a container but + // the host is Windows the slashes and root will get messed up. + int forwardSlashPosition = projectLocation.LastIndexOf('/'); + int backSlashPosition = projectLocation.LastIndexOf('\\'); + int position = Math.Max(forwardSlashPosition, backSlashPosition); + var parentFolder = projectLocation.Substring(0, position); + + path = Path.Combine(parentFolder, "artifacts", Path.GetFileNameWithoutExtension(projectLocation)); + } + else + { + path = Path.Combine(DetermineProjectLocation(workingDirectory, projectLocation), + "bin", + configuration, + targetFramework, + "publish"); + } return path; } @@ -333,7 +353,8 @@ public static string LookupTargetFrameworkFromProjectFile(string projectLocation if (properties.TryGetValue("TargetFrameworks", out var targetFrameworks) && !string.IsNullOrEmpty(targetFrameworks)) { var frameworks = targetFrameworks.Split(';'); - if (frameworks.Length > 1 ){ + if (frameworks.Length > 1) + { return null; } return frameworks[0]; @@ -380,6 +401,25 @@ public static bool LookPublishAotFlag(string projectLocation, string msBuildPara } } + // By default file base C# files are published with Native AOT. Users can override this by adding a line that says "#:property PublishAot=false". + if (Utilities.IsSingleFileCSharpFile(projectLocation)) + { + var directiveRegex = new Regex(@"^\s*#\s*:\s*property\s+PublishAot\s*=\s*false\s*$", RegexOptions.IgnoreCase); + foreach (var rawLine in File.ReadLines(projectLocation)) + { + if (string.IsNullOrWhiteSpace(rawLine)) + continue; + + var m = directiveRegex.Match(rawLine); + if (m.Success) + { + return false; + } + } + + return true; + } + var properties = LookupProjectProperties(projectLocation, msBuildParameters, "PublishAot"); if (properties.TryGetValue("PublishAot", out var publishAot)) { @@ -409,8 +449,8 @@ private static string FindProjectFileInDirectory(string directory) { if (File.Exists(directory)) return directory; - - foreach (var ext in new [] { "*.csproj", "*.fsproj", "*.vbproj" }) + + foreach (var ext in new[] { "*.csproj", "*.fsproj", "*.vbproj" }) { var files = Directory.GetFiles(directory, ext, SearchOption.TopDirectoryOnly); if (files.Length == 1) @@ -449,7 +489,7 @@ public static string DetermineProjectLocation(string workingDirectory, string pr return location.TrimEnd('\\', '/'); } - + /// /// Determine where the dotnet build directory is. /// @@ -624,7 +664,7 @@ private static IDictionary GetFilesToIncludeInArchive(string pub { var relativePath = file.Substring(publishLocation.Length) .Replace(Path.DirectorySeparatorChar.ToString(), uniformDirectorySeparator.ToString()); - + if (relativePath[0] == uniformDirectorySeparator) relativePath = relativePath.Substring(1); @@ -709,7 +749,7 @@ private static void BundleWithZipCLI(string zipCLI, string zipArchivePath, strin } } } - + public static async Task ValidateBucketRegionAsync(IAmazonS3 s3Client, string s3Bucket) { string bucketRegion; @@ -717,14 +757,14 @@ public static async Task ValidateBucketRegionAsync(IAmazonS3 s3Client, string s3 { bucketRegion = await Utilities.GetBucketRegionAsync(s3Client, s3Bucket); } - catch(Exception e) + catch (Exception e) { Console.Error.WriteLine($"Warning: Unable to determine region for bucket {s3Bucket}, assuming bucket is in correct region: {e.Message}", ToolsException.CommonErrorCode.S3GetBucketLocation, e); return; } var configuredRegion = s3Client.Config.RegionEndpoint?.SystemName; - if(configuredRegion == null && !string.IsNullOrEmpty(s3Client.Config.ServiceURL)) + if (configuredRegion == null && !string.IsNullOrEmpty(s3Client.Config.ServiceURL)) { configuredRegion = AWSSDKUtils.DetermineRegion(s3Client.Config.ServiceURL); } @@ -733,7 +773,7 @@ public static async Task ValidateBucketRegionAsync(IAmazonS3 s3Client, string s3 // knows what they are doing. if (configuredRegion == null) return; - + if (!string.Equals(bucketRegion, configuredRegion)) { throw new ToolsException($"Error: S3 bucket must be in the same region as the configured region {configuredRegion}. {s3Bucket} is in the region {bucketRegion}.", ToolsException.CommonErrorCode.BucketInDifferentRegionThenClient); @@ -838,7 +878,7 @@ private static EventHandler CreateProgressHandler(IT EventHandler handler = ((s, e) => { if (e.PercentDone != percentToUpdateOn && e.PercentDone <= percentToUpdateOn) return; - + var increment = e.PercentDone % UPLOAD_PROGRESS_INCREMENT; if (increment == 0) increment = UPLOAD_PROGRESS_INCREMENT; @@ -848,14 +888,14 @@ private static EventHandler CreateProgressHandler(IT return handler; } - + private static EventHandler CreateTransferUtilityProgressHandler(IToolLogger logger) { var percentToUpdateOn = UPLOAD_PROGRESS_INCREMENT; EventHandler handler = ((s, e) => { if (e.PercentDone != percentToUpdateOn && e.PercentDone <= percentToUpdateOn) return; - + var increment = e.PercentDone % UPLOAD_PROGRESS_INCREMENT; if (increment == 0) increment = UPLOAD_PROGRESS_INCREMENT; @@ -865,7 +905,7 @@ private static EventHandler CreateTransferUtilityProgressHan return handler; } - + internal static int WaitForPromptResponseByIndex(int min, int max) { int chosenIndex = -1; @@ -885,8 +925,8 @@ internal static int WaitForPromptResponseByIndex(int min, int max) return chosenIndex; } - - + + static readonly string GENERIC_ASSUME_ROLE_POLICY = @" { @@ -940,8 +980,8 @@ public static ExecuteShellCommandResult ExecuteShellCommand(string workingDirect if (string.IsNullOrEmpty(e.Data)) return; capturedOutput.AppendLine(e.Data); - }); - + }); + using (var proc = new Process()) { proc.StartInfo = startInfo; @@ -959,7 +999,7 @@ public static ExecuteShellCommandResult ExecuteShellCommand(string workingDirect proc.WaitForExit(); return new ExecuteShellCommandResult(proc.ExitCode, capturedOutput.ToString()); - } + } } public static string ReadSecretFromConsole() @@ -982,7 +1022,7 @@ public static string ReadSecretFromConsole() } // i.Key > 31: Skip the initial ascii control characters like ESC and tab. The space character is 32. // KeyChar == '\u0000' if the key pressed does not correspond to a printable character, e.g. F1, Pause-Break, etc - else if ((int)i.Key > 31 && i.KeyChar != '\u0000') + else if ((int)i.Key > 31 && i.KeyChar != '\u0000') { code.Append(i.KeyChar); Console.Write("*"); @@ -990,8 +1030,8 @@ public static string ReadSecretFromConsole() } return code.ToString().Trim(); } - - + + public static void CopyDirectory(string sourceDirectory, string destinationDirectory, bool copySubDirectories) { // Get the subdirectories for the specified directory. @@ -1003,7 +1043,7 @@ public static void CopyDirectory(string sourceDirectory, string destinationDirec "Source directory does not exist or could not be found: " + sourceDirectory); } - + // If the destination directory doesn't exist, create it. if (!Directory.Exists(destinationDirectory)) { @@ -1037,7 +1077,7 @@ public static bool TryGenerateECRRepositoryName(string projectName, out string r { projectName = new DirectoryInfo(projectName).Name; } - else if(File.Exists(projectName)) + else if (File.Exists(projectName)) { projectName = Path.GetFileNameWithoutExtension(projectName); } @@ -1045,20 +1085,20 @@ public static bool TryGenerateECRRepositoryName(string projectName, out string r projectName = projectName.ToLower(); var sb = new StringBuilder(); - foreach(var c in projectName) + foreach (var c in projectName) { - if(char.IsLetterOrDigit(c)) + if (char.IsLetterOrDigit(c)) { sb.Append(c); } - else if(sb.Length > 0 && (c == '.' || c == '_' || c == '-')) + else if (sb.Length > 0 && (c == '.' || c == '_' || c == '-')) { sb.Append(c); } } // Repository name must be at least 2 characters - if(sb.Length > 1) + if (sb.Length > 1) { repositoryName = sb.ToString(); @@ -1071,5 +1111,15 @@ public static bool TryGenerateECRRepositoryName(string projectName, out string r return !string.IsNullOrEmpty(repositoryName); } + + /// + /// Returns true if the project location is using the .NET feature of being a single file based C# file. + /// + /// + /// + public static bool IsSingleFileCSharpFile(string projectLocation) + { + return projectLocation.EndsWith(".cs", StringComparison.Ordinal); + } } } diff --git a/src/Amazon.Lambda.Tools/Amazon.Lambda.Tools.csproj b/src/Amazon.Lambda.Tools/Amazon.Lambda.Tools.csproj index 11bfcbc7..0ceff410 100644 --- a/src/Amazon.Lambda.Tools/Amazon.Lambda.Tools.csproj +++ b/src/Amazon.Lambda.Tools/Amazon.Lambda.Tools.csproj @@ -20,7 +20,7 @@ the source code here is also copied into the AWS VS Toolkit. --> 7.3 - 5.13.2 + 5.13.888 diff --git a/src/Amazon.Lambda.Tools/Commands/DeployFunctionCommand.cs b/src/Amazon.Lambda.Tools/Commands/DeployFunctionCommand.cs index 74f6402d..db708a79 100644 --- a/src/Amazon.Lambda.Tools/Commands/DeployFunctionCommand.cs +++ b/src/Amazon.Lambda.Tools/Commands/DeployFunctionCommand.cs @@ -21,7 +21,7 @@ public class DeployFunctionCommand : UpdateFunctionConfigCommand { public const string COMMAND_DEPLOY_NAME = "deploy-function"; public const string COMMAND_DEPLOY_DESCRIPTION = "Command to deploy the project to AWS Lambda"; - public const string COMMAND_DEPLOY_ARGUMENTS = " The name of the function to deploy"; + public const string COMMAND_DEPLOY_ARGUMENTS = " "; public static readonly IList DeployCommandOptions = BuildLineOptions(new List { @@ -110,6 +110,8 @@ public class DeployFunctionCommand : UpdateFunctionConfigCommand public string ContainerImageForBuild { get; set; } public string CodeMountDirectory { get; set; } + public string InputSingleCSharpFile { get; set; } + public DeployFunctionCommand(IToolLogger logger, string workingDirectory, string[] args) : base(logger, workingDirectory, DeployCommandOptions, args) { @@ -123,9 +125,20 @@ public DeployFunctionCommand(IToolLogger logger, string workingDirectory, string protected override void ParseCommandArguments(CommandOptions values) { base.ParseCommandArguments(values); + if (values.Arguments.Count > 0) { - this.FunctionName = values.Arguments[0]; + foreach (var arg in values.Arguments) + { + if (Utilities.IsSingleFileCSharpFile(arg)) + { + this.InputSingleCSharpFile = arg; + } + else + { + this.FunctionName = arg; + } + } } Tuple tuple; @@ -197,6 +210,9 @@ protected override async Task PerformActionAsync() var architecture = this.GetStringValueOrDefault(this.Architecture, LambdaDefinedCommandOptions.ARGUMENT_FUNCTION_ARCHITECTURE, false); + // Get the current Lambda configuration. If this returns null then the function does not exist and we will create it. + var currentConfiguration = await GetFunctionConfigurationAsync(); + Lambda.PackageType packageType = DeterminePackageType(); string ecrImageUri = null; @@ -218,7 +234,19 @@ protected override async Task PerformActionAsync() { if (string.IsNullOrEmpty(package)) { - EnsureInProjectDirectory(); + var lambdaRuntime = currentConfiguration?.Runtime ?? this.GetStringValueOrDefault(this.Runtime, LambdaDefinedCommandOptions.ARGUMENT_FUNCTION_RUNTIME, true); + + if (!string.IsNullOrEmpty(this.InputSingleCSharpFile)) + { + if (Path.IsPathFullyQualified(this.InputSingleCSharpFile)) + projectLocation = this.InputSingleCSharpFile; + else + projectLocation = Path.Combine(projectLocation, this.InputSingleCSharpFile); + } + else if (!Utilities.IsSingleFileCSharpFile(projectLocation)) + { + EnsureInProjectDirectory(); + } // Release will be the default configuration if nothing set. string configuration = this.GetStringValueOrDefault(this.Configuration, CommonDefinedCommandOptions.ARGUMENT_CONFIGURATION, false); @@ -227,11 +255,18 @@ protected override async Task PerformActionAsync() var targetFramework = this.GetStringValueOrDefault(this.TargetFramework, CommonDefinedCommandOptions.ARGUMENT_FRAMEWORK, false); if (string.IsNullOrEmpty(targetFramework)) { - targetFramework = Utilities.LookupTargetFrameworkFromProjectFile(projectLocation, msbuildParameters); + if (Utilities.IsSingleFileCSharpFile(projectLocation)) + { + targetFramework = LambdaUtilities.DetermineTargetFrameworkForSingleFile(projectLocation, lambdaRuntime); + } + else + { + targetFramework = Utilities.LookupTargetFrameworkFromProjectFile(projectLocation, msbuildParameters); + } // If we still don't know what the target framework is ask the user what targetframework to use. // This is common when a project is using multi targeting. - if(string.IsNullOrEmpty(targetFramework)) + if (string.IsNullOrEmpty(targetFramework)) { targetFramework = this.GetStringValueOrDefault(this.TargetFramework, CommonDefinedCommandOptions.ARGUMENT_FRAMEWORK, true); } @@ -239,7 +274,7 @@ protected override async Task PerformActionAsync() bool isNativeAot = Utilities.LookPublishAotFlag(projectLocation, this.MSBuildParameters); - ValidateTargetFrameworkAndLambdaRuntime(targetFramework); + LambdaUtilities.ValidateTargetFrameworkAndLambdaRuntime(lambdaRuntime, targetFramework); bool disableVersionCheck = this.GetBoolValueOrDefault(this.DisableVersionCheck, LambdaDefinedCommandOptions.ARGUMENT_DISABLE_VERSION_CHECK, false).GetValueOrDefault(); string publishLocation; @@ -302,9 +337,7 @@ protected override async Task PerformActionAsync() var s3Prefix = this.GetStringValueOrDefault(this.S3Prefix, LambdaDefinedCommandOptions.ARGUMENT_S3_PREFIX, false); s3Key = await Utilities.UploadToS3Async(this.Logger, this.S3Client, s3Bucket, s3Prefix, functionName, lambdaZipArchiveStream); } - - - var currentConfiguration = await GetFunctionConfigurationAsync(); + if (currentConfiguration == null) { this.Logger.WriteLine($"Creating new Lambda function {this.FunctionName}"); @@ -351,7 +384,22 @@ protected override async Task PerformActionAsync() if(packageType == Lambda.PackageType.Zip) { - createRequest.Handler = this.GetStringValueOrDefault(this.Handler, LambdaDefinedCommandOptions.ARGUMENT_FUNCTION_HANDLER, true); + string handler; + if (Utilities.IsSingleFileCSharpFile(projectLocation)) + { + handler = this.GetStringValueOrDefault(this.Handler, LambdaDefinedCommandOptions.ARGUMENT_FUNCTION_HANDLER, false); + if (string.IsNullOrEmpty(handler)) + { + // In a file based Lambda function it is always an executable and the assembly is the file name. + handler = Path.GetFileNameWithoutExtension(projectLocation); + } + } + else + { + handler = this.GetStringValueOrDefault(this.Handler, LambdaDefinedCommandOptions.ARGUMENT_FUNCTION_HANDLER, true); + } + + createRequest.Handler = handler; createRequest.Runtime = this.GetStringValueOrDefault(this.Runtime, LambdaDefinedCommandOptions.ARGUMENT_FUNCTION_RUNTIME, true); createRequest.Layers = layerVersionArns?.ToList(); @@ -574,14 +622,6 @@ private async Task PushLambdaImageAsync() return result; } - - - private void ValidateTargetFrameworkAndLambdaRuntime(string targetFramework) - { - string runtimeName = this.GetStringValueOrDefault(this.Runtime, LambdaDefinedCommandOptions.ARGUMENT_FUNCTION_RUNTIME, true); - LambdaUtilities.ValidateTargetFrameworkAndLambdaRuntime(runtimeName, targetFramework); - } - protected override void SaveConfigFile(Dictionary data) { diff --git a/src/Amazon.Lambda.Tools/Commands/PackageCommand.cs b/src/Amazon.Lambda.Tools/Commands/PackageCommand.cs index 3550849f..e47aebae 100644 --- a/src/Amazon.Lambda.Tools/Commands/PackageCommand.cs +++ b/src/Amazon.Lambda.Tools/Commands/PackageCommand.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; using Amazon.Common.DotNetCli.Tools; using Amazon.Common.DotNetCli.Tools.Commands; @@ -12,7 +13,7 @@ public class PackageCommand : LambdaBaseCommand public const string COMMAND_NAME = "package"; public const string COMMAND_DESCRIPTION = "Command to package a Lambda project either into a zip file or docker image if --package-type is set to \"image\". The output can later be deployed to Lambda " + "with either deploy-function command or with another tool."; - public const string COMMAND_ARGUMENTS = " The name of the zip file to package the project into"; + public const string COMMAND_ARGUMENTS = " "; public static readonly IList PackageCommandOptions = BuildLineOptions(new List { @@ -43,7 +44,8 @@ public class PackageCommand : LambdaBaseCommand public string Configuration { get; set; } public string TargetFramework { get; set; } public string OutputPackageFileName { get; set; } - + public string InputSingleCSharpFile { get; set; } + public string MSBuildParameters { get; set; } public string[] LayerVersionArns { get; set; } @@ -96,7 +98,17 @@ protected override void ParseCommandArguments(CommandOptions values) if (values.Arguments.Count > 0) { - this.OutputPackageFileName = values.Arguments[0]; + foreach (var arg in values.Arguments) + { + if (Utilities.IsSingleFileCSharpFile(arg)) + { + this.InputSingleCSharpFile = arg; + } + else + { + this.OutputPackageFileName = arg; + } + } } Tuple tuple; @@ -146,13 +158,23 @@ protected override void ParseCommandArguments(CommandOptions values) protected override async Task PerformActionAsync() { - EnsureInProjectDirectory(); - // Disable interactive since this command is intended to be run as part of a pipeline. this.DisableInteractive = true; string projectLocation = Utilities.DetermineProjectLocation(this.WorkingDirectory, this.GetStringValueOrDefault(this.ProjectLocation, CommonDefinedCommandOptions.ARGUMENT_PROJECT_LOCATION, false)); + if (!string.IsNullOrEmpty(this.InputSingleCSharpFile)) + { + if (Path.IsPathFullyQualified(this.InputSingleCSharpFile)) + projectLocation = this.InputSingleCSharpFile; + else + projectLocation = Path.Combine(projectLocation, this.InputSingleCSharpFile); + } + else if (!Utilities.IsSingleFileCSharpFile(projectLocation)) + { + EnsureInProjectDirectory(); + } + Lambda.PackageType packageType = LambdaUtilities.DeterminePackageType(this.GetStringValueOrDefault(this.PackageType, LambdaDefinedCommandOptions.ARGUMENT_PACKAGE_TYPE, false)); if(packageType == Lambda.PackageType.Image) { @@ -209,7 +231,14 @@ protected override async Task PerformActionAsync() var targetFramework = this.GetStringValueOrDefault(this.TargetFramework, CommonDefinedCommandOptions.ARGUMENT_FRAMEWORK, false); if (string.IsNullOrEmpty(targetFramework)) { - targetFramework = Utilities.LookupTargetFrameworkFromProjectFile(projectLocation, msbuildParameters); + if (Utilities.IsSingleFileCSharpFile(projectLocation)) + { + targetFramework = LambdaUtilities.DetermineTargetFrameworkForSingleFile(projectLocation, null); + } + else + { + targetFramework = Utilities.LookupTargetFrameworkFromProjectFile(projectLocation, msbuildParameters); + } // If we still don't know what the target framework is ask the user what targetframework to use. // This is common when a project is using multi targeting. diff --git a/src/Amazon.Lambda.Tools/LambdaDefinedCommandOptions.cs b/src/Amazon.Lambda.Tools/LambdaDefinedCommandOptions.cs index 7df2b81b..1e8dfdc1 100644 --- a/src/Amazon.Lambda.Tools/LambdaDefinedCommandOptions.cs +++ b/src/Amazon.Lambda.Tools/LambdaDefinedCommandOptions.cs @@ -61,7 +61,7 @@ public static class LambdaDefinedCommandOptions ShortSwitch = "-fh", Switch = "--function-handler", ValueType = CommandOption.CommandOptionValueType.StringValue, - Description = "Handler for the function ::::" + Description = "Handler for the function. .NET Class libraries use the format :::: and executables use the format " }; public static readonly CommandOption ARGUMENT_PACKAGE_TYPE = new CommandOption diff --git a/src/Amazon.Lambda.Tools/LambdaPackager.cs b/src/Amazon.Lambda.Tools/LambdaPackager.cs index 6245a1ef..537f14a8 100644 --- a/src/Amazon.Lambda.Tools/LambdaPackager.cs +++ b/src/Amazon.Lambda.Tools/LambdaPackager.cs @@ -217,38 +217,50 @@ public static bool CreateApplicationBundle(LambdaToolsDefaults defaults, IToolLo } } - var buildLocation = Utilities.DetermineBuildLocation(workingDirectory, projectLocation, configuration, targetFramework); - - // This is here for legacy reasons. Some older versions of the dotnet CLI were not - // copying the deps.json file into the publish folder. - foreach (var file in Directory.GetFiles(buildLocation, "*.deps.json", SearchOption.TopDirectoryOnly)) - { - var destinationPath = Path.Combine(publishLocation, Path.GetFileName(file)); - if (!File.Exists(destinationPath)) - File.Copy(file, destinationPath); - } - bool flattenRuntime = false; - var depsJsonTargetNode = GetDepsJsonTargetNode(logger, publishLocation); - // If there is no target node then this means the tool is being used on a future version of .NET Core - // then was available when the this tool was written. Go ahead and continue the deployment with warnings so the - // user can see if the future version will work. - if (depsJsonTargetNode.HasValue && string.Equals(targetFramework, "netcoreapp1.0", StringComparison.OrdinalIgnoreCase)) + // The code inside this block is for old dotnet CLI issues that would not be a factor + // in a dotnet CLI that supports file based csharp files. + if (!Utilities.IsSingleFileCSharpFile(projectLocation)) { - // Make sure the project is not pulling in dependencies requiring a later version of .NET Core then the declared target framework - if (!ValidateDependencies(logger, targetFramework, depsJsonTargetNode.Value, disableVersionCheck)) - return false; + var buildLocation = Utilities.DetermineBuildLocation(workingDirectory, projectLocation, configuration, targetFramework); - // Flatten the runtime folder which reduces the package size by not including native dependencies - // for other platforms. - flattenRuntime = FlattenRuntimeFolder(logger, publishLocation, depsJsonTargetNode.Value); + // This is here for legacy reasons. Some older versions of the dotnet CLI were not + // copying the deps.json file into the publish folder. + foreach (var file in Directory.GetFiles(buildLocation, "*.deps.json", SearchOption.TopDirectoryOnly)) + { + var destinationPath = Path.Combine(publishLocation, Path.GetFileName(file)); + if (!File.Exists(destinationPath)) + File.Copy(file, destinationPath); + } + + var depsJsonTargetNode = GetDepsJsonTargetNode(logger, publishLocation); + // If there is no target node then this means the tool is being used on a future version of .NET Core + // then was available when the this tool was written. Go ahead and continue the deployment with warnings so the + // user can see if the future version will work. + if (depsJsonTargetNode.HasValue && string.Equals(targetFramework, "netcoreapp1.0", StringComparison.OrdinalIgnoreCase)) + { + // Make sure the project is not pulling in dependencies requiring a later version of .NET Core then the declared target framework + if (!ValidateDependencies(logger, targetFramework, depsJsonTargetNode.Value, disableVersionCheck)) + return false; + + // Flatten the runtime folder which reduces the package size by not including native dependencies + // for other platforms. + flattenRuntime = FlattenRuntimeFolder(logger, publishLocation, depsJsonTargetNode.Value); + } } FlattenPowerShellRuntimeModules(logger, publishLocation, targetFramework); - if (zipArchivePath == null) - zipArchivePath = Path.Combine(Directory.GetParent(publishLocation).FullName, new DirectoryInfo(projectLocation).Name + ".zip"); + { + string baseName; + if (Utilities.IsSingleFileCSharpFile(projectLocation)) + baseName = Path.GetFileNameWithoutExtension(projectLocation); + else + baseName = new DirectoryInfo(projectLocation).Name; + + zipArchivePath = Path.Combine(Directory.GetParent(publishLocation).FullName, baseName + ".zip"); + } zipArchivePath = Path.GetFullPath(zipArchivePath); logger?.WriteLine($"Zipping publish folder {publishLocation} to {zipArchivePath}"); @@ -609,7 +621,7 @@ private static IList CalculateRuntimeHierarchy() { foreach (JsonElement importedRuntime in imports.EnumerateArray()) { - queue.Enqueue(importedRuntime.GetString()); + queue.Enqueue(importedRuntime.GetString()); } } } diff --git a/src/Amazon.Lambda.Tools/LambdaUtilities.cs b/src/Amazon.Lambda.Tools/LambdaUtilities.cs index 00bee8a8..48e65210 100644 --- a/src/Amazon.Lambda.Tools/LambdaUtilities.cs +++ b/src/Amazon.Lambda.Tools/LambdaUtilities.cs @@ -1,27 +1,28 @@ -using System; +using Amazon.Common.DotNetCli.Tools; +using Amazon.Lambda.Model; +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.SecurityToken; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text.Json; -using YamlDotNet.RepresentationModel; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System.Xml.Linq; -using Amazon.Common.DotNetCli.Tools; - -using Amazon.Lambda.Model; -using Amazon.S3; -using Amazon.S3.Model; +using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Xml.Linq; using System.Xml.XPath; +using YamlDotNet.RepresentationModel; using Environment = System.Environment; -using Amazon.SecurityToken; -using System.Runtime.InteropServices; namespace Amazon.Lambda.Tools { public static class TargetFrameworkMonikers { + public const string net10_0 = "net10.0"; public const string net90 = "net9.0"; public const string net80 = "net8.0"; public const string net70 = "net7.0"; @@ -54,6 +55,7 @@ public static class LambdaUtilities public static readonly IReadOnlyDictionary _lambdaRuntimeToDotnetFramework = new Dictionary() { + {"dotnet10", TargetFrameworkMonikers.net10_0}, {Amazon.Lambda.Runtime.Dotnet8.Value, TargetFrameworkMonikers.net80}, {Amazon.Lambda.Runtime.Dotnet6.Value, TargetFrameworkMonikers.net60}, {Amazon.Lambda.Runtime.Dotnetcore31.Value, TargetFrameworkMonikers.netcoreapp31}, @@ -81,12 +83,55 @@ public static string DetermineLambdaRuntimeFromTargetFramework(string targetFram return kvp.Key; } + public static string DetermineTargetFrameworkForSingleFile(string filePath, string lambdaRuntime) + { + return DetermineTargetFrameworkForSingleFile(File.ReadAllLines(filePath), lambdaRuntime); + } + + public static string DetermineTargetFrameworkForSingleFile(string[] lines, string lambdaRuntime) + { + string targetFramework = null; + if (lambdaRuntime != null && _lambdaRuntimeToDotnetFramework.TryGetValue(lambdaRuntime, out targetFramework)) + { + return targetFramework; + } + + // Look for an in-file directive that starts with "#:property" and contains "TargetFramework=" + // Allow extra spaces but the directive must begin with "#:property". Extract the value after '='. + if (lines != null && lines.Length > 0) + { + // Pattern explanation: + // ^\s* -> optional leading whitespace + // #\s*:\s*property -> literal "#", optional spaces, ":", optional spaces, "property" + // \s+ -> at least one space (separates property from key) + // TargetFramework -> literal key + // \s*=\s* -> equals with optional surrounding spaces + // (\S+) -> capture the value (non-whitespace sequence) + var directiveRegex = new Regex(@"^\s*#\s*:\s*property\s+TargetFramework\s*=\s*(\S+)", RegexOptions.IgnoreCase); + + foreach (var rawLine in lines) + { + if (string.IsNullOrWhiteSpace(rawLine)) + continue; + + var m = directiveRegex.Match(rawLine); + if (m.Success && m.Groups.Count > 1) + { + targetFramework = m.Groups[1].Value.Trim(); + break; + } + } + } + + return targetFramework; + } + public static void ValidateTargetFramework(string projectLocation, string msbuildParameters, string targetFramework, bool isNativeAot) { var outputType = Utilities.LookupOutputTypeFromProjectFile(projectLocation, msbuildParameters); var ouputTypeIsExe = outputType != null && outputType.ToLower().Equals("exe"); - if (isNativeAot && !ouputTypeIsExe) + if (isNativeAot && !ouputTypeIsExe && !Utilities.IsSingleFileCSharpFile(projectLocation)) { throw new LambdaToolsException($"Native AOT applications must have output type 'exe'.", LambdaToolsException.LambdaErrorCode.NativeAotOutputTypeError); @@ -119,6 +164,8 @@ public static string GetDefaultBuildImage(string targetFramework, string archite switch (targetFramework?.ToLower()) { + case TargetFrameworkMonikers.net10_0: + return $"mcr.microsoft.com/dotnet/sdk:10.0-aot"; case TargetFrameworkMonikers.net90: return $"public.ecr.aws/sam/build-dotnet9:latest-{architecture}"; case TargetFrameworkMonikers.net80: diff --git a/src/Amazon.Lambda.Tools/Program.cs b/src/Amazon.Lambda.Tools/Program.cs index 841937b8..497ba16b 100644 --- a/src/Amazon.Lambda.Tools/Program.cs +++ b/src/Amazon.Lambda.Tools/Program.cs @@ -14,7 +14,7 @@ static void Main(string[] args) new List() { new GroupHeaderInfo("Commands to deploy and manage AWS Lambda functions:"), - new CommandInfo(DeployFunctionCommand.COMMAND_DEPLOY_NAME, DeployFunctionCommand.COMMAND_DEPLOY_DESCRIPTION, DeployFunctionCommand.DeployCommandOptions, DeployFunctionCommand.COMMAND_ARGUMENTS), + new CommandInfo(DeployFunctionCommand.COMMAND_DEPLOY_NAME, DeployFunctionCommand.COMMAND_DEPLOY_DESCRIPTION, DeployFunctionCommand.DeployCommandOptions, DeployFunctionCommand.COMMAND_DEPLOY_ARGUMENTS), new CommandInfo(InvokeFunctionCommand.COMMAND_NAME, InvokeFunctionCommand.COMMAND_DESCRIPTION, InvokeFunctionCommand.InvokeCommandOptions, InvokeFunctionCommand.COMMAND_ARGUMENTS), new CommandInfo(ListFunctionCommand.COMMAND_NAME, ListFunctionCommand.COMMAND_DESCRIPTION, ListFunctionCommand.ListCommandOptions), new CommandInfo(DeleteFunctionCommand.COMMAND_NAME, DeleteFunctionCommand.COMMAND_DESCRIPTION, DeleteFunctionCommand.DeleteCommandOptions, DeleteFunctionCommand.COMMAND_ARGUMENTS), diff --git a/src/Amazon.Lambda.Tools/TemplateProcessor/TemplateProcessorManager.cs b/src/Amazon.Lambda.Tools/TemplateProcessor/TemplateProcessorManager.cs index 03b53a7a..ba217812 100644 --- a/src/Amazon.Lambda.Tools/TemplateProcessor/TemplateProcessorManager.cs +++ b/src/Amazon.Lambda.Tools/TemplateProcessor/TemplateProcessorManager.cs @@ -153,7 +153,7 @@ private async Task ProcessUpdatableResourceAsync(string t results = new UpdateResourceResults { ImageUri = localPath }; } // Uploading a single file as the code for the resource. If the single file is not a zip file then zip the file first. - else if (File.Exists(localPath)) + else if (File.Exists(localPath) && !Utilities.IsSingleFileCSharpFile(localPath)) { if(field.IsCode && !string.Equals(Path.GetExtension(localPath), ".zip", StringComparison.OrdinalIgnoreCase)) { @@ -172,7 +172,7 @@ private async Task ProcessUpdatableResourceAsync(string t { throw new LambdaToolsException($"File that the field {field.Resource.Name}/{field.Name} is pointing to doesn't exist", LambdaToolsException.LambdaErrorCode.ServerlessTemplateMissingLocalPath); } - else if (!Directory.Exists(localPath)) + else if (!Directory.Exists(localPath) && !Utilities.IsSingleFileCSharpFile(localPath)) { throw new LambdaToolsException($"Directory that the field {field.Resource.Name}/{field.Name} is pointing doesn't exist", LambdaToolsException.LambdaErrorCode.ServerlessTemplateMissingLocalPath); } @@ -187,7 +187,7 @@ private async Task ProcessUpdatableResourceAsync(string t // If the function is image upload then run the .NET tools to handle running // docker build even if the current folder is not a .NET project. The .NET // could be in a sub folder or be a self contained Docker build. - if (IsDotnetProjectDirectory(localPath) || field.Resource.UploadType == CodeUploadType.Image) + if (RequiredDotnetPackaging(localPath) || field.Resource.UploadType == CodeUploadType.Image) { results = await PackageDotnetProjectAsync(field, localPath, args); } @@ -240,8 +240,9 @@ private async Task PackageDotnetProjectAsync(IUpdateResou { if (field.Resource.UploadType == CodeUploadType.Zip) { - var command = new Commands.PackageCommand(this.Logger, location, args); + var command = new Commands.PackageCommand(this.Logger, null, args); + command.ProjectLocation = location; command.LambdaClient = this.OriginatingCommand?.LambdaClient; command.S3Client = this.OriginatingCommand?.S3Client; command.IAMClient = this.OriginatingCommand?.IAMClient; @@ -365,8 +366,11 @@ private static string GenerateOutputZipFilename(IUpdateResourceField field) /// /// /// - private bool IsDotnetProjectDirectory(string localPath) + private bool RequiredDotnetPackaging(string localPath) { + if (Utilities.IsSingleFileCSharpFile(localPath)) + return true; + if (!Directory.Exists(localPath)) return false; diff --git a/test/Amazon.Lambda.Tools.Integ.Tests/DeployProjectTests.cs b/test/Amazon.Lambda.Tools.Integ.Tests/DeployProjectTests.cs index b4d43300..74e6d80b 100644 --- a/test/Amazon.Lambda.Tools.Integ.Tests/DeployProjectTests.cs +++ b/test/Amazon.Lambda.Tools.Integ.Tests/DeployProjectTests.cs @@ -1,3 +1,5 @@ +using Amazon.CloudFormation; +using Amazon.CloudFormation.Model; using Amazon.Lambda.Model; using Amazon.Lambda.Tools.Commands; using System; @@ -7,7 +9,6 @@ using System.Threading.Tasks; using Xunit; using Xunit.Abstractions; - using static Amazon.Lambda.Tools.Integ.Tests.TestConstants; namespace Amazon.Lambda.Tools.Integ.Tests @@ -334,6 +335,58 @@ public async Task SetAndClearEnvironmentVariables() } } + [Fact] + public async Task TestDeploySingleCSharpFile() + { + var assembly = this.GetType().GetTypeInfo().Assembly; + var toolLogger = new TestToolLogger(_testOutputHelper); + var csharpFile = Path.GetFullPath(Path.GetDirectoryName(assembly.Location) + "../../../../../../testapps/SingleFileLambdaFunctions/ToUpperFunctionNoAOT.cs"); + + var functionName = "TestDeploySingleCSharpFile-" + DateTime.Now.Ticks; + var deployFunctionCommand = new DeployFunctionCommand(toolLogger, System.Environment.CurrentDirectory, new string[] { csharpFile }); + deployFunctionCommand.Role = await TestHelper.GetTestRoleArnAsync(); + deployFunctionCommand.DisableInteractive = true; + deployFunctionCommand.Runtime = "dotnet10"; + deployFunctionCommand.Timeout = 30; + deployFunctionCommand.MemorySize = 512; + deployFunctionCommand.Region = TEST_REGION; + deployFunctionCommand.Configuration = "Release"; + deployFunctionCommand.FunctionName = functionName; + + var created = false; + try + { + created = await deployFunctionCommand.ExecuteAsync(); + Assert.True(created); + + toolLogger.ClearBuffer(); + var invokeCommand = new InvokeFunctionCommand(toolLogger, System.Environment.CurrentDirectory, new string[0]); + invokeCommand.FunctionName = functionName; + invokeCommand.Payload = "hello world"; + invokeCommand.Region = TEST_REGION; + + await invokeCommand.ExecuteAsync(); + Assert.Contains("HELLO WORLD", toolLogger.Buffer); + } + finally + { + if (created) + { + try + { + var deleteCommand = new DeleteFunctionCommand(toolLogger, System.Environment.CurrentDirectory, new string[0]); + deleteCommand.FunctionName = functionName; + deleteCommand.Region = TEST_REGION; + await deleteCommand.ExecuteAsync(); + } + catch + { + // Bury exception because we don't want to lose any exceptions during the deploy stage. + } + } + } + } + [Fact] public async Task DeployWithFunctionUrl() { diff --git a/test/Amazon.Lambda.Tools.Integ.Tests/DeployServerlessTests.cs b/test/Amazon.Lambda.Tools.Integ.Tests/DeployServerlessTests.cs index 883b01c5..b9da43f5 100644 --- a/test/Amazon.Lambda.Tools.Integ.Tests/DeployServerlessTests.cs +++ b/test/Amazon.Lambda.Tools.Integ.Tests/DeployServerlessTests.cs @@ -281,6 +281,69 @@ public async Task TestDeployServerlessECRImageUriNoMetadataYamlTemplate() } } + [Fact] + public async Task TestDeployServerlessReferencingSingleFile() + { + var assembly = this.GetType().GetTypeInfo().Assembly; + var toolLogger = new TestToolLogger(_testOutputHelper); + var templatePath = Path.GetFullPath(Path.GetDirectoryName(assembly.Location) + "../../../../../../testapps/SingleFileLambdaFunctions/serverless.template"); + + var stackName = "TestDeployServerlessReferencingSingleFile-" + DateTime.Now.Ticks; + var deployServerlessCommand = new DeployServerlessCommand(toolLogger, Environment.CurrentDirectory, new string[] { "--template", templatePath }); + deployServerlessCommand.DisableInteractive = true; + deployServerlessCommand.Region = TEST_REGION; + deployServerlessCommand.Configuration = "Release"; + deployServerlessCommand.StackName = stackName; + deployServerlessCommand.S3Bucket = this._testFixture.Bucket; + deployServerlessCommand.WaitForStackToComplete = true; + + var created = false; + try + { + string functionName = null; + created = await deployServerlessCommand.ExecuteAsync(); + Assert.True(created); + using (var cfClient = new AmazonCloudFormationClient(RegionEndpoint.GetBySystemName(TEST_REGION))) + { + var describeResponse = await cfClient.DescribeStacksAsync(new DescribeStacksRequest + { + StackName = deployServerlessCommand.StackName + }); + + Assert.Equal(StackStatus.CREATE_COMPLETE, describeResponse.Stacks[0].StackStatus); + + var describeResourceResponse = await cfClient.DescribeStackResourceAsync(new DescribeStackResourceRequest { StackName = stackName, LogicalResourceId = "ToUpperFunctionNoAOT" }); + functionName = describeResourceResponse.StackResourceDetail.PhysicalResourceId; + } + + toolLogger.ClearBuffer(); + var invokeCommand = new InvokeFunctionCommand(toolLogger, Environment.CurrentDirectory, new string[0]); + invokeCommand.FunctionName = functionName; + invokeCommand.Payload = "hello world"; + invokeCommand.Region = TEST_REGION; + + await invokeCommand.ExecuteAsync(); + Assert.Contains("HELLO WORLD", toolLogger.Buffer); + } + finally + { + if (created) + { + try + { + var deleteCommand = new DeleteServerlessCommand(toolLogger, Environment.CurrentDirectory, new string[0]); + deleteCommand.StackName = deployServerlessCommand.StackName; + deleteCommand.Region = TEST_REGION; + await deleteCommand.ExecuteAsync(); + } + catch + { + // Bury exception because we don't want to lose any exceptions during the deploy stage. + } + } + } + } + [Fact] public async Task TestDeployServerlessECRImageUriNoMetadataJsonTemplate() { diff --git a/test/Amazon.Lambda.Tools.Test/LambdaSingleFilePackageTests.cs b/test/Amazon.Lambda.Tools.Test/LambdaSingleFilePackageTests.cs new file mode 100644 index 00000000..50e5ddfe --- /dev/null +++ b/test/Amazon.Lambda.Tools.Test/LambdaSingleFilePackageTests.cs @@ -0,0 +1,350 @@ +using Amazon.Common.DotNetCli.Tools; +using Amazon.Lambda.Tools.Commands; +using System; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Amazon.Lambda.Tools.Test +{ + [Collection("SingleFileTests")] + public class LambdaSingleFilePackageTests + { + private readonly ITestOutputHelper _testOutputHelper; + + public LambdaSingleFilePackageTests(ITestOutputHelper testOutputHelper) + { + this._testOutputHelper = testOutputHelper; + } + + [Fact] + public async Task PackageToUpperNoAOTSettingWithArgument() + { + var tempFile = Path.GetTempFileName() + ".zip"; + try + { + var assembly = this.GetType().GetTypeInfo().Assembly; + var fullPath = Path.GetFullPath(Path.GetDirectoryName(assembly.Location) + "../../../../../../testapps/SingleFileLambdaFunctions/ToUpperFunctionNoAOT.cs"); + var command = new PackageCommand(new TestToolLogger(_testOutputHelper), Environment.CurrentDirectory, new string[] {fullPath, tempFile }); + + var created = await command.ExecuteAsync(); + Assert.True(created); + + Assert.True(File.Exists(tempFile)); + Assert.True(new FileInfo(tempFile).Length > 0); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public async Task PackageToUpperNoAOTSettingWithProjectLocation() + { + var tempFile = Path.GetTempFileName() + ".zip"; + try + { + var assembly = this.GetType().GetTypeInfo().Assembly; + var fullPath = Path.GetFullPath(Path.GetDirectoryName(assembly.Location) + "../../../../../../testapps/SingleFileLambdaFunctions/ToUpperFunctionNoAOT.cs"); + var command = new PackageCommand(new TestToolLogger(_testOutputHelper), Environment.CurrentDirectory, new string[] { tempFile, "--project-location", fullPath }); + + var created = await command.ExecuteAsync(); + Assert.True(created); + + Assert.True(File.Exists(tempFile)); + Assert.True(new FileInfo(tempFile).Length > 0); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Theory] + [InlineData("ToUpperFunctionNoAOT.cs", false, "")] + [InlineData("ToUpperFunctionImplicitAOT.cs", true, "")] + [InlineData("ToUpperFunctionNoAOT.cs", true, "/p:publishaot=true")] + [InlineData("ToUpperFunctionImplicitAOT.cs", false, "/p:publishaot=false")] + public void ConfirmUsingNativeAOT(string filename, bool isAot, string msBuildParameters) + { + var assembly = this.GetType().GetTypeInfo().Assembly; + var fullPath = Path.GetFullPath(Path.GetDirectoryName(assembly.Location) + $"../../../../../../testapps/SingleFileLambdaFunctions/{filename}"); + var actualAot = Utilities.LookPublishAotFlag(fullPath, msBuildParameters); + + Assert.Equal(isAot, actualAot); + } + + [Fact] + public void DeterminePublishLocationForSingleFile() + { + string fullPath; + string expectedPublishLocation; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + fullPath = @"C:\functions\helloworld.cs"; + expectedPublishLocation = @"C:/functions/artifacts/helloworld"; + } + else + { + fullPath = @"/functions/helloworld.cs"; + expectedPublishLocation = @"/functions/artifacts/helloworld"; + } + + var publishLocation = Utilities.DeterminePublishLocation(Environment.CurrentDirectory, fullPath, "Release", "net10.0"); + Assert.Equal(expectedPublishLocation, publishLocation.Replace("\\", "/")); + } + + [Fact] + public void DeterminePublishLocationForSingleFileWithMixedSlashes() + { + string fullPath; + string expectedPublishLocation; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + fullPath = @"C:\functions/subfolder\myfunction.cs"; + expectedPublishLocation = @"C:/functions/subfolder/artifacts/myfunction"; + } + else + { + fullPath = @"/functions/subfolder/myfunction.cs"; + expectedPublishLocation = @"/functions/subfolder/artifacts/myfunction"; + } + + var publishLocation = Utilities.DeterminePublishLocation(Environment.CurrentDirectory, fullPath, "Release", "net10.0"); + Assert.Equal(expectedPublishLocation, publishLocation.Replace("\\", "/")); + } + + [Fact] + public void DeterminePublishLocationForSingleFileWithDifferentConfigurations() + { + string fullPath; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + fullPath = @"C:\functions\myfunction.cs"; + } + else + { + fullPath = @"/functions/myfunction.cs"; + } + + // Configuration parameter doesn't affect single file publish location (unlike regular projects) + var releaseLocation = Utilities.DeterminePublishLocation(Environment.CurrentDirectory, fullPath, "Release", "net10.0"); + var debugLocation = Utilities.DeterminePublishLocation(Environment.CurrentDirectory, fullPath, "Debug", "net10.0"); + + // Both should point to the same artifacts folder since configuration is not part of the path for single files + Assert.Equal(releaseLocation, debugLocation); + } + + [Fact] + public void DeterminePublishLocationForSingleFileWithDifferentTargetFrameworks() + { + string fullPath; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + fullPath = @"C:\functions\myfunction.cs"; + } + else + { + fullPath = @"/functions/myfunction.cs"; + } + + // Target framework parameter doesn't affect single file publish location (unlike regular projects) + var net10Location = Utilities.DeterminePublishLocation(Environment.CurrentDirectory, fullPath, "Release", "net10.0"); + var net9Location = Utilities.DeterminePublishLocation(Environment.CurrentDirectory, fullPath, "Release", "net9.0"); + + // Both should point to the same artifacts folder since target framework is not part of the path for single files + Assert.Equal(net10Location, net9Location); + } + + [Fact] + public void DeterminePublishLocationForSingleFileVsRegularProject() + { + var assembly = this.GetType().GetTypeInfo().Assembly; + + // Test with single file - should use artifacts folder + var singleFilePath = Path.GetFullPath(Path.GetDirectoryName(assembly.Location) + "../../../../../../testapps/SingleFileLambdaFunctions/ToUpperFunctionNoAOT.cs"); + var singleFilePublishLocation = Utilities.DeterminePublishLocation(Environment.CurrentDirectory, singleFilePath, "Release", "net10.0"); + + // Single file should have "artifacts" in the path + Assert.Contains("artifacts", singleFilePublishLocation); + Assert.Contains("ToUpperFunctionNoAOT", singleFilePublishLocation); + + // Test with regular project - should use bin/Release/targetFramework/publish + var regularProjectPath = Path.GetFullPath(Path.GetDirectoryName(assembly.Location) + "../../../../../../testapps/TestFunction"); + var regularProjectPublishLocation = Utilities.DeterminePublishLocation(Environment.CurrentDirectory, regularProjectPath, "Release", "net6.0"); + + // Regular project should have "bin" and "publish" in the path + Assert.Contains("bin", regularProjectPublishLocation); + Assert.Contains("publish", regularProjectPublishLocation); + Assert.Contains("Release", regularProjectPublishLocation); + Assert.Contains("net6.0", regularProjectPublishLocation); + } + + [Fact] + public void DeterminePublishLocationForSingleFileWithSpecialCharacters() + { + string fullPath; + string expectedPublishLocation; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + fullPath = @"C:\functions\my-function_v2.cs"; + expectedPublishLocation = @"C:/functions/artifacts/my-function_v2"; + } + else + { + fullPath = @"/functions/my-function_v2.cs"; + expectedPublishLocation = @"/functions/artifacts/my-function_v2"; + } + + var publishLocation = Utilities.DeterminePublishLocation(Environment.CurrentDirectory, fullPath, "Release", "net10.0"); + Assert.Equal(expectedPublishLocation, publishLocation.Replace("\\", "/")); + } + + [Theory] + [InlineData("ToUpperFunctionNoAOT.cs", true)] + [InlineData("ToUpperFunctionNoAOT.vb", false)] + [InlineData("ToUpperFunctionNoAOT.csproj", false)] + [InlineData("ToUpperFunctionNoAOT", false)] + public void ConfirmSingleFileFlagSet(string filename, bool isSingleFile) + { + Assert.Equal(isSingleFile, Utilities.IsSingleFileCSharpFile(filename)); + } + + [Fact] + public void GetSolutionDirectoryForSingleFile() + { + string projectLocation; + string expectedSolutionDirectory; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + projectLocation = @"C:\functions\myfunction.cs"; + expectedSolutionDirectory = @"C:\functions"; + } + else + { + projectLocation = @"/functions/myfunction.cs"; + expectedSolutionDirectory = @"/functions"; + } + + var solutionDirectory = Utilities.GetSolutionDirectoryFullPath(Environment.CurrentDirectory, projectLocation, null); + Assert.Equal(expectedSolutionDirectory, solutionDirectory); + } + + [Fact] + public void GetSolutionDirectoryForSingleFileWithMixedSlashes() + { + string projectLocation; + string expectedSolutionDirectory; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + projectLocation = @"C:\functions/subfolder\myfunction.cs"; + expectedSolutionDirectory = @"C:\functions\subfolder"; + } + else + { + projectLocation = @"/functions/subfolder/myfunction.cs"; + expectedSolutionDirectory = @"/functions/subfolder"; + } + + var solutionDirectory = Utilities.GetSolutionDirectoryFullPath(Environment.CurrentDirectory, projectLocation, null); + Assert.Equal(expectedSolutionDirectory, solutionDirectory); + } + + [Fact] + public void GetSolutionDirectoryForSingleFileVsRegularProject() + { + var assembly = this.GetType().GetTypeInfo().Assembly; + + // Test with single file - should return parent directory + var singleFilePath = Path.GetFullPath(Path.GetDirectoryName(assembly.Location) + "../../../../../../testapps/SingleFileLambdaFunctions/ToUpperFunctionNoAOT.cs"); + var singleFileSolutionDir = Utilities.GetSolutionDirectoryFullPath(Environment.CurrentDirectory, singleFilePath, null); + var expectedSingleFileDir = Path.GetDirectoryName(singleFilePath); + Assert.Equal(expectedSingleFileDir, singleFileSolutionDir); + + // Test with regular project - should search up for solution file + var regularProjectPath = Path.GetFullPath(Path.GetDirectoryName(assembly.Location) + "../../../../../../testapps/TestFunction"); + var regularProjectSolutionDir = Utilities.GetSolutionDirectoryFullPath(Environment.CurrentDirectory, regularProjectPath, null); + // For regular projects, it searches up the directory tree, so it should be different behavior + Assert.NotNull(regularProjectSolutionDir); + } + + [Fact] + public void GetSolutionDirectoryWithExplicitSolutionDirectory() + { + string projectLocation; + string givenSolutionDirectory; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + projectLocation = @"C:\functions\myfunction.cs"; + givenSolutionDirectory = @"C:\mysolution"; + } + else + { + projectLocation = @"/functions/myfunction.cs"; + givenSolutionDirectory = @"/mysolution"; + } + + // When an explicit solution directory is provided, it should use that regardless of single file + var solutionDirectory = Utilities.GetSolutionDirectoryFullPath(Environment.CurrentDirectory, projectLocation, givenSolutionDirectory); + Assert.Equal(givenSolutionDirectory, solutionDirectory); + } + + [Fact] + public void DetermineTargetFrameworkForSingleFile_FindsExactDirective() + { + var lines = new[] { "#:property TargetFramework=net10.0" }; + var result = LambdaUtilities.DetermineTargetFrameworkForSingleFile(lines, null); + Assert.Equal("net10.0", result); + } + + [Fact] + public void DetermineTargetFrameworkForSingleFile_FindsDirectiveWithExtraSpaces() + { + var lines = new[] { "#:property TargetFramework=net11.0" }; + var result = LambdaUtilities.DetermineTargetFrameworkForSingleFile(lines, null); + Assert.Equal("net11.0", result); + } + + [Fact] + public void DetermineTargetFrameworkForSingleFile_FindsDirectiveWithSpacesAroundEquals() + { + var lines = new[] { "#:property TargetFramework = net12.0" }; + var result = LambdaUtilities.DetermineTargetFrameworkForSingleFile(lines, null); + Assert.Equal("net12.0", result); + } + + [Fact] + public void DetermineTargetFrameworkForSingleFile_CommentedOut() + { + var lines = new[] { "//#:property TargetFramework=NET13.0" }; + var result = LambdaUtilities.DetermineTargetFrameworkForSingleFile(lines, null); + Assert.Null(result); + } + + [Fact] + public void DetermineTargetFrameworkForSingleFile_WithLambdaRuntime() + { + var lines = new[] { "// Nothing to see here" }; + var result = LambdaUtilities.DetermineTargetFrameworkForSingleFile(lines, "dotnet10"); + Assert.Equal("net10.0", result); + } + + [Fact] + public void DetermineTargetFrameworkForSingleFile_NoDirectiveReturnsNull() + { + var lines = new[] { "// just a comment", "using System;" }; + var result = LambdaUtilities.DetermineTargetFrameworkForSingleFile(lines, null); + Assert.Null(result); + } + } +} diff --git a/test/Amazon.Lambda.Tools.Test/UtilitiesTests.cs b/test/Amazon.Lambda.Tools.Test/UtilitiesTests.cs index 9f0c7c90..4f62f312 100644 --- a/test/Amazon.Lambda.Tools.Test/UtilitiesTests.cs +++ b/test/Amazon.Lambda.Tools.Test/UtilitiesTests.cs @@ -179,6 +179,8 @@ public void TestSavingDockerfileInDefaults(string dockerfilePath, string project [InlineData("net8.0", LambdaConstants.ARCHITECTURE_X86_64, "public.ecr.aws/sam/build-dotnet8:latest-x86_64")] [InlineData("net9.0", LambdaConstants.ARCHITECTURE_ARM64, "public.ecr.aws/sam/build-dotnet9:latest-arm64")] [InlineData("net9.0", LambdaConstants.ARCHITECTURE_X86_64, "public.ecr.aws/sam/build-dotnet9:latest-x86_64")] + [InlineData("net10.0", LambdaConstants.ARCHITECTURE_ARM64, "mcr.microsoft.com/dotnet/sdk:10.0-aot")] + [InlineData("net10.0", LambdaConstants.ARCHITECTURE_X86_64, "mcr.microsoft.com/dotnet/sdk:10.0-aot")] [InlineData(null, LambdaConstants.ARCHITECTURE_X86_64, "throws")] [InlineData(null, LambdaConstants.ARCHITECTURE_ARM64, "throws")] public void GetDefaultBuildImage(string targetFramework, string architecture, string expectedValue) diff --git a/testapps/SingleFileLambdaFunctions/ToUpperFunctionImplicitAOT.cs b/testapps/SingleFileLambdaFunctions/ToUpperFunctionImplicitAOT.cs new file mode 100644 index 00000000..a5d1f883 --- /dev/null +++ b/testapps/SingleFileLambdaFunctions/ToUpperFunctionImplicitAOT.cs @@ -0,0 +1,28 @@ +#:package Amazon.Lambda.Core@2.8.0 +#:package Amazon.Lambda.RuntimeSupport@1.14.1 +#:package Amazon.Lambda.Serialization.SystemTextJson@2.4.4 +#:property TargetFramework=net10.0 + +using Amazon.Lambda.Core; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; +using System.Text.Json.Serialization; + +// The function handler that will be called for each Lambda event +var handler = (string input, ILambdaContext context) => +{ + return input.ToUpper(); +}; + +// Build the Lambda runtime client passing in the handler to call for each +// event and the JSON serializer to use for translating Lambda JSON documents +// to .NET types. +await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer()) + .Build() + .RunAsync(); + + +[JsonSerializable(typeof(string))] +public partial class LambdaSerializerContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/testapps/SingleFileLambdaFunctions/ToUpperFunctionNoAOT.cs b/testapps/SingleFileLambdaFunctions/ToUpperFunctionNoAOT.cs new file mode 100644 index 00000000..6f8bc892 --- /dev/null +++ b/testapps/SingleFileLambdaFunctions/ToUpperFunctionNoAOT.cs @@ -0,0 +1,29 @@ +#:package Amazon.Lambda.Core@2.8.0 +#:package Amazon.Lambda.RuntimeSupport@1.14.1 +#:package Amazon.Lambda.Serialization.SystemTextJson@2.4.4 +#:property TargetFramework=net10.0 +#:property PublishAot=false + +using Amazon.Lambda.Core; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; +using System.Text.Json.Serialization; + +// The function handler that will be called for each Lambda event +var handler = (string input, ILambdaContext context) => +{ + return input.ToUpper(); +}; + +// Build the Lambda runtime client passing in the handler to call for each +// event and the JSON serializer to use for translating Lambda JSON documents +// to .NET types. +await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer()) + .Build() + .RunAsync(); + + +[JsonSerializable(typeof(string))] +public partial class LambdaSerializerContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/testapps/SingleFileLambdaFunctions/serverless.template b/testapps/SingleFileLambdaFunctions/serverless.template new file mode 100644 index 00000000..a223f6bf --- /dev/null +++ b/testapps/SingleFileLambdaFunctions/serverless.template @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform" : "AWS::Serverless-2016-10-31", + "Description" : "Starting template for an AWS Serverless Application.", + "Parameters" : { + }, + "Resources" : { + "ToUpperFunctionNoAOT" : { + "Type" : "AWS::Serverless::Function", + "Properties": { + "Handler": "ToUpperFunctionNoAOT", + "Runtime": "dotnet10", + "CodeUri": "./ToUpperFunctionNoAOT.cs", + "Description": "Default function", + "MemorySize": 256, + "Timeout": 30, + "Policies": [ "AWSLambdaBasicExecutionRole" ] + } + } + }, + "Outputs" : { + } +} \ No newline at end of file