Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
</Compile>
<EmbeddedResource Include="..\Aspire.Hosting\Resources\LaunchProfileStrings.resx">
<Link>Resources\LaunchProfileStrings.resx</Link>
<LogicalName>Aspire.Hosting.Resources.LaunchProfileStrings.resources</LogicalName>
</EmbeddedResource>
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,50 @@ public static IResourceBuilder<AzureFunctionsProjectResource> AddAzureFunctionsP
ArgumentException.ThrowIfNullOrEmpty(name);

var resource = new AzureFunctionsProjectResource(name);
return AddAzureFunctionsProjectCore(builder, resource, new TProject());
}

/// <summary>
/// Adds an Azure Functions project to the distributed application.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to which the Azure Functions project will be added.</param>
/// <param name="name">The name to be associated with the Azure Functions project. This name will be used for service discovery when referenced in a dependency.</param>
/// <param name="projectPath">The path to the Azure Functions project file.</param>
/// <returns>An <see cref="IResourceBuilder{AzureFunctionsProjectResource}"/> for the added Azure Functions project resource.</returns>
/// <remarks>
/// <para>
/// This overload of the <see cref="AddAzureFunctionsProject(IDistributedApplicationBuilder, string, string)"/> method adds an Azure Functions project to the application
/// model using a path to the project file. This allows for projects to be referenced that may not be part of the same solution. If the project
/// path is not an absolute path then it will be computed relative to the app host directory.
/// </para>
/// <example>
/// Add an Azure Functions project to the app model via a project path.
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddAzureFunctionsProject("funcapp", @"..\MyFunctions\MyFunctions.csproj");
///
/// builder.Build().Run();
/// </code>
/// </example>
/// </remarks>
public static IResourceBuilder<AzureFunctionsProjectResource> AddAzureFunctionsProject(this IDistributedApplicationBuilder builder, [ResourceName] string name, string projectPath)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentNullException.ThrowIfNull(projectPath);

projectPath = NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, projectPath));

var resource = new AzureFunctionsProjectResource(name);
return AddAzureFunctionsProjectCore(builder, resource, new AzureFunctionsProjectMetadata(projectPath));
}

private static IResourceBuilder<AzureFunctionsProjectResource> AddAzureFunctionsProjectCore(
IDistributedApplicationBuilder builder,
AzureFunctionsProjectResource resource,
IProjectMetadata projectMetadata)
{
// Add the default storage resource if it doesn't already exist.
var storageResourceName = builder.CreateDefaultStorageName();
var storage = builder.Resources
Expand Down Expand Up @@ -91,7 +134,7 @@ public static IResourceBuilder<AzureFunctionsProjectResource> AddAzureFunctionsP
resource.HostStorage = storage;

var functionsBuilder = builder.AddResource(resource)
.WithAnnotation(new TProject())
.WithAnnotation(projectMetadata)
.WithAnnotation(new AzureFunctionsAnnotation());

// Add launch profile annotations like regular projects do.
Expand Down Expand Up @@ -254,4 +297,50 @@ private static string CreateDefaultStorageName(this IDistributedApplicationBuild
var applicationHash = builder.Configuration["AppHost:ProjectNameSha256"]![..5].ToLowerInvariant();
return $"{DefaultAzureFunctionsHostStorageName}{applicationHash}";
}

private static string NormalizePathForCurrentPlatform(string path)
{
if (string.IsNullOrEmpty(path))
{
return path;
}

// Fix slashes
path = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);

return Path.GetFullPath(path);
}

