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
19 changes: 1 addition & 18 deletions src/Aspire.Hosting.Foundry/FoundryExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -253,24 +253,7 @@ internal static IResourceBuilder<T> WithRoleAssignments<T>(
params FoundryRole[] roles)
where T : IResource
{
if (roles is null || roles.Length == 0)
{
return builder.WithRoleAssignments(target, Array.Empty<CognitiveServicesBuiltInRole>());
}

var builtInRoles = new CognitiveServicesBuiltInRole[roles.Length];
for (var i = 0; i < roles.Length; i++)
{
builtInRoles[i] = roles[i] switch
{
FoundryRole.CognitiveServicesOpenAIContributor => CognitiveServicesBuiltInRole.CognitiveServicesOpenAIContributor,
FoundryRole.CognitiveServicesOpenAIUser => CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser,
FoundryRole.CognitiveServicesUser => CognitiveServicesBuiltInRole.CognitiveServicesUser,
_ => throw new ArgumentException($"'{roles[i]}' is not a valid {nameof(FoundryRole)} value.", nameof(roles))
};
}

return builder.WithRoleAssignments(target, builtInRoles);
return builder.WithRoleAssignments(target, FoundryRoleHelpers.ToCognitiveServicesBuiltInRoles(roles));
}

private static IResourceBuilder<FoundryResource> WithInitializer(this IResourceBuilder<FoundryResource> builder)
Expand Down
39 changes: 39 additions & 0 deletions src/Aspire.Hosting.Foundry/FoundryRole.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.Azure;
using Azure.Provisioning.CognitiveServices;

namespace Aspire.Hosting;

/// <summary>
Expand All @@ -23,3 +26,39 @@ internal enum FoundryRole
/// </summary>
CognitiveServicesUser,
}

internal static class FoundryRoleHelpers
{
internal static CognitiveServicesBuiltInRole[] ToCognitiveServicesBuiltInRoles(IReadOnlyList<FoundryRole>? roles)
{
if (roles is null || roles.Count == 0)
{
return [];
}

var builtInRoles = new CognitiveServicesBuiltInRole[roles.Count];
for (var i = 0; i < roles.Count; i++)
{
builtInRoles[i] = roles[i] switch
{
FoundryRole.CognitiveServicesOpenAIContributor => CognitiveServicesBuiltInRole.CognitiveServicesOpenAIContributor,
FoundryRole.CognitiveServicesOpenAIUser => CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser,
FoundryRole.CognitiveServicesUser => CognitiveServicesBuiltInRole.CognitiveServicesUser,
_ => throw new ArgumentException($"'{roles[i]}' is not a valid {nameof(FoundryRole)} value.", nameof(roles))
};
}

return builtInRoles;
}
}

internal static class FoundryProjectRoleHelpers
{
private const string AzureAIUserRoleId = "53ca6127-db72-4b80-b1b0-d745d6d5456d";
private const string AzureAIUserRoleName = "Azure AI User";

internal static HashSet<RoleDefinition> CreateDefaultRoleDefinitions() =>
[
new RoleDefinition(AzureAIUserRoleId, AzureAIUserRoleName)
];
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Aspire.Hosting.Foundry;
using Microsoft.Extensions.DependencyInjection;

Expand Down Expand Up @@ -323,6 +324,17 @@ await interactionService.PromptMessageBoxAsync(
ContainerRegistry = projResource.ContainerRegistry
});

builder.ApplicationBuilder.CreateResourceBuilder(target)
.WithReferenceRelationship(projResource);

// Hosted agents deploy from the target compute resource, so the target needs the project role assignment.
if ((!target.TryGetAnnotationsOfType<RoleAssignmentAnnotation>(out var roleAssignments) ||
!roleAssignments.Any(a => ReferenceEquals(a.Target, projResource))) &&
projResource.TryGetLastAnnotation<DefaultRoleAssignmentsAnnotation>(out var defaultRoleAssignments))
{
target.Annotations.Add(new RoleAssignmentAnnotation(projResource, defaultRoleAssignments.Roles));
}

builder.ApplicationBuilder.AddResource(agent)
.WithReferenceRelationship(target)
.WithReference(project);
Expand Down
74 changes: 73 additions & 1 deletion src/Aspire.Hosting.Foundry/Project/ProjectBuilderExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,72 @@ public static IResourceBuilder<AzureCognitiveServicesProjectResource> AddProject

