Skip to content

Commit 54c9b0d

Browse files
jviauCopilot
andauthored
[MSBUILD SDK] Collect extension files into .azurefunctions output folder (#3277)
* Staging work * Collect extension files to .azurefunctions output folder * Update src/Azure.Functions.Sdk/Targets/Inner/Azure.Functions.Sdk.Inner.RuntimePackages.props Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix flaky test * Fix random csproj generation --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent dd7aa5d commit 54c9b0d

13 files changed

+933
-5
lines changed

src/Azure.Functions.Sdk/Targets/Extensions/Azure.Functions.Sdk.Extensions.targets

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ WARNING: DO NOT MODIFY this file unless you are knowledgeable about MSBuild and
2525
DependsOnTargets="CollectExtensionPackages;WriteExtensionProject;RestoreExtensionProject"
2626
AfterTargets="Restore" />
2727

28+
<Target Name="PrepareFunctionsExtensionPayload" Condition="'$(DesignTimeBuild)' != 'true'"
29+
DependsOnTargets="GetFunctionsExtensionFiles"
30+
AfterTargets="CoreCompile" BeforeTargets="GetCopyToOutputDirectoryItems" />
31+
32+
<Target Name="PublishFunctionsExtensionPayload" Condition="'$(DesignTimeBuild)' != 'true'"
33+
DependsOnTargets="GetFunctionsExtensionFiles"
34+
BeforeTargets="GetCopyToPublishDirectoryItems" />
35+
2836
<Target Name="CollectExtensionPackages" Returns="@(_AzureFunctionPackageReference)">
2937
<ResolveExtensionPackages ProjectAssetsFile="$(ProjectAssetsFile)">
3038
<Output TaskParameter="ExtensionPackages" ItemName="_AzureFunctionPackageReference" />
@@ -46,4 +54,16 @@ WARNING: DO NOT MODIFY this file unless you are knowledgeable about MSBuild and
4654
RemoveProperties="$(_AzureFunctionsExtensionRemoveProps)" Properties="IsRestoring=true;RestoreSources=$(_OutputSources)" />
4755
</Target>
4856

57+
<Target Name="GetFunctionsExtensionFiles" Returns="@(_FunctionsExtensionsOutputItems)">
58+
<MSBuild Projects="$(_AzureFunctionsExtensionProjectPath)" Targets="ResolveFunctionsExtensionFiles"
59+
RemoveProperties="$(_AzureFunctionsExtensionRemoveProps)">
60+
<Output TaskParameter="TargetOutputs" ItemName="_FunctionsExtensionsOutputItems" />
61+
</MSBuild>
62+
63+
<ItemGroup>
64+
<_NoneWithTargetPath Include="@(_FunctionsExtensionsOutputItems)"
65+
CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
66+
</ItemGroup>
67+
</Target>
68+
4969
</Project>

src/Azure.Functions.Sdk/Targets/Inner/Azure.Functions.Sdk.Inner.RuntimePackages.props

Lines changed: 604 additions & 0 deletions
Large diffs are not rendered by default.

src/Azure.Functions.Sdk/Targets/Inner/Azure.Functions.Sdk.Inner.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@ WARNING: DO NOT MODIFY this file unless you are knowledgeable about MSBuild and
2121
</PropertyGroup>
2222

2323
<Import Sdk="Microsoft.NET.Sdk" Project="Sdk.props" />
24+
<Import Project="$(MSBuildThisFileDirectory)Azure.Functions.Sdk.Inner.RuntimePackages.props" />
2425

2526
</Project>

src/Azure.Functions.Sdk/Targets/Inner/Azure.Functions.Sdk.Inner.targets

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ WARNING: DO NOT MODIFY this file unless you are knowledgeable about MSBuild and
1111

1212
<Project>
1313

14+
<UsingTask TaskName="$(AzureFunctionsTaskNamespace).Inner.ResolveExtensionCopyLocal" AssemblyFile="$(AzureFunctionsSdkTasksAssembly)" />
15+
1416
<Import Sdk="Microsoft.NET.Sdk" Project="Sdk.targets" />
1517

1618
<!--
@@ -21,4 +23,21 @@ WARNING: DO NOT MODIFY this file unless you are knowledgeable about MSBuild and
2123
<Error Condition="'$(TargetFramework)' != 'net8.0'" Text="The target framework '$(TargetFramework)' must be 'net8.0'. Verify if target framework has been overridden by a global property." />
2224
</Target>
2325

26+
<Target Name="ResolveFunctionsExtensionFiles"
27+
DependsOnTargets="ResolveReferences;GenerateBuildDependencyFile;GetCopyToOutputDirectoryItems"
28+
Returns="@(FunctionsExtensionFiles)">
29+
<ResolveExtensionCopyLocal RuntimeAssemblies="@(_FunctionsRuntimeAssembly)" RuntimePackages="@(_FunctionsRuntimePackage)" CopyLocalFiles="@(ReferenceCopyLocalPaths)">
30+
<Output TaskParameter="ExtensionsCopyLocal" ItemName="FunctionsExtensionFiles" />
31+
</ResolveExtensionCopyLocal>
32+
33+
<ItemGroup>
34+
<FunctionsExtensionFiles Include="@(AllItemsFullPathWithTargetPath)">
35+
<TargetPath>$([System.IO.Path]::Combine(.azurefunctions, %(AllItemsFullPathWithTargetPath.TargetPath)))</TargetPath>
36+
</FunctionsExtensionFiles>
37+
<FunctionsExtensionFiles Include="$(ProjectDepsFilePath)">
38+
<TargetPath>$([System.IO.Path]::Combine(.azurefunctions, function.deps.json))</TargetPath>
39+
</FunctionsExtensionFiles>
40+
</ItemGroup>
41+
</Target>
42+
2443
</Project>

src/Azure.Functions.Sdk/TaskItemExtensions.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See License.txt in the project root for license information.
33

4+
using System.Diagnostics.CodeAnalysis;
45
using Microsoft.Build.Framework;
56

67
namespace Azure.Functions.Sdk;
@@ -39,5 +40,25 @@ public string SourcePackageId
3940
get => taskItem.GetMetadata("SourcePackageId") ?? string.Empty;
4041
set => taskItem.SetMetadata("SourcePackageId", value);
4142
}
43+
44+
/// <summary>
45+
/// Gets or sets the "NuGetPackageId" metadata on the task item.
46+
/// </summary>
47+
public string NuGetPackageId
48+
{
49+
get => taskItem.GetMetadata("NuGetPackageId") ?? string.Empty;
50+
set => taskItem.SetMetadata("NuGetPackageId", value);
51+
}
52+
53+
/// <summary>
54+
/// Tries to get the NuGet package ID from the task item.
55+
/// </summary>
56+
/// <param name="packageId">The package ID, if found.</param>
57+
/// <returns><c>true</c> if nuget package ID is found; <c>false</c> otherwise.</returns>
58+
public bool TryGetNuGetPackageId([NotNullWhen(true)] out string? packageId)
59+
{
60+
packageId = taskItem.NuGetPackageId;
61+
return !string.IsNullOrEmpty(packageId);
62+
}
4263
}
4364
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using Microsoft.Build.Framework;
5+
6+
namespace Azure.Functions.Sdk.Tasks.Inner;
7+
8+
/// <summary>
9+
/// Resolves the set of extension assemblies that should be copied locally,
10+
/// excluding those that are part of the Azure Functions runtime.
11+
/// </summary>
12+
public class ResolveExtensionCopyLocal : Microsoft.Build.Utilities.Task
13+
{
14+
/// <summary>
15+
/// Gets or sets the runtime assemblies.
16+
/// </summary>
17+
/// <remarks>
18+
/// These are the assemblies that are part of the Azure Functions runtime and should not be included
19+
/// in the extensions payload.
20+
/// </remarks>
21+
[Required]
22+
public ITaskItem[] RuntimeAssemblies { get; set; } = [];
23+
24+
/// <summary>
25+
/// Gets or sets the runtime packages.
26+
/// </summary>
27+
/// <remarks>
28+
/// These are packages that are part of the Azure Functions runtime and should not be included
29+
/// in the extensions payload.
30+
/// </remarks>
31+
[Required]
32+
public ITaskItem[] RuntimePackages { get; set; } = [];
33+
34+
/// <summary>
35+
/// Gets or sets the copy local files.
36+
/// </summary>
37+
[Required]
38+
public ITaskItem[] CopyLocalFiles { get; set; } = [];
39+
40+
/// <summary>
41+
/// Gets the extensions copy local items.
42+
/// </summary>
43+
[Output]
44+
public ITaskItem[] ExtensionsCopyLocal { get; private set; } = [];
45+
46+
public override bool Execute()
47+
{
48+
HashSet<string> runtimeAssemblies = new(
49+
RuntimeAssemblies.Select(p => p.ItemSpec), StringComparer.OrdinalIgnoreCase);
50+
HashSet<string> runtimePackages = new(
51+
RuntimePackages.Select(p => p.ItemSpec), StringComparer.OrdinalIgnoreCase);
52+
53+
List<ITaskItem> extensionsCopyLocal = [];
54+
foreach (ITaskItem item in CopyLocalFiles)
55+
{
56+
if (ShouldIncludeItem(item, runtimeAssemblies, runtimePackages))
57+
{
58+
string destination = item.GetMetadata("DestinationSubPath");
59+
item.SetMetadata("TargetPath", Path.Combine(Constants.ExtensionsOutputFolder, destination));
60+
extensionsCopyLocal.Add(item);
61+
}
62+
}
63+
64+
ExtensionsCopyLocal = [.. extensionsCopyLocal];
65+
return !Log.HasLoggedErrors;
66+
}
67+
68+
private static bool ShouldIncludeItem(
69+
ITaskItem item, HashSet<string> runtimeAssemblies, HashSet<string> runtimePackages)
70+
{
71+
if (item.TryGetNuGetPackageId(out string? packageId) && runtimePackages.Contains(packageId))
72+
{
73+
// Comes from a runtime package, exclude.
74+
return false;
75+
}
76+
77+
// Check if the assembly name is in the runtime assemblies list.
78+
string fileName = Path.GetFileName(item.ItemSpec);
79+
return !runtimeAssemblies.Contains(fileName);
80+
}
81+
}