private sealed class AzureFunctionsProjectMetadata(string projectPath) : IProjectMetadata
{
private string? _resolvedProjectPath;

public string ProjectPath => _resolvedProjectPath ??= ResolveProjectPath(projectPath);

public bool SuppressBuild => false;

private static string ResolveProjectPath(string path)
{
if (Directory.Exists(path))
{
// Path is a directory, assume it's a project directory
var projectFiles = Directory.GetFiles(path, "*.*proj", new EnumerationOptions
{
MatchCasing = MatchCasing.CaseInsensitive,
RecurseSubdirectories = false,
IgnoreInaccessible = true
});

if (projectFiles.Length != 1)
{
// Either no project files found or multiple project files found,
// just let it pass through and be handled later during resource start
return path;
}
return Path.GetFullPath(projectFiles[0]);
}

return path;
}
}
}
4 changes: 4 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,10 @@ public async Task DeployAsync_WithAzureFunctionsProject_Works()
{
["principalId"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" },
},
string name when name.StartsWith("funcapp-containerapp") => new Dictionary<string, object>
{
["id"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/containerApps/funcapp" }
},
string name when name.StartsWith("funcapp") => new Dictionary<string, object>
{
["identity_id"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" },
Expand Down
179 changes: 165 additions & 14 deletions tests/Aspire.Hosting.Azure.Tests/AzureFunctionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ public async Task AddAzureFunctionsProject_WorksWithAddAzureContainerAppsInfrast

await Verify(rolesManifest.ToString(), "json")
.AppendContentAsFile(rolesBicep, "bicep");

}

[Fact]
Expand Down Expand Up @@ -382,7 +382,7 @@ public async Task AddAzureFunctionsProject_WorksWithAddAzureContainerAppsInfrast

await Verify(rolesManifest.ToString(), "json")
.AppendContentAsFile(rolesBicep, "bicep");

}

[Fact]
Expand Down Expand Up @@ -417,7 +417,7 @@ await Verify(rolesManifest.ToString(), "json")
.AppendContentAsFile(rolesBicep, "bicep")
.AppendContentAsFile(rolesManifest2.ToString(), "json")
.AppendContentAsFile(rolesBicep2, "bicep");

}

private static Task<(JsonNode ManifestNode, string BicepText)> GetManifestWithBicep(IResource resource) =>
Expand Down Expand Up @@ -545,14 +545,14 @@ private sealed class TestProjectWithHttpsNoPort : IProjectMetadata
public void AddAzureFunctionsProject_AddsDefaultLaunchProfileAnnotation_WhenConfigured()
{
using var builder = TestDistributedApplicationBuilder.Create();

// Set the AppHost default launch profile configuration
builder.Configuration["AppHost:DefaultLaunchProfileName"] = "TestProfile";

builder.AddAzureFunctionsProject<TestProject>("funcapp");

var functionsResource = Assert.Single(builder.Resources.OfType<AzureFunctionsProjectResource>());

// Verify that the DefaultLaunchProfileAnnotation is added
Assert.True(functionsResource.TryGetLastAnnotation<DefaultLaunchProfileAnnotation>(out var annotation));
Assert.Equal("TestProfile", annotation.LaunchProfileName);
Expand All @@ -562,14 +562,14 @@ public void AddAzureFunctionsProject_AddsDefaultLaunchProfileAnnotation_WhenConf
public void AddAzureFunctionsProject_AddsDefaultLaunchProfileAnnotation_FromDotnetLaunchProfile()
{
using var builder = TestDistributedApplicationBuilder.Create();

// Set the DOTNET_LAUNCH_PROFILE configuration
builder.Configuration["DOTNET_LAUNCH_PROFILE"] = "DotnetProfile";

builder.AddAzureFunctionsProject<TestProject>("funcapp");

var functionsResource = Assert.Single(builder.Resources.OfType<AzureFunctionsProjectResource>());

// Verify that the DefaultLaunchProfileAnnotation is added
Assert.True(functionsResource.TryGetLastAnnotation<DefaultLaunchProfileAnnotation>(out var annotation));
Assert.Equal("DotnetProfile", annotation.LaunchProfileName);
Expand All @@ -579,11 +579,11 @@ public void AddAzureFunctionsProject_AddsDefaultLaunchProfileAnnotation_FromDotn
public void AddAzureFunctionsProject_DoesNotAddLaunchProfileAnnotation_WhenNoConfigurationSet()
{
using var builder = TestDistributedApplicationBuilder.Create();

builder.AddAzureFunctionsProject<TestProject>("funcapp");

var functionsResource = Assert.Single(builder.Resources.OfType<AzureFunctionsProjectResource>());

// Verify that no DefaultLaunchProfileAnnotation is added when no configuration is set
Assert.False(functionsResource.TryGetLastAnnotation<DefaultLaunchProfileAnnotation>(out _));
}
Expand All @@ -592,17 +592,168 @@ public void AddAzureFunctionsProject_DoesNotAddLaunchProfileAnnotation_WhenNoCon
public void AddAzureFunctionsProject_AppHostConfigurationOverridesDotnetLaunchProfile()
{
using var builder = TestDistributedApplicationBuilder.Create();

// Set both configurations, AppHost should take precedence
builder.Configuration["AppHost:DefaultLaunchProfileName"] = "AppHostProfile";
builder.Configuration["DOTNET_LAUNCH_PROFILE"] = "DotnetProfile";

builder.AddAzureFunctionsProject<TestProject>("funcapp");

var functionsResource = Assert.Single(builder.Resources.OfType<AzureFunctionsProjectResource>());

// Verify that AppHost configuration takes precedence
Assert.True(functionsResource.TryGetLastAnnotation<DefaultLaunchProfileAnnotation>(out var annotation));
Assert.Equal("AppHostProfile", annotation.LaunchProfileName);
}

[Fact]
public void AddAzureFunctionsProject_WithProjectPath_Works()
{
using var tempDir = new TempDirectory();
using var builder = TestDistributedApplicationBuilder.Create();

// Create a temporary project file
var projectPath = Path.Combine(tempDir.Path, "TestFunctions.csproj");
File.WriteAllText(projectPath, "<Project Sdk=\"Microsoft.NET.Sdk\"></Project>");

var funcApp = builder.AddAzureFunctionsProject("funcapp", projectPath);

// Assert that default storage resource is configured
Assert.Contains(builder.Resources, resource =>
resource is AzureStorageResource && resource.Name.StartsWith(AzureFunctionsProjectResourceExtensions.DefaultAzureFunctionsHostStorageName));
// Assert that custom project resource type is configured
Assert.Contains(builder.Resources, resource =>
resource is AzureFunctionsProjectResource && resource.Name == "funcapp");

// Verify that the project metadata annotation is added
Assert.True(funcApp.Resource.TryGetLastAnnotation<IProjectMetadata>(out var projectMetadata));
Assert.NotNull(projectMetadata);
Assert.Contains("TestFunctions.csproj", projectMetadata.ProjectPath);
}

[Fact]
public void AddAzureFunctionsProject_WithProjectPath_NormalizesPath()
{
using var tempDir = new TempDirectory();
using var builder = TestDistributedApplicationBuilder.Create();

// Create a temporary project file
var projectPath = Path.Combine(tempDir.Path, "MyFunctions.csproj");
File.WriteAllText(projectPath, "<Project Sdk=\"Microsoft.NET.Sdk\"></Project>");

// Use a relative path from the builder's directory
var relativePath = Path.GetRelativePath(builder.AppHostDirectory, projectPath);
var funcApp = builder.AddAzureFunctionsProject("funcapp", relativePath);

// Verify that the project metadata annotation is added with normalized path
Assert.True(funcApp.Resource.TryGetLastAnnotation<IProjectMetadata>(out var projectMetadata));
Assert.NotNull(projectMetadata);

// The path should be normalized to an absolute path
Assert.True(Path.IsPathRooted(projectMetadata.ProjectPath));
}

[Fact]
public async Task AddAzureFunctionsProject_WithProjectPath_ConfiguresEnvironmentVariables()
{
using var tempDir = new TempDirectory();
using var builder = TestDistributedApplicationBuilder.Create();

// Create a temporary project file
var projectPath = Path.Combine(tempDir.Path, "TestFunctions.csproj");
File.WriteAllText(projectPath, "<Project Sdk=\"Microsoft.NET.Sdk\"></Project>");

builder.AddAzureFunctionsProject("funcapp", projectPath);

using var app = builder.Build();

await ExecuteBeforeStartHooksAsync(app, default);

var functionsResource = Assert.Single(builder.Resources.OfType<AzureFunctionsProjectResource>());
Assert.True(functionsResource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var envAnnotations));

var context = new EnvironmentCallbackContext(builder.ExecutionContext);
foreach (var envAnnotation in envAnnotations)
{
await envAnnotation.Callback(context);
}

// Verify common environment variables are set
Assert.True(context.EnvironmentVariables.ContainsKey("OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES"));
Assert.True(context.EnvironmentVariables.ContainsKey("FUNCTIONS_WORKER_RUNTIME"));
Assert.True(context.EnvironmentVariables.ContainsKey("AzureFunctionsJobHost__telemetryMode"));
}

[Fact]
public void AddAzureFunctionsProject_WithProjectPath_SharesDefaultStorage()
{
using var tempDir = new TempDirectory();
using var builder = TestDistributedApplicationBuilder.Create();

// Create temporary project files
var projectPath1 = Path.Combine(tempDir.Path, "Functions1.csproj");
var projectPath2 = Path.Combine(tempDir.Path, "Functions2.csproj");
File.WriteAllText(projectPath1, "<Project Sdk=\"Microsoft.NET.Sdk\"></Project>");
File.WriteAllText(projectPath2, "<Project Sdk=\"Microsoft.NET.Sdk\"></Project>");

builder.AddAzureFunctionsProject("funcapp1", projectPath1);
builder.AddAzureFunctionsProject("funcapp2", projectPath2);

// Assert that only one default storage resource exists and is shared
var storageResources = builder.Resources.OfType<AzureStorageResource>()
.Where(r => r.Name.StartsWith(AzureFunctionsProjectResourceExtensions.DefaultAzureFunctionsHostStorageName))
.ToList();
Assert.Single(storageResources);
}

[Fact]
[RequiresDocker]
public async Task AddAzureFunctionsProject_WithProjectPath_CanUseCustomHostStorage()
{
using var tempDir = new TempDirectory();
using var builder = TestDistributedApplicationBuilder.Create();

// Create a temporary project file
var projectPath = Path.Combine(tempDir.Path, "Functions.csproj");
File.WriteAllText(projectPath, "<Project Sdk=\"Microsoft.NET.Sdk\"></Project>");

var customStorage = builder.AddAzureStorage("my-custom-storage").RunAsEmulator();
var funcApp = builder.AddAzureFunctionsProject("funcapp", projectPath)
.WithHostStorage(customStorage);

using var host = builder.Build();
await host.StartAsync();

// Assert that the custom storage is used and default storage is not present
var model = host.Services.GetRequiredService<DistributedApplicationModel>();
Assert.DoesNotContain(model.Resources.OfType<AzureStorageResource>(),
r => r.Name.StartsWith(AzureFunctionsProjectResourceExtensions.DefaultAzureFunctionsHostStorageName));
var storageResource = Assert.Single(model.Resources.OfType<AzureStorageResource>());
Assert.Equal("my-custom-storage", storageResource.Name);

Assert.True(funcApp.Resource.TryGetAnnotationsOfType<ResourceRelationshipAnnotation>(out var relAnnotations));
var rel = Assert.Single(relAnnotations);
Assert.Equal("Reference", rel.Type);
Assert.Equal(customStorage.Resource, rel.Resource);

await host.StopAsync();
}

[Fact]
public void AddAzureFunctionsProject_WithProjectPath_AddsAzureFunctionsAnnotation()
{
using var tempDir = new TempDirectory();
using var builder = TestDistributedApplicationBuilder.Create();

// Create a temporary project file
var projectPath = Path.Combine(tempDir.Path, "Functions.csproj");
File.WriteAllText(projectPath, "<Project Sdk=\"Microsoft.NET.Sdk\"></Project>");

builder.AddAzureFunctionsProject("funcapp", projectPath);

var functionsResource = Assert.Single(builder.Resources.OfType<AzureFunctionsProjectResource>());

// Verify that AzureFunctionsAnnotation is added
Assert.True(functionsResource.TryGetLastAnnotation<AzureFunctionsAnnotation>(out _));
}
}