var project = builder.ApplicationBuilder.AddResource(new AzureCognitiveServicesProjectResource(name, ConfigureInfrastructure, builder.Resource));
project.Resource.DefaultContainerRegistry = CreateDefaultRegistry(builder.ApplicationBuilder, $"{name}-acr");
return project;
return project.WithAnnotation(new DefaultRoleAssignmentsAnnotation(FoundryProjectRoleHelpers.CreateDefaultRoleDefinitions()));
}

/// <summary>
/// Assigns the specified roles to the Microsoft Foundry project identity on the parent Microsoft Foundry resource.
/// This replaces the default project role assignments.
/// </summary>
/// <param name="builder">The Microsoft Foundry project resource builder.</param>
/// <param name="target">The parent Microsoft Foundry resource.</param>
/// <param name="roles">The built-in Cognitive Services roles to assign to the project identity.</param>
/// <returns>The updated <see cref="IResourceBuilder{AzureCognitiveServicesProjectResource}"/> with the applied role assignments.</returns>
Comment thread
sebastienros marked this conversation as resolved.
/// <remarks>
/// <para>
/// Microsoft Foundry projects are assigned <see cref="CognitiveServicesBuiltInRole.CognitiveServicesUser"/> on their parent
/// Microsoft Foundry resource by default. Use this method to replace the default roles, or pass no roles to remove the
/// default role assignment.
/// </para>
/// <example>
/// The following example assigns the <see cref="CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser"/> role to a
/// Microsoft Foundry project identity on its parent Microsoft Foundry resource.
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var foundry = builder.AddFoundry("foundry");
/// var project = foundry.AddProject("project")
/// .WithRoleAssignments(foundry, CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser);
/// </code>
/// </example>
/// </remarks>
[AspireExportIgnore(Reason = "CognitiveServicesBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the FoundryRole-based overload instead.")]
public static IResourceBuilder<AzureCognitiveServicesProjectResource> WithRoleAssignments(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need this new API. Instead I think we should just add the AI User role assignment from the Project to the Foundry account automatically. See https://aka.ms/FoundryPermissions

Assign the Azure AI User role on your Foundry resource to your project's managed identity.

this IResourceBuilder<AzureCognitiveServicesProjectResource> builder,
IResourceBuilder<FoundryResource> target,
params CognitiveServicesBuiltInRole[] roles)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(target);

if (!ReferenceEquals(builder.Resource.Parent, target.Resource))
{
throw new ArgumentException($"The target Microsoft Foundry resource must be the parent of project resource '{builder.Resource.Name}'.", nameof(target));
}

builder.Resource.ParentAccountRoleAssignments = roles is null || roles.Length == 0
? []
: roles.Distinct().ToArray();

return builder;
}

/// <summary>
/// Assigns the specified roles to the Microsoft Foundry project identity on the parent Microsoft Foundry resource.
/// This replaces the default project role assignments.
/// </summary>
/// <param name="builder">The Microsoft Foundry project resource builder.</param>
/// <param name="target">The parent Microsoft Foundry resource.</param>
/// <param name="roles">The Microsoft Foundry roles to assign to the project identity.</param>
/// <returns>The updated <see cref="IResourceBuilder{AzureCognitiveServicesProjectResource}"/> with the applied role assignments.</returns>
/// <exception cref="ArgumentException">Thrown when a role value is not a valid <see cref="FoundryRole"/> value.</exception>
[AspireExport("withFoundryProjectRoleAssignments", MethodName = "withRoleAssignments", Description = "Assigns Microsoft Foundry roles to a project identity.")]
internal static IResourceBuilder<AzureCognitiveServicesProjectResource> WithRoleAssignments(
this IResourceBuilder<AzureCognitiveServicesProjectResource> builder,
IResourceBuilder<FoundryResource> target,
params FoundryRole[] roles)
{
return builder.WithRoleAssignments(target, FoundryRoleHelpers.ToCognitiveServicesBuiltInRoles(roles));
}

/// <summary>
Expand Down Expand Up @@ -402,6 +467,13 @@ internal static void ConfigureInfrastructure(AzureResourceInfrastructure infra)
Value = projectPrincipalId
});