test/Azure.Functions.Sdk.Tests/Assertions/TaskItemAssertions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ public AndConstraint<TaskItemAssertions> HaveMetadata(
5555
return HaveMetadata(name, value, StringComparer.Ordinal, because, becauseArgs);
5656
}
5757

58+
[CustomAssertion]
59+
public AndConstraint<TaskItemAssertions> HaveMetadataLike(
60+
string name, string value, string because = "", params object[] becauseArgs)
61+
{
62+
string actual = Subject.GetMetadata(name);
63+
actual.Should().Match(value, because, becauseArgs);
64+
return new AndConstraint<TaskItemAssertions>(this);
65+
}
66+
5867
[CustomAssertion]
5968
public AndConstraint<TaskItemAssertions> HaveMetadata(
6069
string name,

test/Azure.Functions.Sdk.Tests/Integration/MSBuildSdkTestBase.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ protected virtual void Dispose(bool disposing)
5353
}
5454
}
5555

56-
protected string GetTempCsproj() => _temp.GetRandomFile(ext: ".csproj");
56+
// Ensure this starts with a non-numeric character to be a valid csproj name.
57+
protected string GetTempCsproj() => _temp.GetRandomCsproj();
5758

5859
private static string GetArtifactsPath()
5960
{

test/Azure.Functions.Sdk.Tests/Integration/SdkEndToEndTests.Build.cs

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,37 @@ namespace Azure.Functions.Sdk.Tests.Integration;
77

88
public partial class SdkEndToEndTests
99
{
10+
private static readonly string[] ExpectedExtensionFiles =
11+
[
12+
"Azure.Core.Amqp.dll",
13+
"Azure.Core.dll",
14+
"Azure.Identity.dll",
15+
"Azure.Messaging.ServiceBus.dll",
16+
"Azure.Storage.Blobs.dll",
17+
"Azure.Storage.Common.dll",
18+
"Azure.Storage.Queues.dll",
19+
"function.deps.json",
20+
"Google.Protobuf.dll",
21+
"Grpc.AspNetCore.Server.ClientFactory.dll",
22+
"Grpc.AspNetCore.Server.dll",
23+
"Grpc.Core.Api.dll",
24+
"Grpc.Net.Client.dll",
25+
"Grpc.Net.ClientFactory.dll",
26+
"Grpc.Net.Common.dll",
27+
"Microsoft.Azure.Amqp.dll",
28+
"Microsoft.Azure.WebJobs.Extensions.Rpc.dll",
29+
"Microsoft.Azure.WebJobs.Extensions.ServiceBus.dll",
30+
"Microsoft.Azure.WebJobs.Extensions.Storage.Blobs.dll",
31+
"Microsoft.Azure.WebJobs.Extensions.Storage.Queues.dll",
32+
"Microsoft.Bcl.AsyncInterfaces.dll",
33+
"Microsoft.Extensions.Azure.dll",
34+
"Microsoft.Identity.Client.dll",
35+
"Microsoft.Identity.Client.Extensions.Msal.dll",
36+
"Microsoft.IdentityModel.Abstractions.dll",
37+
"System.ClientModel.dll",
38+
"System.IO.Hashing.dll",
39+
];
40+
1041
[Fact]
1142
public void Build_NetCore()
1243
{
@@ -26,6 +57,7 @@ public void Build_NetCore()
2657
Path.Combine(outputPath, "worker.config.json"),
2758
"dotnet",
2859
"MyFunctionApp.dll");
60+
ValidateExtensionsPayload(outputPath, "function.deps.json");
2961
}
3062

3163
[Fact]
@@ -48,6 +80,58 @@ public void Build_NetFx()
4880
Path.Combine(outputPath, "worker.config.json"),
4981
"{WorkerRoot}MyFunctionApp.exe",
5082
"MyFunctionApp.exe");
83+
ValidateExtensionsPayload(outputPath, "function.deps.json");
84+
}
85+
86+
[Fact]
87+
public void Build_NetCore_WithExtensions()
88+
{
89+
// Arrange
90+
ProjectCreator project = ProjectCreator.Templates.AzureFunctionsProject(
91+
GetTempCsproj(), targetFramework: "net8.0")
92+
.Property("AssemblyName", "MyFunctionApp")
93+
.WriteSourceFile("Program.cs", Resources.Program_Minimal_cs)
94+
.ItemPackageReference(NugetPackage.ServiceBus)
95+
.ItemPackageReference(NugetPackage.Storage);
96+
97+
// Act
98+
BuildOutput output = project.Build(restore: true);
99+
100+
// Assert
101+
output.Should().BeSuccessful().And.HaveNoIssues();
102+
string outputPath = project.GetOutputPath();
103+
ValidateConfig(
104+
Path.Combine(outputPath, "worker.config.json"),
105+
"dotnet",
106+
"MyFunctionApp.dll");
107+
108+
ValidateExtensionsPayload(outputPath, ExpectedExtensionFiles);
109+
}
110+
111+
[Fact]
112+
public void Build_NetFx_WithExtensions()
113+
{
114+
// Arrange
115+
ProjectCreator project = ProjectCreator.Templates.AzureFunctionsProject(
116+
GetTempCsproj(), targetFramework: "net481")
117+
.Property("AssemblyName", "MyFunctionApp")
118+
.Property("LangVersion", "latest")
119+
.WriteSourceFile("Program.cs", Resources.Program_Minimal_cs)
120+
.ItemPackageReference(NugetPackage.ServiceBus)
121+
.ItemPackageReference(NugetPackage.Storage);
122+
123+
// Act
124+
BuildOutput output = project.Build(restore: true);
125+
126+
// Assert
127+
output.Should().BeSuccessful().And.HaveNoIssues();
128+
string outputPath = project.GetOutputPath();
129+
ValidateConfig(
130+
Path.Combine(outputPath, "worker.config.json"),
131+
"{WorkerRoot}MyFunctionApp.exe",
132+
"MyFunctionApp.exe");
133+
134+
ValidateExtensionsPayload(outputPath, ExpectedExtensionFiles);
51135
}
52136

