diff --git a/eng/ci/templates/jobs/test-e2e-linux.yml b/eng/ci/templates/jobs/test-e2e-linux.yml index cdbbf87ff..b20bc0de5 100644 --- a/eng/ci/templates/jobs/test-e2e-linux.yml +++ b/eng/ci/templates/jobs/test-e2e-linux.yml @@ -22,6 +22,9 @@ jobs: python_linux_x64: languageWorker: 'Python' runtime: 'linux-x64' + custom_linux_x64: + languageWorker: 'Custom' + runtime: 'linux-x64' steps: - pwsh: ./eng/scripts/start-emulators.ps1 diff --git a/eng/ci/templates/jobs/test-e2e-osx.yml b/eng/ci/templates/jobs/test-e2e-osx.yml index d633b97a5..f9f3df493 100644 --- a/eng/ci/templates/jobs/test-e2e-osx.yml +++ b/eng/ci/templates/jobs/test-e2e-osx.yml @@ -22,6 +22,9 @@ jobs: python_osx_x64: languageWorker: 'Python' runtime: 'osx-x64' + custom_osx_x64: + languageWorker: 'Custom' + runtime: 'osx-x64' steps: - pwsh: ./eng/scripts/start-emulators.ps1 diff --git a/eng/ci/templates/jobs/test-e2e-windows.yml b/eng/ci/templates/jobs/test-e2e-windows.yml index 2f6a8a5d3..badff71bc 100644 --- a/eng/ci/templates/jobs/test-e2e-windows.yml +++ b/eng/ci/templates/jobs/test-e2e-windows.yml @@ -22,6 +22,9 @@ jobs: python_win_x64: languageWorker: 'Python' runtime: 'win-x64' + custom_win_x64: + languageWorker: 'Custom' + runtime: 'win-x64' steps: - pwsh: ./eng/scripts/start-emulators.ps1 -NoWait diff --git a/eng/ci/templates/steps/run-e2e-tests.yml b/eng/ci/templates/steps/run-e2e-tests.yml index 58b6f0a99..98a12965a 100644 --- a/eng/ci/templates/steps/run-e2e-tests.yml +++ b/eng/ci/templates/steps/run-e2e-tests.yml @@ -30,6 +30,7 @@ steps: Write-Host "##vso[task.setvariable variable=DURABLE_FUNCTION_PATH]$(Build.SourcesDirectory)/test/Azure.Functions.Cli.Tests/Resources/DurableTestFolder" Write-Host "##vso[task.setvariable variable=INPROC_RUN_SETTINGS]$(Build.SourcesDirectory)/test/Cli/Func.E2ETests/.runsettings/start_tests/ci_pipeline/dotnet_inproc.runsettings" Write-Host "##vso[task.setvariable variable=TEST_PROJECT_PATH]$(Build.SourcesDirectory)/test/TestFunctionApps" + Write-Host "##vso[task.setvariable variable=FUNCTIONS_PYTHON_DOCKER_IMAGE]mcr.microsoft.com/azure-functions/python:4-python3.11-buildenv" displayName: 'Set environment variables for E2E tests' diff --git a/release_notes.md b/release_notes.md index 20324c995..88fda62fe 100644 --- a/release_notes.md +++ b/release_notes.md @@ -1,4 +1,4 @@ -# Azure Functions CLI 4.2.2 +# Azure Functions CLI 4.2.3 #### Host Version @@ -6,5 +6,4 @@ - In-Proc Host Version: 4.41.100 (4.841.100, 4.641.100) #### Changes - -- Fix .NET template install bug (#4612) +- Add `func pack` functionality to other languages (#4600) diff --git a/src/Cli/func/ActionAttribute.cs b/src/Cli/func/ActionAttribute.cs index e85ea88ac..357bf5af2 100644 --- a/src/Cli/func/ActionAttribute.cs +++ b/src/Cli/func/ActionAttribute.cs @@ -15,5 +15,7 @@ internal sealed class ActionAttribute : Attribute public string HelpText { get; set; } = "placeholder"; public bool ShowInHelp { get; set; } = true; + + public string ParentCommandName { get; set; } = string.Empty; } } diff --git a/src/Cli/func/ActionType.cs b/src/Cli/func/ActionType.cs index 249c2b2a9..476f0e197 100644 --- a/src/Cli/func/ActionType.cs +++ b/src/Cli/func/ActionType.cs @@ -12,5 +12,7 @@ internal class ActionType public IEnumerable SubContexts { get; set; } public IEnumerable Names { get; set; } + + public IEnumerable ParentCommandName { get; set; } } } diff --git a/src/Cli/func/Actions/HelpAction.cs b/src/Cli/func/Actions/HelpAction.cs index 0c9a8d570..249281181 100644 --- a/src/Cli/func/Actions/HelpAction.cs +++ b/src/Cli/func/Actions/HelpAction.cs @@ -42,7 +42,8 @@ public HelpAction(IEnumerable actions, Func cr Type = type, Contexts = attributes.Select(a => a.Context), SubContexts = attributes.Select(a => a.SubContext), - Names = attributes.Select(a => a.Name) + Names = attributes.Select(a => a.Name), + ParentCommandName = attributes.Select(a => a.ParentCommandName) }; }); } @@ -187,7 +188,8 @@ private void DisplayGeneralHelp() .WriteLine("Usage: func [context] [-/--options]") .WriteLine(); DisplayContextsHelp(contexts); - var actions = _actionTypes.Where(a => a.Contexts.Contains(Context.None)); + var actions = _actionTypes + .Where(a => a.Contexts.Contains(Context.None)); DisplayActionsHelp(actions); } @@ -211,15 +213,80 @@ private void DisplayActionsHelp(IEnumerable actions) if (actions.Any()) { ColoredConsole.WriteLine(TitleColor("Actions: ")); + + // Group actions by parent command + var parentCommands = actions + .Where(a => a.ParentCommandName.All(p => string.IsNullOrEmpty(p))) // Actions with no parent + .ToList(); + + var subCommands = actions + .Where(a => a.ParentCommandName.Any(p => !string.IsNullOrEmpty(p))) // Actions with a parent + .ToList(); + var longestName = actions.Select(a => a.Names).SelectMany(n => n).Max(n => n.Length); longestName += 2; // for coloring chars - foreach (var action in actions) + + // Display parent commands first + foreach (var parentAction in parentCommands) { - ColoredConsole.WriteLine(GetActionHelp(action, longestName)); - DisplaySwitches(action); + // Display parent command + ColoredConsole.WriteLine(GetActionHelp(parentAction, longestName)); + DisplaySwitches(parentAction); + + // Find and display child commands for this parent + var parentName = parentAction.Names.First(); + var childCommands = subCommands + .Where(s => s.ParentCommandName.Any(p => p.Equals(parentName, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + if (childCommands.Any()) + { + ColoredConsole.WriteLine(); // Add spacing before subcommands + + foreach (var childCommand in childCommands) + { + DisplaySubCommandHelp(childCommand); + } + } + + ColoredConsole.WriteLine(); } + } + } - ColoredConsole.WriteLine(); + private void DisplaySubCommandHelp(ActionType subCommand) + { + // Ensure subCommand is valid + if (subCommand == null) + { + return; + } + + // Extract the runtime name from the full command name + // E.g., "pack dotnet" -> "Dotnet" + var fullCommandName = subCommand.Names?.FirstOrDefault(); + + string runtimeName = null; + if (!string.IsNullOrWhiteSpace(fullCommandName)) + { + var parts = fullCommandName.Split(' ', StringSplitOptions.RemoveEmptyEntries); + runtimeName = parts.Length > 1 && !string.IsNullOrEmpty(parts[1]) + ? char.ToUpper(parts[1][0]) + parts[1].Substring(1).ToLower() + : fullCommandName; + } + + // Fall back to a safe default if we couldn't determine a runtime name + runtimeName ??= subCommand.Type?.Name ?? "subcommand"; + + var description = subCommand.Type?.GetCustomAttributes()?.FirstOrDefault()?.HelpText; + + // Display indented subcommand header + ColoredConsole.WriteLine($" {runtimeName.DarkCyan()} {description}"); + + // Display subcommand switches with extra indentation + if (subCommand.Type != null) + { + DisplaySwitches(subCommand); } } @@ -261,7 +328,7 @@ private void DisplayPositionalArguments(IEnumerable arguments) longestName += 4; // 4 for coloring and <> characters foreach (var argument in arguments) { - var helpLine = string.Format($" {{0, {-longestName}}} {{1}}", $"<{argument.Name}>".DarkGray(), argument.Description); + var helpLine = string.Format($"{" "}{{0, {-longestName}}} {{1}}", $"<{argument.Name}>".DarkGray(), argument.Description); if (helpLine.Length < SafeConsole.BufferWidth) { ColoredConsole.WriteLine(helpLine); @@ -277,7 +344,7 @@ private void DisplayPositionalArguments(IEnumerable arguments) } } - private static void DisplayOptions(IEnumerable options) + private static void DisplayOptions(IEnumerable options, bool addExtraIndent = false) { var longestName = options.Max(o => { @@ -311,7 +378,7 @@ private static void DisplayOptions(IEnumerable options) stringBuilder.Append($" [-{option.ShortName}]"); } - var helpSwitch = string.Format($" {{0, {-longestName}}} ", stringBuilder.ToString().DarkGray()); + var helpSwitch = string.Format($"{(addExtraIndent ? " " : " ")}{{0, {-longestName}}} ", stringBuilder.ToString().DarkGray()); var helpSwitchLength = helpSwitch.Length - 2; // helpSwitch contains 2 formatting characters. var helpText = option.Description; if (string.IsNullOrWhiteSpace(helpText)) diff --git a/src/Cli/func/Actions/LocalActions/PackAction.cs b/src/Cli/func/Actions/LocalActions/PackAction.cs deleted file mode 100644 index b8f7dad74..000000000 --- a/src/Cli/func/Actions/LocalActions/PackAction.cs +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using Azure.Functions.Cli.Common; -using Azure.Functions.Cli.Helpers; -using Azure.Functions.Cli.Interfaces; -using Colors.Net; -using Fclp; -using Microsoft.Azure.WebJobs.Script; -using static Azure.Functions.Cli.Common.OutputTheme; - -namespace Azure.Functions.Cli.Actions.LocalActions -{ - [Action(Name = "pack", HelpText = "Pack function app into a zip that's ready to run.", ShowInHelp = false)] - internal class PackAction : BaseAction - { - private readonly ISecretsManager _secretsManager; - - public PackAction(ISecretsManager secretsManager) - { - _secretsManager = secretsManager; - } - - public string FolderName { get; set; } = string.Empty; - - public string OutputPath { get; set; } - - public bool BuildNativeDeps { get; set; } - - public string AdditionalPackages { get; set; } = string.Empty; - - public bool Squashfs { get; private set; } - - public override ICommandLineParserResult ParseArgs(string[] args) - { - Parser - .Setup('o', "output") - .WithDescription("output path for the packed archive") - .Callback(o => OutputPath = o); - Parser - .Setup("build-native-deps") - .SetDefault(false) - .WithDescription("Skips generating .wheels folder when publishing python function apps.") - .Callback(f => BuildNativeDeps = f); - Parser - .Setup("no-bundler") - .WithDescription("Skips generating a bundle when publishing python function apps with build-native-deps.") - .Callback(nb => ColoredConsole.WriteLine(WarningColor($"Warning: Argument {AdditionalInfoColor("--no-bundler")} is deprecated and a no-op. Python function apps are not bundled anymore."))); - Parser - .Setup("additional-packages") - .WithDescription("List of packages to install when building native dependencies. For example: \"python3-dev libevent-dev\"") - .Callback(p => AdditionalPackages = p); - Parser - .Setup("squashfs") - .Callback(f => Squashfs = f); - - if (args.Any() && !args.First().StartsWith("-")) - { - FolderName = args.First(); - } - - return base.ParseArgs(args); - } - - public override async Task RunAsync() - { - var functionAppRoot = string.IsNullOrEmpty(FolderName) - ? Path.Combine(Environment.CurrentDirectory, FolderName) - : ScriptHostHelpers.GetFunctionAppRootDirectory(Environment.CurrentDirectory); - - string outputPath; - if (string.IsNullOrEmpty(OutputPath)) - { - outputPath = Path.Combine(Environment.CurrentDirectory, $"{Path.GetFileName(functionAppRoot)}"); - } - else - { - outputPath = Path.Combine(Environment.CurrentDirectory, OutputPath); - if (FileSystemHelpers.DirectoryExists(outputPath)) - { - outputPath = Path.Combine(outputPath, $"{Path.GetFileName(functionAppRoot)}"); - } - } - - if (!FileSystemHelpers.FileExists(Path.Combine(functionAppRoot, ScriptConstants.HostMetadataFileName))) - { - throw new CliException($"Can't find {Path.Combine(functionAppRoot, ScriptConstants.HostMetadataFileName)}"); - } - - var workerRuntime = WorkerRuntimeLanguageHelper.GetCurrentWorkerRuntimeLanguage(_secretsManager); - outputPath += Squashfs ? ".squashfs" : ".zip"; - if (FileSystemHelpers.FileExists(outputPath)) - { - ColoredConsole.WriteLine($"Deleting the old package {outputPath}"); - try - { - FileSystemHelpers.FileDelete(outputPath); - } - catch (Exception) - { - throw new CliException($"Could not delete {outputPath}"); - } - } - - // Restore all valid extensions - var installExtensionAction = new InstallExtensionAction(_secretsManager, false); - await installExtensionAction.RunAsync(); - - bool useGoZip = EnvironmentHelper.GetEnvironmentVariableAsBool(Constants.UseGoZip); - TelemetryHelpers.AddCommandEventToDictionary(TelemetryCommandEvents, "UseGoZip", useGoZip.ToString()); - - var stream = await ZipHelper.GetAppZipFile(functionAppRoot, BuildNativeDeps, BuildOption.Default, noBuild: false, additionalPackages: AdditionalPackages); - - if (Squashfs) - { - stream = await PythonHelpers.ZipToSquashfsStream(stream); - } - - ColoredConsole.WriteLine($"Creating a new package {outputPath}"); - await FileSystemHelpers.WriteToFile(outputPath, stream); - } - } -} diff --git a/src/Cli/func/Actions/LocalActions/PackAction/CustomPackSubcommandAction.cs b/src/Cli/func/Actions/LocalActions/PackAction/CustomPackSubcommandAction.cs new file mode 100644 index 000000000..e3fc65294 --- /dev/null +++ b/src/Cli/func/Actions/LocalActions/PackAction/CustomPackSubcommandAction.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Azure.Functions.Cli.Interfaces; +using Fclp; + +namespace Azure.Functions.Cli.Actions.LocalActions.PackAction +{ + [Action(Name = "pack custom", ParentCommandName = "pack", ShowInHelp = false, HelpText = "Arguments specific to custom worker runtime apps when running func pack")] + internal class CustomPackSubcommandAction : PackSubcommandAction + { + public override ICommandLineParserResult ParseArgs(string[] args) + { + return base.ParseArgs(args); + } + + public async Task RunAsync(PackOptions packOptions) + { + await ExecuteAsync(packOptions); + } + + protected override Task GetPackingRootAsync(string functionAppRoot, PackOptions options) + { + // Custom worker packs from the function app root without extra steps + return Task.FromResult(functionAppRoot); + } + + public override Task RunAsync() + { + // Keep this since this subcommand is not meant to be run directly. + return Task.CompletedTask; + } + } +} diff --git a/src/Cli/func/Actions/LocalActions/PackAction/DotnetPackSubcommandAction.cs b/src/Cli/func/Actions/LocalActions/PackAction/DotnetPackSubcommandAction.cs new file mode 100644 index 000000000..9df1f10cc --- /dev/null +++ b/src/Cli/func/Actions/LocalActions/PackAction/DotnetPackSubcommandAction.cs @@ -0,0 +1,126 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.IO; +using Azure.Functions.Cli.Common; +using Azure.Functions.Cli.Helpers; +using Azure.Functions.Cli.Interfaces; +using Colors.Net; +using Fclp; +using static Azure.Functions.Cli.Common.OutputTheme; + +namespace Azure.Functions.Cli.Actions.LocalActions.PackAction +{ + [Action(Name = "pack dotnet", ParentCommandName = "pack", ShowInHelp = false, HelpText = "Arguments specific to .NET apps when running func pack")] + internal class DotnetPackSubcommandAction : PackSubcommandAction + { + private readonly ISecretsManager _secretsManager; + + public DotnetPackSubcommandAction(ISecretsManager secretsManager) + { + _secretsManager = secretsManager; + } + + public override ICommandLineParserResult ParseArgs(string[] args) + { + // .NET doesn't have any runtime-specific arguments beyond the common ones + return base.ParseArgs(args); + } + + public async Task RunAsync(PackOptions packOptions) + { + await ExecuteAsync(packOptions); + } + + public override Task RunAsync() + { + // Keep this in case the customer tries to run func pack dotnet, since this subcommand is not meant to be run directly. + return Task.CompletedTask; + } + + protected override void ValidateFunctionApp(string functionAppRoot, PackOptions options) + { + var requiredFiles = new[] { "host.json" }; + foreach (var file in requiredFiles) + { + if (!FileSystemHelpers.FileExists(Path.Combine(functionAppRoot, file))) + { + throw new CliException($"Required file '{file}' not found in build output directory: {functionAppRoot}"); + } + } + } + + protected override async Task GetPackingRootAsync(string functionAppRoot, PackOptions options) + { + // ValidateFunctionApp + PackHelpers.ValidateFunctionAppRoot(functionAppRoot); + + // For --no-build, treat FolderPath as the build output directory + if (options.NoBuild) + { + var packingRoot = functionAppRoot; + + if (string.IsNullOrEmpty(options.FolderPath)) + { + ColoredConsole.WriteLine(WarningColor("No folder path specified. Using current directory as build output directory.")); + packingRoot = Environment.CurrentDirectory; + } + else + { + packingRoot = Path.IsPathRooted(options.FolderPath) + ? options.FolderPath + : Path.Combine(Environment.CurrentDirectory, options.FolderPath); + } + + if (!Directory.Exists(packingRoot)) + { + throw new CliException($"Build output directory not found: {packingRoot}"); + } + + return packingRoot; + } + else + { + ColoredConsole.WriteLine("Building .NET project..."); + await RunDotNetPublish(functionAppRoot); + + return Path.Combine(functionAppRoot, "output"); + } + } + + protected override Task PerformCleanupAfterPackingAsync(string packingRoot, string functionAppRoot, PackOptions options) + { + if (!options.NoBuild) + { + // If not no-build, delete packing root after packing + FileSystemHelpers.DeleteDirectorySafe(packingRoot); + } + + return Task.CompletedTask; + } + + private async Task RunDotNetPublish(string functionAppRoot) + { + DotnetHelpers.EnsureDotnet(); + + var outputPath = Path.Combine(functionAppRoot, "output"); + + // Clean the output directory if it exists + if (FileSystemHelpers.DirectoryExists(outputPath)) + { + FileSystemHelpers.DeleteDirectorySafe(outputPath); + } + + // Run dotnet publish + var exe = new Executable("dotnet", $"publish --output \"{outputPath}\"", workingDirectory: functionAppRoot); + var exitCode = await exe.RunAsync( + o => ColoredConsole.WriteLine(o), + e => ColoredConsole.Error.WriteLine(ErrorColor(e))); + + if (exitCode != 0) + { + throw new CliException("Error publishing .NET project"); + } + } + } +} diff --git a/src/Cli/func/Actions/LocalActions/PackAction/NodePackSubcommandAction.cs b/src/Cli/func/Actions/LocalActions/PackAction/NodePackSubcommandAction.cs new file mode 100644 index 000000000..a8fa58db1 --- /dev/null +++ b/src/Cli/func/Actions/LocalActions/PackAction/NodePackSubcommandAction.cs @@ -0,0 +1,157 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.IO; +using System.Runtime.InteropServices; +using Azure.Functions.Cli.Common; +using Azure.Functions.Cli.Helpers; +using Azure.Functions.Cli.Interfaces; +using Colors.Net; +using Fclp; +using static Azure.Functions.Cli.Common.OutputTheme; + +namespace Azure.Functions.Cli.Actions.LocalActions.PackAction +{ + [Action(Name = "pack node", ParentCommandName = "pack", ShowInHelp = true, HelpText = "Arguments specific to Node.js apps when running func pack")] + internal class NodePackSubcommandAction : PackSubcommandAction + { + private readonly ISecretsManager _secretsManager; + + public NodePackSubcommandAction(ISecretsManager secretsManager) + { + _secretsManager = secretsManager; + } + + public bool SkipInstall { get; set; } + + public override ICommandLineParserResult ParseArgs(string[] args) + { + Parser + .Setup("skip-install") + .WithDescription("Skips running 'npm install' when packing the function app.") + .Callback(o => SkipInstall = o); + + return base.ParseArgs(args); + } + + public async Task RunAsync(PackOptions packOptions, string[] args) + { + await ExecuteAsync(packOptions, args); + } + + protected override void ParseSubcommandArgs(string[] args) + { + // Parse Node.js-specific arguments + ParseArgs(args); + } + + protected override void ValidateFunctionApp(string functionAppRoot, PackOptions options) + { + // ValidateFunctionApp package.json exists + var packageJsonPath = Path.Combine(functionAppRoot, "package.json"); + if (!FileSystemHelpers.FileExists(packageJsonPath)) + { + throw new CliException($"package.json not found in {functionAppRoot}. This is required for Node.js function apps."); + } + + if (StaticSettings.IsDebug) + { + ColoredConsole.WriteLine(VerboseColor($"Found package.json at {packageJsonPath}")); + } + } + + protected override async Task GetPackingRootAsync(string functionAppRoot, PackOptions options) + { + // Node packs from the function app root. If build is not skipped, run npm steps before packaging + if (!options.NoBuild) + { + await RunNodeJsBuildProcess(functionAppRoot); + } + + return functionAppRoot; + } + + protected override Task PackFunctionAsync(string packingRoot, string outputPath, PackOptions options) + { + return PackHelpers.CreatePackage(packingRoot, outputPath, options.NoBuild, TelemetryCommandEvents); + } + + private async Task RunNodeJsBuildProcess(string functionAppRoot) + { + // Ensure npm is available + EnsureNpmExists(); + + // Change to the function app directory for npm operations + var previousDirectory = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = functionAppRoot; + + // Run npm install if not skipped + if (!SkipInstall) + { + await NpmHelper.Install(); + Console.WriteLine(); + } + + // Check if build script exists and run it + await RunNpmBuildIfExists(); + } + finally + { + // Restore the previous directory + Environment.CurrentDirectory = previousDirectory; + } + } + + private async Task RunNpmBuildIfExists() + { + try + { + // Check if package.json has a build script + var packageJsonPath = Path.Combine(Environment.CurrentDirectory, "package.json"); + if (FileSystemHelpers.FileExists(packageJsonPath)) + { + var packageJsonContent = await FileSystemHelpers.ReadAllTextFromFileAsync(packageJsonPath); + + // Simple check if build script exists + if (packageJsonContent.Contains("\"build\"") && packageJsonContent.Contains("scripts")) + { + await NpmHelper.RunNpmCommand("run build", ignoreError: false); + Console.WriteLine(); + } + else + { + if (StaticSettings.IsDebug) + { + ColoredConsole.WriteLine(VerboseColor("No build script found in package.json, skipping npm run build.")); + } + } + } + } + catch (Exception ex) + { + throw new CliException($"npm run build failed: {ex.Message}"); + } + } + + private static void EnsureNpmExists() + { + if (!CommandChecker.CommandExists("npm")) + { + throw new CliException("npm is required for Node.js function apps. Please install Node.js and npm from https://nodejs.org/"); + } + + if (StaticSettings.IsDebug) + { + ColoredConsole.WriteLine(VerboseColor("npm command found and available.")); + } + } + + public override Task RunAsync() + { + // This method is called when someone tries to run "func pack node" directly + return Task.CompletedTask; + } + } +} diff --git a/src/Cli/func/Actions/LocalActions/PackAction/PackAction.cs b/src/Cli/func/Actions/LocalActions/PackAction/PackAction.cs new file mode 100644 index 000000000..e5e8744c7 --- /dev/null +++ b/src/Cli/func/Actions/LocalActions/PackAction/PackAction.cs @@ -0,0 +1,108 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Azure.Functions.Cli.Common; +using Azure.Functions.Cli.Helpers; +using Azure.Functions.Cli.Interfaces; +using Fclp; + +namespace Azure.Functions.Cli.Actions.LocalActions.PackAction +{ + [Action(Name = "pack", HelpText = "Pack function app into a zip that's ready to deploy.", ShowInHelp = false)] + internal class PackAction : BaseAction + { + private readonly ISecretsManager _secretsManager; + + public PackAction(ISecretsManager secretsManager) + { + _secretsManager = secretsManager; + } + + public string FolderPath { get; set; } = string.Empty; + + public string OutputPath { get; set; } + + public bool NoBuild { get; set; } + + private string[] Args { get; set; } + + public override ICommandLineParserResult ParseArgs(string[] args) + { + Parser + .Setup('o', "output") + .WithDescription("Specifies the file path where the packed ZIP archive will be created.") + .Callback(o => OutputPath = o); + + Parser + .Setup("no-build") + .WithDescription("Do not build the project before packaging. Optionally provide a directory when func pack as the first argument that has the build contents." + + "Otherwise, default is the current directory.") + .Callback(n => NoBuild = n); + + if (args.Any() && !args.First().StartsWith("-")) + { + FolderPath = args.First(); + } + + Args = args; + + return base.ParseArgs(args); + } + + public override async Task RunAsync() + { + // Get the original command line args to pass to subcommands + var packOptions = new PackOptions + { + FolderPath = FolderPath, + OutputPath = OutputPath, + NoBuild = NoBuild + }; + + var oldCurrentDirectory = Environment.CurrentDirectory; + if (!string.IsNullOrEmpty(FolderPath)) + { + if (!Directory.Exists(FolderPath)) + { + throw new CliException($"The specified folder path '{FolderPath}' does not exist."); + } + + // If a folder path is provided, change to that directory + Environment.CurrentDirectory = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, FolderPath)); + } + + // Detect the runtime and set the runtime + var workerRuntime = WorkerRuntimeLanguageHelper.GetCurrentWorkerRuntimeLanguage(_secretsManager, refreshSecrets: true); + + // If no runtime is detected and NoBuild is true, check for .dll files to infer .NET runtime + // This is because when we run dotnet publish, there is no local.settings.json anymore to determine runtime. + if (workerRuntime == WorkerRuntime.None && NoBuild) + { + var files = Directory.GetFiles(FolderPath, "*.dll", SearchOption.AllDirectories); + if (files.Length > 0) + { + workerRuntime = WorkerRuntime.Dotnet; + } + } + + GlobalCoreToolsSettings.CurrentWorkerRuntime = workerRuntime; + + // Switch back to original directory after detecting runtime to package app in the correct context + Environment.CurrentDirectory = oldCurrentDirectory; + + // Internally dispatch to runtime-specific subcommand + await RunRuntimeSpecificPackAsync(workerRuntime, packOptions); + } + + private async Task RunRuntimeSpecificPackAsync(WorkerRuntime runtime, PackOptions packOptions) => + await (runtime switch + { + WorkerRuntime.Dotnet or WorkerRuntime.DotnetIsolated => new DotnetPackSubcommandAction(_secretsManager).RunAsync(packOptions), + WorkerRuntime.Python => new PythonPackSubcommandAction(_secretsManager).RunAsync(packOptions, Args), + WorkerRuntime.Node => new NodePackSubcommandAction(_secretsManager).RunAsync(packOptions, Args), + WorkerRuntime.Powershell => new PowershellPackSubcommandAction().RunAsync(packOptions), + WorkerRuntime.Custom => new CustomPackSubcommandAction().RunAsync(packOptions), + _ => throw new CliException($"Unsupported runtime: {runtime}") + }); + } +} diff --git a/src/Cli/func/Actions/LocalActions/PackAction/PackHelpers.cs b/src/Cli/func/Actions/LocalActions/PackAction/PackHelpers.cs new file mode 100644 index 000000000..bd1f1e05c --- /dev/null +++ b/src/Cli/func/Actions/LocalActions/PackAction/PackHelpers.cs @@ -0,0 +1,76 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Azure.Functions.Cli.Common; +using Azure.Functions.Cli.Helpers; +using Colors.Net; +using Microsoft.Azure.WebJobs.Script; +using static Azure.Functions.Cli.Common.OutputTheme; + +namespace Azure.Functions.Cli.Actions.LocalActions.PackAction +{ + internal static class PackHelpers + { + // Common helper methods that all subcommands can use + public static string ResolveFunctionAppRoot(string folderPath) + { + return string.IsNullOrEmpty(folderPath) + ? ScriptHostHelpers.GetFunctionAppRootDirectory(Environment.CurrentDirectory) + : Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, folderPath)); + } + + public static string ResolveOutputPath(string functionAppRoot, string outputPath) + { + string resolvedPath; + if (string.IsNullOrEmpty(outputPath)) + { + resolvedPath = Path.Combine(Environment.CurrentDirectory, $"{Path.GetFileName(functionAppRoot)}"); + } + else + { + resolvedPath = Path.Combine(Environment.CurrentDirectory, outputPath); + + // Create directory if it doesn't exist + Directory.CreateDirectory(resolvedPath); + resolvedPath = Path.Combine(resolvedPath, $"{Path.GetFileName(functionAppRoot)}"); + } + + return resolvedPath + ".zip"; + } + + public static void ValidateFunctionAppRoot(string functionAppRoot) + { + if (!FileSystemHelpers.FileExists(Path.Combine(functionAppRoot, ScriptConstants.HostMetadataFileName))) + { + throw new CliException($"Can't find {Path.Combine(functionAppRoot, ScriptConstants.HostMetadataFileName)}"); + } + } + + public static void CleanupExistingPackage(string outputPath) + { + if (FileSystemHelpers.FileExists(outputPath)) + { + ColoredConsole.WriteLine($"Deleting the old package {outputPath}"); + try + { + FileSystemHelpers.FileDelete(outputPath); + } + catch (Exception) + { + ColoredConsole.WriteLine(WarningColor($"Could not delete {outputPath}")); + } + } + } + + public static async Task CreatePackage(string packingRoot, string outputPath, bool noBuild, IDictionary telemetryCommandEvents, bool buildNativeDeps = false) + { + bool useGoZip = EnvironmentHelper.GetEnvironmentVariableAsBool(Constants.UseGoZip); + TelemetryHelpers.AddCommandEventToDictionary(telemetryCommandEvents, "UseGoZip", useGoZip.ToString()); + + var stream = await ZipHelper.GetAppZipFile(packingRoot, buildNativeDeps, BuildOption.Default, noBuild: noBuild); + + ColoredConsole.WriteLine($"Creating a new package {outputPath}"); + await FileSystemHelpers.WriteToFile(outputPath, stream); + } + } +} diff --git a/src/Cli/func/Actions/LocalActions/PackAction/PackOptions.cs b/src/Cli/func/Actions/LocalActions/PackAction/PackOptions.cs new file mode 100644 index 000000000..ed88a31b8 --- /dev/null +++ b/src/Cli/func/Actions/LocalActions/PackAction/PackOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace Azure.Functions.Cli.Actions.LocalActions.PackAction +{ + public class PackOptions + { + public string FolderPath { get; set; } = string.Empty; + + public string OutputPath { get; set; } = string.Empty; + + public bool NoBuild { get; set; } + } +} diff --git a/src/Cli/func/Actions/LocalActions/PackAction/PackSubcommandAction.cs b/src/Cli/func/Actions/LocalActions/PackAction/PackSubcommandAction.cs new file mode 100644 index 000000000..adf35be42 --- /dev/null +++ b/src/Cli/func/Actions/LocalActions/PackAction/PackSubcommandAction.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Azure.Functions.Cli.Common; + +namespace Azure.Functions.Cli.Actions.LocalActions.PackAction +{ + // Base class for pack subcommands to reduce duplication using a template method. + internal abstract class PackSubcommandAction : BaseAction + { + // Orchestrates the pack flow for subcommands that don't need extra args + protected async Task ExecuteAsync(PackOptions options) + { + var functionAppRoot = PackHelpers.ResolveFunctionAppRoot(options.FolderPath); + if (!Directory.Exists(functionAppRoot)) + { + throw new CliException($"Directory not found to pack: {functionAppRoot}"); + } + + ValidateFunctionApp(functionAppRoot, options); + + var packingRoot = await GetPackingRootAsync(functionAppRoot, options); + + var outputPath = PackHelpers.ResolveOutputPath(functionAppRoot, options.OutputPath); + PackHelpers.CleanupExistingPackage(outputPath); + + await PerformBuildBeforePackingAsync(packingRoot, functionAppRoot, options); + + await PackFunctionAsync(packingRoot, outputPath, options); + + await PerformCleanupAfterPackingAsync(packingRoot, functionAppRoot, options); + } + + // Orchestrates the pack flow for subcommands that parse extra args + protected async Task ExecuteAsync(PackOptions options, string[] args) + { + ParseSubcommandArgs(args); + await ExecuteAsync(options); + } + + // Hook: allow subcommands to parse their specific args + protected virtual void ParseSubcommandArgs(string[] args) + { + } + + // Hook: optional validation prior to determining packing root + protected virtual void ValidateFunctionApp(string functionAppRoot, PackOptions options) + { + } + + // Hook: must return the root folder to package (can trigger build/publish as needed) + protected abstract Task GetPackingRootAsync(string functionAppRoot, PackOptions options); + + // Hook: optional step to run right before packaging (e.g., Node build) + protected virtual Task PerformBuildBeforePackingAsync(string packingRoot, string functionAppRoot, PackOptions options) => Task.CompletedTask; + + // Hook: actual packaging operation (default zip from packingRoot) + protected virtual Task PackFunctionAsync(string packingRoot, string outputPath, PackOptions options) + => PackHelpers.CreatePackage(packingRoot, outputPath, options.NoBuild, TelemetryCommandEvents); + + // Hook: optional cleanup after packaging + protected virtual Task PerformCleanupAfterPackingAsync(string packingRoot, string functionAppRoot, PackOptions options) => Task.CompletedTask; + } +} diff --git a/src/Cli/func/Actions/LocalActions/PackAction/PowershellPackSubcommandAction.cs b/src/Cli/func/Actions/LocalActions/PackAction/PowershellPackSubcommandAction.cs new file mode 100644 index 000000000..3d6f985a4 --- /dev/null +++ b/src/Cli/func/Actions/LocalActions/PackAction/PowershellPackSubcommandAction.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Azure.Functions.Cli.Interfaces; +using Fclp; + +namespace Azure.Functions.Cli.Actions.LocalActions.PackAction +{ + [Action(Name = "pack powershell", ParentCommandName = "pack", ShowInHelp = false, HelpText = "Arguments specific to PowerShell apps when running func pack")] + internal class PowershellPackSubcommandAction : PackSubcommandAction + { + public override ICommandLineParserResult ParseArgs(string[] args) + { + return base.ParseArgs(args); + } + + public async Task RunAsync(PackOptions packOptions) + { + await ExecuteAsync(packOptions); + } + + protected override Task GetPackingRootAsync(string functionAppRoot, PackOptions options) + { + // PowerShell packs from the function app root without extra steps + return Task.FromResult(functionAppRoot); + } + + public override Task RunAsync() + { + // Keep this since this subcommand is not meant to be run directly. + return Task.CompletedTask; + } + } +} diff --git a/src/Cli/func/Actions/LocalActions/PackAction/PythonPackSubcommandAction.cs b/src/Cli/func/Actions/LocalActions/PackAction/PythonPackSubcommandAction.cs new file mode 100644 index 000000000..ba21e5d70 --- /dev/null +++ b/src/Cli/func/Actions/LocalActions/PackAction/PythonPackSubcommandAction.cs @@ -0,0 +1,87 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.ComponentModel; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using Azure.Functions.Cli.Common; +using Azure.Functions.Cli.Interfaces; +using Colors.Net; +using Fclp; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Build.Evaluation; +using static Azure.Functions.Cli.Common.OutputTheme; + +namespace Azure.Functions.Cli.Actions.LocalActions.PackAction +{ + [Action(Name = "pack python", ParentCommandName = "pack", ShowInHelp = true, HelpText = "Arguments specific to Python apps when running func pack")] + internal class PythonPackSubcommandAction : PackSubcommandAction + { + private readonly ISecretsManager _secretsManager; + + public PythonPackSubcommandAction(ISecretsManager secretsManager) + { + _secretsManager = secretsManager; + } + + public bool BuildNativeDeps { get; set; } + + public override ICommandLineParserResult ParseArgs(string[] args) + { + Parser + .Setup("build-native-deps") + .WithDescription("Builds function app locally using an image that matches the environment used in Azure. " + + "When enabled, Core Tools starts a Docker container, builds the app inside that container," + + " and creates a ZIP file with all dependencies restored in .python_packages.") + .Callback(o => BuildNativeDeps = o); + + return base.ParseArgs(args); + } + + public async Task RunAsync(PackOptions packOptions, string[] args) + { + await ExecuteAsync(packOptions, args); + } + + protected override void ParseSubcommandArgs(string[] args) + { + // Parse python-specific args + ParseArgs(args); + } + + protected override void ValidateFunctionApp(string functionAppRoot, PackOptions options) + { + // ValidateFunctionApp invalid flag combinations + if (options.NoBuild && BuildNativeDeps) + { + throw new CliException("Invalid options: --no-build cannot be used with --build-native-deps."); + } + + // Windows warning when not using native deps + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !BuildNativeDeps) + { + ColoredConsole.WriteLine(WarningColor("Python function apps are supported only on Linux. Please use the --build-native-deps flag" + + " when building on windows to ensure dependencies are properly restored.")); + } + } + + protected override Task GetPackingRootAsync(string functionAppRoot, PackOptions options) + { + // Python packs from the function app root + return Task.FromResult(functionAppRoot); + } + + protected override Task PackFunctionAsync(string packingRoot, string outputPath, PackOptions options) + { + // Include BuildNativeDeps in packaging call + return PackHelpers.CreatePackage(packingRoot, outputPath, options.NoBuild, TelemetryCommandEvents, BuildNativeDeps); + } + + public override Task RunAsync() + { + // Keep this since this subcommand is not meant to be run directly. + return Task.CompletedTask; + } + } +} diff --git a/src/Cli/func/Common/SecretsManager.cs b/src/Cli/func/Common/SecretsManager.cs index b4128538f..309002e25 100644 --- a/src/Cli/func/Common/SecretsManager.cs +++ b/src/Cli/func/Common/SecretsManager.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. using Azure.Functions.Cli.Helpers; @@ -36,8 +36,13 @@ public static string AppSettingsFileName } } - public IDictionary GetSecrets() + public IDictionary GetSecrets(bool refreshSecrets = false) { + if (refreshSecrets) + { + return new AppSettingsFile(AppSettingsFilePath).GetValues(); + } + return Settings.GetValues(); } diff --git a/src/Cli/func/Helpers/HostHelpers.cs b/src/Cli/func/Helpers/HostHelpers.cs index 8eb9fe5b8..0d716787f 100644 --- a/src/Cli/func/Helpers/HostHelpers.cs +++ b/src/Cli/func/Helpers/HostHelpers.cs @@ -9,14 +9,15 @@ namespace Azure.Functions.Cli.Helpers { public static class HostHelpers { - public static async Task GetCustomHandlerExecutable() + public static async Task GetCustomHandlerExecutable(string path = null) { - if (!FileSystemHelpers.FileExists(Constants.HostJsonFileName)) + var file = !string.IsNullOrEmpty(path) ? Path.Combine(path, Constants.HostJsonFileName) : Constants.HostJsonFileName; + if (!FileSystemHelpers.FileExists(file)) { throw new InvalidOperationException(); } - var hostJson = JsonConvert.DeserializeObject(await FileSystemHelpers.ReadAllTextFromFileAsync(Constants.HostJsonFileName)); + var hostJson = JsonConvert.DeserializeObject(await FileSystemHelpers.ReadAllTextFromFileAsync(file)); return hostJson["customHandler"]?["description"]?["defaultExecutablePath"]?.ToString() ?? string.Empty; } } diff --git a/src/Cli/func/Helpers/WorkerRuntimeLanguageHelper.cs b/src/Cli/func/Helpers/WorkerRuntimeLanguageHelper.cs index 233c3384d..41489be3c 100644 --- a/src/Cli/func/Helpers/WorkerRuntimeLanguageHelper.cs +++ b/src/Cli/func/Helpers/WorkerRuntimeLanguageHelper.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. using Azure.Functions.Cli.Common; @@ -173,10 +173,10 @@ public static IEnumerable LanguagesForWorker(WorkerRuntime worker) return _normalizeMap.Where(p => p.Value == worker).Select(p => p.Key); } - public static WorkerRuntime GetCurrentWorkerRuntimeLanguage(ISecretsManager secretsManager) + public static WorkerRuntime GetCurrentWorkerRuntimeLanguage(ISecretsManager secretsManager, bool refreshSecrets = false) { var setting = Environment.GetEnvironmentVariable(Constants.FunctionsWorkerRuntime) - ?? secretsManager.GetSecrets().FirstOrDefault(s => s.Key.Equals(Constants.FunctionsWorkerRuntime, StringComparison.OrdinalIgnoreCase)).Value; + ?? secretsManager.GetSecrets(refreshSecrets).FirstOrDefault(s => s.Key.Equals(Constants.FunctionsWorkerRuntime, StringComparison.OrdinalIgnoreCase)).Value; try { diff --git a/src/Cli/func/Helpers/ZipHelper.cs b/src/Cli/func/Helpers/ZipHelper.cs index d166963af..fa50c729e 100644 --- a/src/Cli/func/Helpers/ZipHelper.cs +++ b/src/Cli/func/Helpers/ZipHelper.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. using System.IO.Compression; @@ -40,14 +40,15 @@ public static async Task GetAppZipFile(string functionAppRoot, bool buil else if (GlobalCoreToolsSettings.CurrentWorkerRuntime == WorkerRuntime.Dotnet && buildOption == BuildOption.Remote) { // Remote build for dotnet does not require bin and obj folders. They will be generated during the oryx build - return await CreateZip(FileSystemHelpers.GetLocalFiles(functionAppRoot, ignoreParser, false, new string[] { "bin", "obj" }), functionAppRoot, Enumerable.Empty()); + return await CreateZip(FileSystemHelpers.GetLocalFiles(functionAppRoot, ignoreParser, false, new string[] { "bin", "obj" }), functionAppRoot, Array.Empty()); } else { - var customHandler = await HostHelpers.GetCustomHandlerExecutable(); + var customHandler = await HostHelpers.GetCustomHandlerExecutable(functionAppRoot); IEnumerable executables = !string.IsNullOrEmpty(customHandler) ? new[] { customHandler } : Enumerable.Empty(); + return await CreateZip(FileSystemHelpers.GetLocalFiles(functionAppRoot, ignoreParser, false), functionAppRoot, executables); } } diff --git a/src/Cli/func/Interfaces/ISecretsManager.cs b/src/Cli/func/Interfaces/ISecretsManager.cs index 1cb271996..c65cb6365 100644 --- a/src/Cli/func/Interfaces/ISecretsManager.cs +++ b/src/Cli/func/Interfaces/ISecretsManager.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. using Azure.Functions.Cli.Common; @@ -7,7 +7,7 @@ namespace Azure.Functions.Cli.Interfaces { public interface ISecretsManager { - internal IDictionary GetSecrets(); + internal IDictionary GetSecrets(bool refreshSecrets = false); internal IEnumerable GetConnectionStrings(); diff --git a/test/Cli/Func.E2ETests/Commands/FuncPack/BasePackTests.cs b/test/Cli/Func.E2ETests/Commands/FuncPack/BasePackTests.cs index b89fc0527..227660559 100644 --- a/test/Cli/Func.E2ETests/Commands/FuncPack/BasePackTests.cs +++ b/test/Cli/Func.E2ETests/Commands/FuncPack/BasePackTests.cs @@ -1,6 +1,7 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. +using Azure.Functions.Cli.Common; using Azure.Functions.Cli.TestFramework.Assertions; using Azure.Functions.Cli.TestFramework.Commands; using FluentAssertions; @@ -11,7 +12,7 @@ namespace Azure.Functions.Cli.E2ETests.Commands.FuncPack { internal static class BasePackTests { - internal static void TestBasicPackFunctionality(string workingDir, string testName, string funcPath, ITestOutputHelper log, string[] filesToValidate) + internal static void TestBasicPackFunctionality(string workingDir, string testName, string funcPath, ITestOutputHelper log, string[] filesToValidate, string[]? logStatementsToValidate = null) { // Run pack command var funcPackCommand = new FuncPackCommand(funcPath, testName, log); @@ -44,5 +45,72 @@ internal static void TestBasicPackFunctionality(string workingDir, string testNa File.Delete(zipFiles.First()); // Clean up the zip file after validation } + + internal static async Task TestDotnetNoBuildCustomOutputPackFunctionality( + string projectDir, + string testName, + string funcPath, + ITestOutputHelper log, + string zipOutputDirectory, + string[] filesToValidate) + { + // Ensure publish output exists for --no-build scenario + var randomDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var outputPath = Path.Combine(randomDir, "output"); + var exe = new Executable("dotnet", $"publish --output {outputPath}", workingDirectory: projectDir); + var exitCode = await exe.RunAsync(); + exitCode.Should().Be(0); + + // Run func pack pointing at publish output directory, no build, and custom output location + var funcPackCommand = new FuncPackCommand(funcPath, testName, log); + var packResult = funcPackCommand + .WithWorkingDirectory(projectDir) + .Execute([outputPath, "--no-build", "--output", zipOutputDirectory]); + + // Verify pack succeeded and build was skipped + packResult.Should().ExitWith(0); + packResult.Should().NotHaveStdOutContaining("Building .NET project..."); + packResult.Should().HaveStdOutContaining("Skipping build event for functions project (--no-build)."); + + // Find any zip files in the specified output directory + var zipFiles = Directory.GetFiles(zipOutputDirectory, "*.zip"); + Assert.True(zipFiles.Length > 0, $"No zip files found in {zipOutputDirectory}"); + + // Validate zip contents + packResult.Should().ValidateZipContents(zipFiles.First(), filesToValidate, log); + + // Clean up + File.Delete(zipFiles.First()); + } + + internal static void TestPackWithPathArgument( + string funcInvocationWorkingDir, + string projectAbsoluteDir, + string pathArgumentToPass, + string testName, + string funcPath, + ITestOutputHelper log, + string[] filesToValidate) + { + var expectedZip = Path.Combine(funcInvocationWorkingDir, Path.GetFileName(projectAbsoluteDir) + ".zip"); + if (File.Exists(expectedZip)) + { + File.Delete(expectedZip); + } + + var funcPackCommand = new FuncPackCommand(funcPath, testName, log); + var packResult = funcPackCommand + .WithWorkingDirectory(funcInvocationWorkingDir) + .Execute(new[] { pathArgumentToPass }); + + packResult.Should().ExitWith(0); + packResult.Should().HaveStdOutContaining("Creating a new package"); + + File.Exists(expectedZip).Should().BeTrue($"Expected package at {expectedZip} was created."); + + packResult.Should().ValidateZipContents(expectedZip, filesToValidate, log); + + File.Delete(expectedZip); + } } } diff --git a/test/Cli/Func.E2ETests/Commands/FuncPack/CustomHandlerPackTests.cs b/test/Cli/Func.E2ETests/Commands/FuncPack/CustomHandlerPackTests.cs new file mode 100644 index 000000000..36b90c6fa --- /dev/null +++ b/test/Cli/Func.E2ETests/Commands/FuncPack/CustomHandlerPackTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.IO.Compression; +using Azure.Functions.Cli.E2ETests.Traits; +using Azure.Functions.Cli.TestFramework.Assertions; +using Azure.Functions.Cli.TestFramework.Commands; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Azure.Functions.Cli.E2ETests.Commands.FuncPack +{ + [Trait(WorkerRuntimeTraits.WorkerRuntime, WorkerRuntimeTraits.Custom)] + public class CustomHandlerPackTests : BaseE2ETests + { + public CustomHandlerPackTests(ITestOutputHelper log) + : base(log) + { + } + + private string CustomHandlerProjectPath => Path.Combine(TestProjectDirectory, "TestCustomHandlerProject"); + + [Fact] + public void Pack_CustomHandler_TurnsBitExecutable() + { + var testName = nameof(Pack_CustomHandler_TurnsBitExecutable); + + var packResult = new FuncPackCommand(FuncPath, testName, Log) + .WithWorkingDirectory(CustomHandlerProjectPath) + .Execute([]); + + packResult.Should().ExitWith(0); + packResult.Should().HaveStdOutContaining("Creating a new package"); + + var zipFiles = Directory.GetFiles(CustomHandlerProjectPath, "*.zip"); + Assert.True(zipFiles.Length > 0, $"No zip files found in {CustomHandlerProjectPath}"); + + var zipPath = zipFiles.First(); + + packResult.Should().ValidateZipContents( + zipPath, + new[] + { + "host.json", + "GoCustomHandlers" + }, + Log); + + using (var archive = ZipFile.OpenRead(zipPath)) + { + var entry = archive.Entries.FirstOrDefault(e => e.FullName.Replace('\\', '/').EndsWith("GoCustomHandlers")); + entry.Should().NotBeNull("GoCustomHandlers should be present in the packaged zip"); + + int permissions = (entry!.ExternalAttributes >> 16) & 0xFFFF; + permissions.Should().Be(Convert.ToInt32("100777", 8), "GoCustomHandlers should be marked as executable in the zip"); + } + + File.Delete(zipPath); + } + + [Fact] + public void Pack_CustomHandler_WithRelativePathArgument_Works() + { + var testName = nameof(Pack_CustomHandler_WithRelativePathArgument_Works); + var projectName = "TestCustomHandlerProject"; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: TestProjectDirectory, + projectAbsoluteDir: Path.Combine(TestProjectDirectory, projectName), + pathArgumentToPass: $"./{projectName}", + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: + new[] + { + "host.json", + "GoCustomHandlers" + }); + } + + [Fact] + public void Pack_CustomHandler_WithAbsolutePathArgument_Works() + { + var testName = nameof(Pack_CustomHandler_WithAbsolutePathArgument_Works); + var projectAbs = CustomHandlerProjectPath; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: WorkingDirectory, + projectAbsoluteDir: projectAbs, + pathArgumentToPass: projectAbs, + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + "GoCustomHandlers" + }); + } + } +} diff --git a/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetInProc6PackTests.cs b/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetInProc6PackTests.cs index 06279d26b..1891f9d4c 100644 --- a/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetInProc6PackTests.cs +++ b/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetInProc6PackTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. using Azure.Functions.Cli.E2ETests.Traits; @@ -21,7 +21,12 @@ public DotnetInProc6PackTests(ITestOutputHelper log) public void Pack_Dotnet6InProc_WorksAsExpected() { var testName = nameof(Pack_Dotnet6InProc_WorksAsExpected); - Log.WriteLine(Dotnet6ProjectPath); + + var logsToValidate = new[] + { + "Building .NET project...", + "Determining projects to restore..." + }; BasePackTests.TestBasicPackFunctionality( Dotnet6ProjectPath, @@ -31,8 +36,92 @@ public void Pack_Dotnet6InProc_WorksAsExpected() new[] { "host.json", - "Dotnet6InProc.cs", - "TestNet6InProcProject.csproj" + Path.Combine("bin", "extensions.json"), + Path.Combine("bin", "function.deps.json"), + Path.Combine("bin", "Microsoft.Azure.WebJobs.Host.Storage.dll"), + Path.Combine("bin", "Microsoft.WindowsAzure.Storage.dll"), + Path.Combine("bin", "TestNet6InProcProject.dll"), + Path.Combine("bin", "TestNet6InProcProject.pdb"), + Path.Combine("Dotnet6InProc", "function.json"), + Path.Combine("bin", "runtimes", "browser", "lib", "net6.0", "System.Text.Encodings.Web.dll") + }, + logsToValidate); + } + + [Fact] + public async Task Pack_Dotnet6InProc_CustomOutput_NoBuild() + { + var testName = nameof(Pack_Dotnet6InProc_CustomOutput_NoBuild); + + await BasePackTests.TestDotnetNoBuildCustomOutputPackFunctionality( + Dotnet6ProjectPath, + testName, + FuncPath, + Log, + WorkingDirectory, + new[] + { + "host.json", + Path.Combine("bin", "extensions.json"), + Path.Combine("bin", "function.deps.json"), + Path.Combine("bin", "Microsoft.Azure.WebJobs.Host.Storage.dll"), + Path.Combine("bin", "Microsoft.WindowsAzure.Storage.dll"), + Path.Combine("bin", "TestNet6InProcProject.dll"), + Path.Combine("bin", "TestNet6InProcProject.pdb"), + Path.Combine("Dotnet6InProc", "function.json"), + Path.Combine("bin", "runtimes", "browser", "lib", "net6.0", "System.Text.Encodings.Web.dll") + }); + } + + [Fact] + public void Pack_Dotnet6InProc_WithRelativePathArgument_Works() + { + var testName = nameof(Pack_Dotnet6InProc_WithRelativePathArgument_Works); + var projectName = "TestNet6InProcProject"; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: TestProjectDirectory, + projectAbsoluteDir: Path.Combine(TestProjectDirectory, projectName), + pathArgumentToPass: $"./{projectName}", + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + Path.Combine("bin", "extensions.json"), + Path.Combine("bin", "function.deps.json"), + Path.Combine("bin", "Microsoft.Azure.WebJobs.Host.Storage.dll"), + Path.Combine("bin", "Microsoft.WindowsAzure.Storage.dll"), + Path.Combine("bin", "TestNet6InProcProject.dll"), + Path.Combine("bin", "TestNet6InProcProject.pdb"), + Path.Combine("Dotnet6InProc", "function.json"), + Path.Combine("bin", "runtimes", "browser", "lib", "net6.0", "System.Text.Encodings.Web.dll") + }); + } + + [Fact] + public void Pack_Dotnet6InProc_WithAbsolutePathArgument_Works() + { + var testName = nameof(Pack_Dotnet6InProc_WithAbsolutePathArgument_Works); + var projectAbs = Dotnet6ProjectPath; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: WorkingDirectory, + projectAbsoluteDir: projectAbs, + pathArgumentToPass: projectAbs, + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + Path.Combine("bin", "extensions.json"), + Path.Combine("bin", "function.deps.json"), + Path.Combine("bin", "Microsoft.Azure.WebJobs.Host.Storage.dll"), + Path.Combine("bin", "Microsoft.WindowsAzure.Storage.dll"), + Path.Combine("bin", "TestNet6InProcProject.dll"), + Path.Combine("bin", "TestNet6InProcProject.pdb"), + Path.Combine("Dotnet6InProc", "function.json"), + Path.Combine("bin", "runtimes", "browser", "lib", "net6.0", "System.Text.Encodings.Web.dll") }); } } diff --git a/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetInProc8PackTests.cs b/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetInProc8PackTests.cs index afb8b1aaa..9eeeddb60 100644 --- a/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetInProc8PackTests.cs +++ b/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetInProc8PackTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. using Azure.Functions.Cli.E2ETests.Traits; @@ -22,6 +22,12 @@ public void Pack_Dotnet8InProc_WorksAsExpected() { var testName = nameof(Pack_Dotnet8InProc_WorksAsExpected); + var logsToValidate = new[] + { + "Building .NET project...", + "Determining projects to restore..." + }; + BasePackTests.TestBasicPackFunctionality( Dotnet8ProjectPath, testName, @@ -30,8 +36,88 @@ public void Pack_Dotnet8InProc_WorksAsExpected() new[] { "host.json", - "Dotnet8InProc.cs", - "TestNet8InProcProject.csproj" + Path.Combine("bin", "extensions.json"), + Path.Combine("bin", "function.deps.json"), + Path.Combine("bin", "Microsoft.Azure.WebJobs.Host.Storage.dll"), + Path.Combine("bin", "Microsoft.WindowsAzure.Storage.dll"), + Path.Combine("bin", "TestNet8InProcProject.dll"), + Path.Combine("bin", "TestNet8InProcProject.pdb"), + Path.Combine("Dotnet8InProc", "function.json") + }, + logsToValidate); + } + + [Fact] + public async Task Pack_Dotnet8InProc_CustomOutput_NoBuild() + { + var testName = nameof(Pack_Dotnet8InProc_CustomOutput_NoBuild); + + await BasePackTests.TestDotnetNoBuildCustomOutputPackFunctionality( + Dotnet8ProjectPath, + testName, + FuncPath, + Log, + WorkingDirectory, + new[] + { + "host.json", + Path.Combine("bin", "extensions.json"), + Path.Combine("bin", "function.deps.json"), + Path.Combine("bin", "Microsoft.Azure.WebJobs.Host.Storage.dll"), + Path.Combine("bin", "Microsoft.WindowsAzure.Storage.dll"), + Path.Combine("bin", "TestNet8InProcProject.dll"), + Path.Combine("bin", "TestNet8InProcProject.pdb"), + Path.Combine("Dotnet8InProc", "function.json") + }); + } + + [Fact] + public void Pack_Dotnet8InProc_WithRelativePathArgument_Works() + { + var testName = nameof(Pack_Dotnet8InProc_WithRelativePathArgument_Works); + var projectName = "TestNet8InProcProject"; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: TestProjectDirectory, + projectAbsoluteDir: Path.Combine(TestProjectDirectory, projectName), + pathArgumentToPass: $"./{projectName}", + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + Path.Combine("bin", "extensions.json"), + Path.Combine("bin", "function.deps.json"), + Path.Combine("bin", "Microsoft.Azure.WebJobs.Host.Storage.dll"), + Path.Combine("bin", "Microsoft.WindowsAzure.Storage.dll"), + Path.Combine("bin", "TestNet8InProcProject.dll"), + Path.Combine("bin", "TestNet8InProcProject.pdb"), + Path.Combine("Dotnet8InProc", "function.json") + }); + } + + [Fact] + public void Pack_Dotnet8InProc_WithAbsolutePathArgument_Works() + { + var testName = nameof(Pack_Dotnet8InProc_WithAbsolutePathArgument_Works); + var projectAbs = Dotnet8ProjectPath; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: WorkingDirectory, + projectAbsoluteDir: projectAbs, + pathArgumentToPass: projectAbs, + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + Path.Combine("bin", "extensions.json"), + Path.Combine("bin", "function.deps.json"), + Path.Combine("bin", "Microsoft.Azure.WebJobs.Host.Storage.dll"), + Path.Combine("bin", "Microsoft.WindowsAzure.Storage.dll"), + Path.Combine("bin", "TestNet8InProcProject.dll"), + Path.Combine("bin", "TestNet8InProcProject.pdb"), + Path.Combine("Dotnet8InProc", "function.json") }); } } diff --git a/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetIsolatedPackTests.cs b/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetIsolatedPackTests.cs index b68da0658..1f7cdfb36 100644 --- a/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetIsolatedPackTests.cs +++ b/test/Cli/Func.E2ETests/Commands/FuncPack/DotnetIsolatedPackTests.cs @@ -1,7 +1,6 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. -using Azure.Functions.Cli.E2ETests.Fixtures; using Azure.Functions.Cli.E2ETests.Traits; using Xunit; using Xunit.Abstractions; @@ -23,17 +22,147 @@ public void Pack_DotnetIsolated_WorksAsExpected() { var testName = nameof(Pack_DotnetIsolated_WorksAsExpected); + var logsToValidate = new[] + { + "Building .NET project...", + "Determining projects to restore..." + }; + BasePackTests.TestBasicPackFunctionality( DotnetIsolatedProjectPath, testName, FuncPath, Log, new[] + { + "Azure.Core.dll", + "Azure.Identity.dll", + "extensions.json", + "functions.metadata", + "Google.Protobuf.dll", + "Grpc.Core.Api.dll", + "Grpc.Net.Client.dll", + "Grpc.Net.ClientFactory.dll", + "Grpc.Net.Common.dll", + "host.json", + "Microsoft.AI.DependencyCollector.dll", + "Microsoft.AI.EventCounterCollector.dll", + "Microsoft.AI.PerfCounterCollector.dll", + "Microsoft.AI.ServerTelemetryChannel.dll", + "Microsoft.AI.WindowsServer.dll", + "Microsoft.ApplicationInsights.dll", + "Microsoft.ApplicationInsights.WorkerService.dll", + "Microsoft.Azure.Functions.Worker.ApplicationInsights.dll", + "Microsoft.Azure.Functions.Worker.Core.dll", + "Microsoft.Azure.Functions.Worker.dll", + "Microsoft.Azure.Functions.Worker.Extensions.Abstractions.dll", + "Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.dll", + "Microsoft.Azure.Functions.Worker.Extensions.Http.dll", + "Microsoft.Azure.Functions.Worker.Grpc.dll", + "Microsoft.Bcl.AsyncInterfaces.dll", + "Microsoft.Extensions.Configuration.Binder.dll", + "Microsoft.Extensions.Configuration.FileExtensions.dll", + "Microsoft.Extensions.Configuration.Json.dll", + "Microsoft.Extensions.Configuration.UserSecrets.dll", + "Microsoft.Extensions.DependencyInjection.Abstractions.dll", + "Microsoft.Extensions.DependencyInjection.dll", + "Microsoft.Extensions.Diagnostics.Abstractions.dll", + "Microsoft.Extensions.Diagnostics.dll", + "Microsoft.Extensions.Hosting.Abstractions.dll", + "Microsoft.Extensions.Hosting.dll", + "Microsoft.Extensions.Logging.Abstractions.dll", + "Microsoft.Extensions.Logging.ApplicationInsights.dll", + "Microsoft.Extensions.Logging.Configuration.dll", + "Microsoft.Extensions.Logging.Console.dll", + "Microsoft.Extensions.Logging.Debug.dll", + "Microsoft.Extensions.Logging.dll", + "Microsoft.Extensions.Logging.EventLog.dll", + "Microsoft.Extensions.Logging.EventSource.dll", + "Microsoft.Extensions.Options.dll", + "Microsoft.Identity.Client.dll", + "Microsoft.Identity.Client.Extensions.Msal.dll", + "Microsoft.IdentityModel.Abstractions.dll", + "Microsoft.Win32.SystemEvents.dll", + "System.ClientModel.dll", + "System.Configuration.ConfigurationManager.dll", + "System.Diagnostics.EventLog.dll", + "System.Diagnostics.PerformanceCounter.dll", + "System.Drawing.Common.dll", + "System.Memory.Data.dll", + "System.Security.Cryptography.ProtectedData.dll", + "System.Security.Permissions.dll", + "System.Windows.Extensions.dll", + "TestDotnet8IsolatedProject.deps.json", + "TestDotnet8IsolatedProject.dll", + "TestDotnet8IsolatedProject.pdb", + "TestDotnet8IsolatedProject.runtimeconfig.json", + "worker.config.json", + Path.Combine(".azurefunctions", "function.deps.json"), + Path.Combine(".azurefunctions", "Microsoft.Azure.Functions.Worker.Extensions.dll"), + Path.Combine(".azurefunctions", "Microsoft.Azure.Functions.Worker.Extensions.pdb"), + Path.Combine(".azurefunctions", "Microsoft.Azure.WebJobs.Extensions.FunctionMetadataLoader.dll"), + Path.Combine(".azurefunctions", "Microsoft.Azure.WebJobs.Host.Storage.dll"), + Path.Combine(".azurefunctions", "Microsoft.WindowsAzure.Storage.dll") + }, + logsToValidate); + } + + [Fact] + public async Task Pack_DotnetIsolated_CustomOutput_NoBuild() + { + var testName = nameof(Pack_DotnetIsolated_CustomOutput_NoBuild); + + await BasePackTests.TestDotnetNoBuildCustomOutputPackFunctionality( + DotnetIsolatedProjectPath, + testName, + FuncPath, + Log, + WorkingDirectory, + new[] + { + "Azure.Core.dll", + "Azure.Identity.dll", + "extensions.json", + "functions.metadata", + "host.json" + }); + } + + [Fact] + public void Pack_DotnetIsolated_WithRelativePathArgument_Works() + { + var testName = nameof(Pack_DotnetIsolated_WithRelativePathArgument_Works); + var projectName = "TestDotnet8IsolatedProject"; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: TestProjectDirectory, + projectAbsoluteDir: Path.Combine(TestProjectDirectory, projectName), + pathArgumentToPass: $"./{projectName}", + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + "extensions.json" + }); + } + + [Fact] + public void Pack_DotnetIsolated_WithAbsolutePathArgument_Works() + { + var testName = nameof(Pack_DotnetIsolated_WithAbsolutePathArgument_Works); + var projectAbs = DotnetIsolatedProjectPath; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: WorkingDirectory, + projectAbsoluteDir: projectAbs, + pathArgumentToPass: projectAbs, + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] { "host.json", - "TestDotnet8IsolatedProject.csproj", - "Program.cs", - "Function1.cs" + "extensions.json" }); } } diff --git a/test/Cli/Func.E2ETests/Commands/FuncPack/NodePackTests.cs b/test/Cli/Func.E2ETests/Commands/FuncPack/NodePackTests.cs index ed1d9789b..7c5e8fb88 100644 --- a/test/Cli/Func.E2ETests/Commands/FuncPack/NodePackTests.cs +++ b/test/Cli/Func.E2ETests/Commands/FuncPack/NodePackTests.cs @@ -1,7 +1,11 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. using Azure.Functions.Cli.E2ETests.Traits; +using Azure.Functions.Cli.Helpers; +using Azure.Functions.Cli.TestFramework.Assertions; +using Azure.Functions.Cli.TestFramework.Commands; +using FluentAssertions; using Xunit; using Xunit.Abstractions; @@ -22,6 +26,17 @@ public void Pack_Node_WorksAsExpected() { var testName = nameof(Pack_Node_WorksAsExpected); + var logsToValidate = new[] + { + "Running 'npm install'...", + "Running 'npm run build'...", + }; + + if (Directory.Exists(Path.Combine(NodeProjectPath, "node_modules"))) + { + Directory.Delete(Path.Combine(NodeProjectPath, "node_modules"), true); + } + BasePackTests.TestBasicPackFunctionality( NodeProjectPath, testName, @@ -32,7 +47,133 @@ public void Pack_Node_WorksAsExpected() "host.json", "package.json", Path.Combine("src", "functions", "HttpTrigger.js"), - "package-lock.json" + "package-lock.json", + Path.Combine("node_modules", ".package-lock.json") + }, + logsToValidate); + } + + [Fact] + public async Task Pack_Node_CustomOutput_NoBuild() + { + var testName = nameof(Pack_Node_CustomOutput_NoBuild); + + if (!Directory.Exists(Path.Combine(NodeProjectPath, "node_modules"))) + { + Environment.CurrentDirectory = NodeProjectPath; + await NpmHelper.RunNpmCommand($"install"); + } + + // Run npm run build first + await NpmHelper.RunNpmCommand($"run build"); + + // Now pack from the directory produced by npm build (nodecustom) + var result = new FuncPackCommand(FuncPath, testName, Log) + .WithWorkingDirectory(NodeProjectPath) + .Execute(new[] { "--no-build", "-o", "nodecustomzip" }); + + result.Should().ExitWith(0); + result.Should().HaveStdOutContaining("Skipping build event for functions project (--no-build)."); + result.Should().NotHaveStdOutContaining("Running 'npm install'..."); + + var expectedZip = Path.Combine(NodeProjectPath, "nodecustomzip", "TestNodeProject.zip"); + File.Exists(expectedZip).Should().BeTrue(); + + result.Should().ValidateZipContents( + expectedZip, + new[] + { + "host.json", + "package.json", + Path.Combine("src", "functions", "HttpTrigger.js"), + "package-lock.json", + Path.Combine("node_modules", ".package-lock.json") + }, + Log); + + File.Delete(expectedZip); + } + + [Fact] + public async Task Pack_Node_SkipInstall_CreatesPackage() + { + var testName = nameof(Pack_Node_SkipInstall_CreatesPackage); + var customOutput = "nodeskipinstall"; + + if (!Directory.Exists(Path.Combine(NodeProjectPath, "node_modules"))) + { + Environment.CurrentDirectory = NodeProjectPath; + await NpmHelper.Install(); + } + + var result = new FuncPackCommand(FuncPath, testName, Log) + .WithWorkingDirectory(NodeProjectPath) + .Execute(new[] { "--skip-install", "-o", customOutput }); + + result.Should().ExitWith(0); + result.Should().NotHaveStdOutContaining("Running 'npm install'..."); + result.Should().NotHaveStdOutContaining("Skipping build event for functions project (--no-build)."); + result.Should().HaveStdOutContaining("Running 'npm run build'..."); + + var expectedZip = Path.Combine(NodeProjectPath, customOutput, "TestNodeProject.zip"); + File.Exists(expectedZip).Should().BeTrue(); + + result.Should().ValidateZipContents( + expectedZip, + new[] + { + "host.json", + "package.json", + Path.Combine("src", "functions", "HttpTrigger.js"), + "package-lock.json", + Path.Combine("node_modules", ".package-lock.json") + }, + Log); + + File.Delete(expectedZip); + } + + [Fact] + public void Pack_Node_WithRelativePathArgument_Works() + { + var testName = nameof(Pack_Node_WithRelativePathArgument_Works); + var projectName = "TestNodeProject"; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: TestProjectDirectory, + projectAbsoluteDir: Path.Combine(TestProjectDirectory, projectName), + pathArgumentToPass: $"./{projectName}", + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + "package.json", + Path.Combine("src", "functions", "HttpTrigger.js"), + "package-lock.json", + Path.Combine("node_modules", ".package-lock.json") + }); + } + + [Fact] + public void Pack_Node_WithAbsolutePathArgument_Works() + { + var testName = nameof(Pack_Node_WithAbsolutePathArgument_Works); + var projectAbs = NodeProjectPath; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: WorkingDirectory, + projectAbsoluteDir: projectAbs, + pathArgumentToPass: projectAbs, + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + "package.json", + Path.Combine("src", "functions", "HttpTrigger.js"), + "package-lock.json", + Path.Combine("node_modules", ".package-lock.json") }); } } diff --git a/test/Cli/Func.E2ETests/Commands/FuncPack/PowershellPackTests.cs b/test/Cli/Func.E2ETests/Commands/FuncPack/PowershellPackTests.cs index 1f8a1c739..f223fc643 100644 --- a/test/Cli/Func.E2ETests/Commands/FuncPack/PowershellPackTests.cs +++ b/test/Cli/Func.E2ETests/Commands/FuncPack/PowershellPackTests.cs @@ -1,7 +1,10 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. using Azure.Functions.Cli.E2ETests.Traits; +using Azure.Functions.Cli.TestFramework.Assertions; +using Azure.Functions.Cli.TestFramework.Commands; +using FluentAssertions; using Xunit; using Xunit.Abstractions; @@ -36,5 +39,125 @@ public void Pack_Powershell_WorksAsExpected() Path.Combine("HttpTrigger", "function.json") }); } + + [Fact] + public void Pack_Powershell_CustomOutput_NoBuild() + { + var testName = nameof(Pack_Powershell_CustomOutput_NoBuild); + var customOutput = "pscustom"; + var packNoBuild = new FuncPackCommand(FuncPath, testName, Log) + .WithWorkingDirectory(PowershellProjectPath) + .Execute(["--no-build", "-o", customOutput]); + + packNoBuild.Should().ExitWith(0); + packNoBuild.Should().HaveStdOutContaining("Skipping build event for functions project (--no-build)."); + + var expectedZip = Path.Combine(PowershellProjectPath, customOutput, "TestPowershellProject.zip"); + File.Exists(expectedZip).Should().BeTrue(); + packNoBuild.Should().ValidateZipContents(expectedZip, ["host.json", "requirements.psd1"], Log); + File.Delete(expectedZip); + } + + [Fact] + public void Pack_Powershell_NoBuild_BehaviorUnchanged() + { + var testName = nameof(Pack_Powershell_NoBuild_BehaviorUnchanged); + + // Clean any previous zips + foreach (var zip in Directory.GetFiles(PowershellProjectPath, "*.zip")) + { + File.Delete(zip); + } + + // Baseline: regular pack + var regular = new FuncPackCommand(FuncPath, testName + "_Regular", Log) + .WithWorkingDirectory(PowershellProjectPath) + .Execute([]); + regular.Should().ExitWith(0); + + var baselineZip = Directory.GetFiles(PowershellProjectPath, "*.zip").FirstOrDefault(); + baselineZip.Should().NotBeNull(); + regular.Should().ValidateZipContents( + baselineZip!, + new[] + { + "host.json", + "requirements.psd1", + Path.Combine("HttpTrigger", "run.ps1"), + "profile.ps1", + Path.Combine("HttpTrigger", "function.json") + }, + Log); + + // Remove zip to avoid interference + File.Delete(baselineZip!); + + // Now run with --no-build and validate contents are the same (behavior unchanged) + var nobuild = new FuncPackCommand(FuncPath, testName + "_NoBuild", Log) + .WithWorkingDirectory(PowershellProjectPath) + .Execute(["--no-build"]); + nobuild.Should().ExitWith(0); + nobuild.Should().HaveStdOutContaining("Skipping build event for functions project (--no-build)."); + + var nobuildZip = Directory.GetFiles(PowershellProjectPath, "*.zip").FirstOrDefault(); + nobuildZip.Should().NotBeNull(); + nobuild.Should().ValidateZipContents( + nobuildZip!, + new[] + { + "host.json", + "requirements.psd1", + Path.Combine("HttpTrigger", "run.ps1"), + "profile.ps1", + Path.Combine("HttpTrigger", "function.json") + }, + Log); + + File.Delete(nobuildZip!); + } + + [Fact] + public void Pack_Powershell_WithRelativePathArgument_Works() + { + var testName = nameof(Pack_Powershell_WithRelativePathArgument_Works); + var projectName = "TestPowershellProject"; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: TestProjectDirectory, + projectAbsoluteDir: Path.Combine(TestProjectDirectory, projectName), + pathArgumentToPass: $"./{projectName}", + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + "requirements.psd1", + Path.Combine("HttpTrigger", "run.ps1"), + "profile.ps1", + Path.Combine("HttpTrigger", "function.json") + }); + } + + [Fact] + public void Pack_Powershell_WithAbsolutePathArgument_Works() + { + var testName = nameof(Pack_Powershell_WithAbsolutePathArgument_Works); + var projectAbs = PowershellProjectPath; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: WorkingDirectory, + projectAbsoluteDir: projectAbs, + pathArgumentToPass: projectAbs, + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + "requirements.psd1", + Path.Combine("HttpTrigger", "run.ps1"), + "profile.ps1", + Path.Combine("HttpTrigger", "function.json") + }); + } } } diff --git a/test/Cli/Func.E2ETests/Commands/FuncPack/PythonPackTests.cs b/test/Cli/Func.E2ETests/Commands/FuncPack/PythonPackTests.cs index eee0ff5e4..407af756c 100644 --- a/test/Cli/Func.E2ETests/Commands/FuncPack/PythonPackTests.cs +++ b/test/Cli/Func.E2ETests/Commands/FuncPack/PythonPackTests.cs @@ -1,6 +1,7 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. +using System.Runtime.InteropServices; using Azure.Functions.Cli.E2ETests.Traits; using Azure.Functions.Cli.TestFramework.Assertions; using Azure.Functions.Cli.TestFramework.Commands; @@ -25,6 +26,23 @@ public void Pack_Python_WorksAsExpected() { var testName = nameof(Pack_Python_WorksAsExpected); + // Remove existing _python_packages directory + if (Directory.Exists(Path.Combine(PythonProjectPath, ".python_packages"))) + { + Directory.Delete(Path.Combine(PythonProjectPath, ".python_packages"), true); + } + + var logsToValidate = new[] + { + "Found Python version 3.11.9 (py).", + "Successfully downloaded azure-functions werkzeug MarkupSafe" + }; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + logsToValidate = logsToValidate.Append("Python function apps is supported only on Linux. Please use the --build-native-deps flag when building on windows to ensure dependencies are properly restored.").ToArray(); + } + BasePackTests.TestBasicPackFunctionality( PythonProjectPath, testName, @@ -36,7 +54,8 @@ public void Pack_Python_WorksAsExpected() "requirements.txt", "function_app.py", Path.Combine(".python_packages", "requirements.txt.md5") - }); + }, + logsToValidate); } [Fact] @@ -47,7 +66,6 @@ public void Pack_PythonFromCache_WorksAsExpected() var syncDirMessage = "Directory .python_packages already in sync with requirements.txt. Skipping restoring dependencies..."; // Step 1: Initialize a Python function app - // Note that we need to initialize the function app as we are testing an instance that has not run pack before. var funcInitCommand = new FuncInitCommand(FuncPath, testName, Log ?? throw new ArgumentNullException(nameof(Log))); var initResult = funcInitCommand .WithWorkingDirectory(workingDir) @@ -116,5 +134,133 @@ public void Pack_PythonFromCache_WorksAsExpected() // Verify .python_packages/requirements.txt.md5 file still exists thirdPackResult.Should().FilesExistsWithExpectContent(packFilesToValidate); } + + [Fact] + public void Pack_Python_BuildNativeDeps_OnWindows_WorksAsExpected() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // Only validate this scenario on Linux since linux based docker image is required + return; + } + + var testName = nameof(Pack_Python_BuildNativeDeps_OnWindows_WorksAsExpected); + + // Remove existing _python_packages directory + if (Directory.Exists(Path.Combine(PythonProjectPath, ".python_packages"))) + { + Directory.Delete(Path.Combine(PythonProjectPath, ".python_packages"), true); + } + + var packResult = new FuncPackCommand(FuncPath, testName, Log) + .WithWorkingDirectory(PythonProjectPath) + .Execute(["--build-native-deps"]); + + packResult.Should().ExitWith(0); + packResult.Should().HaveStdOutContaining("Creating a new package"); + packResult.Should().HaveStdOutContaining("Running 'docker pull"); + + // Find any zip files in the working directory + var zipFiles = Directory.GetFiles(PythonProjectPath, "*.zip"); + Assert.True(zipFiles.Length > 0, $"No zip files found in {PythonProjectPath}"); + + // Validate minimal contents + packResult.Should().ValidateZipContents( + zipFiles.First(), + new[] + { + "host.json", + "requirements.txt", + "function_app.py", + Path.Combine(".python_packages", "requirements.txt.md5") + }, + Log); + + File.Delete(zipFiles.First()); + } + + [Fact] + public void Pack_Python_NoBuild_JustZipsDirectory() + { + var testName = nameof(Pack_Python_NoBuild_JustZipsDirectory); + + var packResult = new FuncPackCommand(FuncPath, testName, Log) + .WithWorkingDirectory(PythonProjectPath) + .Execute(["--no-build"]); + + packResult.Should().ExitWith(0); + packResult.Should().HaveStdOutContaining("Creating a new package"); + packResult.Should().HaveStdOutContaining("Skipping build event for functions project (--no-build)."); + + var zipFiles = Directory.GetFiles(PythonProjectPath, "*.zip"); + Assert.True(zipFiles.Length > 0, $"No zip files found in {PythonProjectPath}"); + + packResult.Should().ValidateZipContents( + zipFiles.First(), + new[] + { + "host.json", + "requirements.txt", + "function_app.py" + }, + Log); + + File.Delete(zipFiles.First()); + } + + [Fact] + public void Pack_Python_NoBuild_WithNativeDeps_ShouldFail() + { + var testName = nameof(Pack_Python_NoBuild_WithNativeDeps_ShouldFail); + + var packResult = new FuncPackCommand(FuncPath, testName, Log) + .WithWorkingDirectory(PythonProjectPath) + .Execute(["--no-build", "--build-native-deps"]); + + packResult.Should().ExitWith(1); + packResult.Should().HaveStdErrContaining("Invalid options: --no-build cannot be used with --build-native-deps."); + } + + [Fact] + public void Pack_Python_WithRelativePathArgument_Works() + { + var testName = nameof(Pack_Python_WithRelativePathArgument_Works); + var projectName = "TestPythonProject"; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: TestProjectDirectory, + projectAbsoluteDir: Path.Combine(TestProjectDirectory, projectName), + pathArgumentToPass: $"./{projectName}", + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + "requirements.txt", + "function_app.py", + Path.Combine(".python_packages", "requirements.txt.md5") + }); + } + + [Fact] + public void Pack_Python_WithAbsolutePathArgument_Works() + { + var testName = nameof(Pack_Python_WithAbsolutePathArgument_Works); + var projectAbs = PythonProjectPath; + BasePackTests.TestPackWithPathArgument( + funcInvocationWorkingDir: WorkingDirectory, + projectAbsoluteDir: projectAbs, + pathArgumentToPass: projectAbs, + testName: testName, + funcPath: FuncPath, + log: Log, + filesToValidate: new[] + { + "host.json", + "requirements.txt", + "function_app.py", + Path.Combine(".python_packages", "requirements.txt.md5") + }); + } } } diff --git a/test/Cli/Func.UnitTests/ActionsTests/StartHostActionTests.cs b/test/Cli/Func.UnitTests/ActionsTests/StartHostActionTests.cs index 2116116b4..8d7ffc7e9 100644 --- a/test/Cli/Func.UnitTests/ActionsTests/StartHostActionTests.cs +++ b/test/Cli/Func.UnitTests/ActionsTests/StartHostActionTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. using System.IO.Abstractions; @@ -285,7 +285,7 @@ public async Task GetConfigurationSettings_OverwritesAzFuncEnvironment_WhenAlrea }; var mockSecretsManager = new Mock(); - mockSecretsManager.Setup(s => s.GetSecrets()) + mockSecretsManager.Setup(s => s.GetSecrets(false)) .Returns(() => new Dictionary(secretsDict)); // Return an empty set of connection strings of the expected type diff --git a/test/Cli/Func.UnitTests/HelperTests/HostHelperTests.cs b/test/Cli/Func.UnitTests/HelperTests/HostHelperTests.cs index b2cc57492..a7e5b96da 100644 --- a/test/Cli/Func.UnitTests/HelperTests/HostHelperTests.cs +++ b/test/Cli/Func.UnitTests/HelperTests/HostHelperTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. using System.IO.Abstractions; @@ -32,7 +32,7 @@ public async Task GetCustomHandlerExecutable_Throws_When_HostJson_Missing() [Fact] public async Task GetCustomHandlerExecutable_Returns_ExecutablePath_When_Present() { - var json = @"{""customHandler"":{""description"":{""defaultExecutablePath"":""file.exe""}}}"; + var json = @"{""customHandler"":{""description"":{ ""defaultExecutablePath"":""file.exe"" }}}"; var fileSystem = Substitute.For(); fileSystem.File.Exists(Arg.Any()).Returns(true); fileSystem.File.Open(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -56,7 +56,7 @@ public async Task GetCustomHandlerExecutable_Returns_ExecutablePath_When_Present [Fact] public async Task GetCustomHandlerExecutable_Returns_Empty_When_ExecutablePath_Missing() { - var json = @"{""customHandler"":{""description"":{}}}"; + var json = @"{""customHandler"":{ ""description"":{}}}"; var fileSystem = Substitute.For(); fileSystem.File.Exists(Arg.Any()).Returns(true); fileSystem.File.Open(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -101,6 +101,40 @@ public async Task GetCustomHandlerExecutable_Returns_Empty_When_CustomHandler_Mi result.Should().BeEmpty(); } + [Fact] + public async Task GetCustomHandlerExecutable_Uses_Provided_Path_To_Read_HostJson() + { + // Arrange + var customRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var expectedHostJsonPath = Path.Combine(customRoot, Constants.HostJsonFileName); + var json = @"{""customHandler"":{""description"":{ ""defaultExecutablePath"":""file.exe"" }}}"; + + var fileSystem = Substitute.For(); + + fileSystem.File.Exists(Arg.Any()) + .Returns(ci => string.Equals(ci.ArgAt(0), expectedHostJsonPath, StringComparison.OrdinalIgnoreCase)); + + fileSystem.File.Open(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + var path = ci.ArgAt(0); + if (string.Equals(path, expectedHostJsonPath, StringComparison.OrdinalIgnoreCase)) + { + return json.ToStream(); + } + + throw new FileNotFoundException(path); + }); + + FileSystemHelpers.Instance = fileSystem; + + // Act + var result = await HostHelpers.GetCustomHandlerExecutable(customRoot); + + // Assert + result.Should().Be("file.exe"); + } + public void Dispose() { FileSystemHelpers.Instance = _originalFileSystem; diff --git a/test/TestFunctionApps/TestCustomHandlerProject/GoCustomHandlers b/test/TestFunctionApps/TestCustomHandlerProject/GoCustomHandlers new file mode 100644 index 000000000..588c670ef Binary files /dev/null and b/test/TestFunctionApps/TestCustomHandlerProject/GoCustomHandlers differ diff --git a/test/TestFunctionApps/TestCustomHandlerProject/GoCustomHandlers.go b/test/TestFunctionApps/TestCustomHandlerProject/GoCustomHandlers.go new file mode 100644 index 000000000..4e94f8b8e --- /dev/null +++ b/test/TestFunctionApps/TestCustomHandlerProject/GoCustomHandlers.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "time" +) + +func simpleHttpTriggerHandler(w http.ResponseWriter, r *http.Request) { + t := time.Now() + fmt.Println(t.Month()) + fmt.Println(t.Day()) + fmt.Println(t.Year()) + ua := r.Header.Get("User-Agent") + fmt.Printf("user agent is: %s \n", ua) + invocationid := r.Header.Get("X-Azure-Functions-InvocationId") + fmt.Printf("invocationid is: %s \n", invocationid) + + queryParams := r.URL.Query() + + for k, v := range queryParams { + fmt.Println("k:", k, "v:", v) + } + + w.Write([]byte("Hello World from go worker")) +} + +func main() { + customHandlerPort, exists := os.LookupEnv("FUNCTIONS_CUSTOMHANDLER_PORT") + if exists { + fmt.Println("FUNCTIONS_CUSTOMHANDLER_PORT: " + customHandlerPort) + } + mux := http.NewServeMux() + mux.HandleFunc("/api/SimpleHttpTrigger", simpleHttpTriggerHandler) + fmt.Println("Go server Listening...on FUNCTIONS_CUSTOMHANDLER_PORT:", customHandlerPort) + log.Fatal(http.ListenAndServe(":"+customHandlerPort, mux)) +} diff --git a/test/TestFunctionApps/TestCustomHandlerProject/SimpleHttpTrigger/function.json b/test/TestFunctionApps/TestCustomHandlerProject/SimpleHttpTrigger/function.json new file mode 100644 index 000000000..ab7f2cc88 --- /dev/null +++ b/test/TestFunctionApps/TestCustomHandlerProject/SimpleHttpTrigger/function.json @@ -0,0 +1,18 @@ +{ + "bindings": [ + { + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post" + ] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ] +} \ No newline at end of file diff --git a/test/TestFunctionApps/TestCustomHandlerProject/host.json b/test/TestFunctionApps/TestCustomHandlerProject/host.json new file mode 100644 index 000000000..c42e933a8 --- /dev/null +++ b/test/TestFunctionApps/TestCustomHandlerProject/host.json @@ -0,0 +1,13 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "customHandler": { + "description": { + "defaultExecutablePath": "GoCustomHandlers" + }, + "enableForwardingHttpRequest":true + } +} \ No newline at end of file diff --git a/test/TestFunctionApps/TestCustomHandlerProject/local.settings.json b/test/TestFunctionApps/TestCustomHandlerProject/local.settings.json new file mode 100644 index 000000000..3c094a681 --- /dev/null +++ b/test/TestFunctionApps/TestCustomHandlerProject/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "Custom" + }, + "ConnectionStrings": {} +} \ No newline at end of file diff --git a/test/TestFunctionApps/TestDotnet8IsolatedProject/TestDotnet8IsolatedProject.csproj b/test/TestFunctionApps/TestDotnet8IsolatedProject/TestDotnet8IsolatedProject.csproj index b4201f6ab..9d04939dc 100644 --- a/test/TestFunctionApps/TestDotnet8IsolatedProject/TestDotnet8IsolatedProject.csproj +++ b/test/TestFunctionApps/TestDotnet8IsolatedProject/TestDotnet8IsolatedProject.csproj @@ -5,6 +5,7 @@ Exe enable enable + false diff --git a/test/TestFunctionApps/TestNodeProject/package.json b/test/TestFunctionApps/TestNodeProject/package.json index 8ba10afa6..7384f595e 100644 --- a/test/TestFunctionApps/TestNodeProject/package.json +++ b/test/TestFunctionApps/TestNodeProject/package.json @@ -5,7 +5,8 @@ "main": "src/functions/*.js", "scripts": { "start": "func start", - "test": "echo \"No tests yet...\"" + "test": "echo \"No tests yet...\"", + "build": "echo \"Building...\"" }, "dependencies": { "@azure/functions": "^4.0.0"