foreach (var role in aspireResource.ParentAccountRoleAssignments)
{
var roleAssignment = account.CreateRoleAssignment(role, RoleManagementPrincipalType.ServicePrincipal, projectPrincipalId);
Comment thread
sebastienros marked this conversation as resolved.
roleAssignment.Name = BicepFunction.CreateGuid(account.Id, project.Id, roleAssignment.RoleDefinitionId);
infra.Add(roleAssignment);
}

/*
* Container registry for hosted agents
*
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Hosting.Foundry/Project/ProjectResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ IEnumerable<KeyValuePair<string, ReferenceExpression>> IResourceWithConnectionSt
/// </summary>
public BicepOutputReference PrincipalId => new("principalId", this);

internal IReadOnlyList<CognitiveServicesBuiltInRole> ParentAccountRoleAssignments { get; set; } =
[
CognitiveServicesBuiltInRole.CognitiveServicesUser
];

internal BicepOutputReference ContainerRegistryUrl => new("AZURE_CONTAINER_REGISTRY_ENDPOINT", this);
internal BicepOutputReference ContainerRegistryName => new("AZURE_CONTAINER_REGISTRY_NAME", this);
// Mnaged identity used for client access to container registry
Expand Down
53 changes: 41 additions & 12 deletions src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -867,9 +867,9 @@ private static void AddToCompatibilityMap(
}

/// <summary>
/// Detects method name collisions after capability expansion and removes colliding methods,
/// keeping only the first one (sorted by CapabilityId). A warning is emitted for each
/// removed capability. Since ATS doesn't support method overloading, each (TargetTypeId, MethodName)
/// Detects method name collisions after capability expansion and removes colliding target bindings,
/// preferring capabilities that directly target the concrete resource. A warning is emitted for each
/// removed target binding. Since ATS doesn't support method overloading, each (TargetTypeId, MethodName)
/// pair must be unique. Use [AspireExport(MethodName = "uniqueName")] to resolve collisions.
/// </summary>
private static void FilterMethodNameCollisions(List<AtsCapabilityInfo> capabilities, List<AtsDiagnostic> diagnostics)
Expand All @@ -878,6 +878,9 @@ private static void FilterMethodNameCollisions(List<AtsCapabilityInfo> capabilit
.Where(c => c.ExpandedTargetTypes.Count > 0)
.SelectMany(c => c.ExpandedTargetTypes.Select(t => (Target: t.TypeId, Capability: c)))
.ToList();
var capabilitiesWithExpandedTargets = capabilitiesWithTargets
.Select(t => t.Capability)
.ToHashSet();

var collisionGroups = capabilitiesWithTargets
.GroupBy(x => (x.Target, x.Capability.MethodName))
Expand All @@ -889,29 +892,55 @@ private static void FilterMethodNameCollisions(List<AtsCapabilityInfo> capabilit
return;
}

var capabilitiesToRemove = new HashSet<string>();
var capabilityTargetsToRemove = new Dictionary<AtsCapabilityInfo, HashSet<string>>();

foreach (var collisionGroup in collisionGroups)
{
var methodName = collisionGroup.Key.MethodName;
var targetTypeId = collisionGroup.Key.Target;
var capIds = collisionGroup.Select(x => x.Capability.CapabilityId).Distinct().ToList();
capIds.Sort(StringComparer.Ordinal);
var collidingCapabilities = collisionGroup
.Select(x => x.Capability)
.DistinctBy(c => c.CapabilityId)
.OrderByDescending(c => IsDirectTarget(c, targetTypeId))
.ThenBy(c => c.CapabilityId, StringComparer.Ordinal)
.ToArray();
var keptCapability = collidingCapabilities[0];
var capIds = collidingCapabilities
.Select(c => c.CapabilityId)
.Order(StringComparer.Ordinal)
.ToArray();

var conflictingIdsStr = string.Join(", ", capIds);

// First capability keeps original name, others are removed
for (var i = 1; i < capIds.Count; i++)
foreach (var capability in collidingCapabilities.Skip(1))
{
capabilitiesToRemove.Add(capIds[i]);
if (!capabilityTargetsToRemove.TryGetValue(capability, out var targets))
{
targets = [];
capabilityTargetsToRemove[capability] = targets;
}

targets.Add(targetTypeId);

diagnostics.Add(AtsDiagnostic.Warning(
$"Method '{methodName}' on target '{targetTypeId}' has collisions ({conflictingIdsStr}). '{capIds[i]}' was removed. Use [AspireExport(MethodName = \"uniqueName\")] to set an explicit name.",
capIds[i]));
$"Method '{methodName}' on target '{targetTypeId}' has collisions ({conflictingIdsStr}). '{capability.CapabilityId}' was removed from this target; '{keptCapability.CapabilityId}' was kept. Use [AspireExport(MethodName = \"uniqueName\")] to set an explicit name.",
capability.CapabilityId));
}
}

capabilities.RemoveAll(c => capabilitiesToRemove.Contains(c.CapabilityId));
foreach (var (capability, targetsToRemove) in capabilityTargetsToRemove)
{
capability.ExpandedTargetTypes = capability.ExpandedTargetTypes
.Where(t => !targetsToRemove.Contains(t.TypeId))
.ToArray();
}

capabilities.RemoveAll(c => c.ExpandedTargetTypes.Count == 0 && capabilitiesWithExpandedTargets.Contains(c));

static bool IsDirectTarget(AtsCapabilityInfo capability, string targetTypeId)
{
return string.Equals(capability.TargetTypeId, targetTypeId, StringComparison.Ordinal);
}
}

/// <summary>
Expand Down
66 changes: 66 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Foundry;
using Aspire.Hosting.Utils;
using Azure.Provisioning.CognitiveServices;
using Microsoft.AI.Foundry.Local;
using Microsoft.Extensions.DependencyInjection;

Expand Down Expand Up @@ -208,6 +209,65 @@ public void AddProject_AddsDefaultContainerRegistryInRunMode()
Assert.Same(registry, project.Resource.ContainerRegistry);
}

[Fact]
public async Task AddProject_GeneratesDefaultRoleAssignmentOnParentFoundry()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);

var foundry = builder.AddFoundry("foundry");
var project = foundry.AddProject("project");

using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();

await VerifyProjectBicepAsync(model, project.Resource);
}

[Fact]
public async Task AddProject_WithPublishAsExistingFoundry_GeneratesDefaultRoleAssignmentOnParentFoundry()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);

var foundry = builder.AddFoundry("foundry")
.PublishAsExisting("existing-foundry", "existing-rg");
var project = foundry.AddProject("project");

using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();

await VerifyProjectBicepAsync(model, project.Resource);
}

[Fact]
public async Task AddProject_WithRoleAssignments_ReplacesDefaultRoleAssignmentsOnParentFoundry()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);

var foundry = builder.AddFoundry("foundry");
var project = foundry.AddProject("project")
.WithRoleAssignments(foundry, CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser);

using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();

await VerifyProjectBicepAsync(model, project.Resource);
}

[Fact]
public async Task AddProject_WithEmptyRoleAssignments_RemovesDefaultRoleAssignmentsOnParentFoundry()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);

var foundry = builder.AddFoundry("foundry");
var project = foundry.AddProject("project")
.WithRoleAssignments(foundry, Array.Empty<CognitiveServicesBuiltInRole>());

using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();

await VerifyProjectBicepAsync(model, project.Resource);
}

[Fact]
public async Task AddProject_WithPublishAsExistingFoundry_GeneratesBicepThatReferencesExistingParent()
{
Expand Down Expand Up @@ -263,4 +323,10 @@ public void AddAsExistingResource_ShouldBeIdempotent_ForFoundryResource()
Assert.Same(firstResult, secondResult);
}

private static async Task VerifyProjectBicepAsync(DistributedApplicationModel model, AzureCognitiveServicesProjectResource project)
{
var (_, bicepText) = await AzureManifestUtils.GetManifestWithBicep(model, project);

await Verify(bicepText, extension: "bicep");
}
}
17 changes: 17 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/RoleAssignmentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,23 @@ public Task AzureFoundrySupport()
});
}

[Fact]
public Task AzureFoundryProjectDefaultSupport()
{
return RoleAssignmentTest("project",
builder =>
{
var env = builder.CreateResourceBuilder(
builder.Resources.OfType<IComputeEnvironmentResource>().Single(r => r.Name == "env"));
var project = builder.AddFoundry("ai")
.AddProject("project");

builder.AddProject<Project>("api", launchProfileName: null)
.WithReference(project)
.WithComputeEnvironment(env);
});
}

[Fact]
public Task EventHubsSupport()
{
Expand Down
Loading
Loading