53137
[Fact]
@@ -68,16 +152,35 @@ public void Build_Incremental_NoOp()
68152

69153
string configPath = Path.Combine(outputPath, "worker.config.json");
70154
ValidateConfig(configPath, "dotnet", "MyFunctionApp.dll");
155+
ValidateExtensionsPayload(outputPath, "function.deps.json");
71156

72157
FileInfo config = new(configPath);
73-
DateTime lastWriteTime = config.LastWriteTimeUtc;
158+
DateTime configWriteTime = config.LastWriteTimeUtc;
159+
160+
FileInfo deps = new(Path.Combine(outputPath, "function.deps.json"));
161+
DateTime depsWriteTime = deps.LastWriteTimeUtc;
74162

75163
// Act 2: Incremental build
76164
BuildOutput output2 = project.Build();
77165

78166
// Assert 2: Verify no changes were made
79167
output2.Should().BeSuccessful().And.HaveNoIssues();
80168
config.Refresh();
81-
config.LastWriteTimeUtc.Should().Be(lastWriteTime);
169+
config.LastWriteTimeUtc.Should().Be(configWriteTime);
170+
171+
deps.Refresh();
172+
deps.LastWriteTimeUtc.Should().Be(depsWriteTime);
173+
}
174+
175+
private static void ValidateExtensionsPayload(string outputPath, params string[] expectedFiles)
176+
{
177+
string extensionsFolder = Path.Combine(outputPath, Constants.ExtensionsOutputFolder);
178+
Directory.Exists(extensionsFolder).Should().BeTrue("Extensions folder should exist.");
179+
180+
HashSet<string> actualFiles = Directory.GetFiles(extensionsFolder, "*", SearchOption.AllDirectories)
181+
.Select(f => Path.GetRelativePath(extensionsFolder, f))
182+
.ToHashSet(StringComparer.OrdinalIgnoreCase);
183+
184+
actualFiles.Should().BeEquivalentTo(expectedFiles);
82185
}
83186
}

0 commit comments

Comments
 (0)