Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions src/Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<ItemGroup>
<Compile Include="$(RepoRoot)src\Shared\AzureRoleAssignmentUtils.cs" />
<Compile Include="$(RepoRoot)src\Shared\ContainerRegistryInfrastructure.cs" />
<Compile Include="$(RepoRoot)src\Shared\EnvironmentVariableNameEncoder.cs" Link="Shared\EnvironmentVariableNameEncoder.cs" />
<Compile Include="$(RepoRoot)src\Aspire.Hosting\Utils\FormattingHelpers.cs" Link="Utils\FormattingHelpers.cs" />
<Compile Include="..\Shared\AzureCredentialHelper.cs" Link="AzureCredentialHelper.cs" />
<Compile Remove="tools\**\*.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ private static void AddProjectReferenceForRunMode<T>(
IResourceBuilder<AzureCognitiveServicesProjectResource> project)
where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource
{
builder.WithReference(project);
// Run mode waits for the project to provision so the agent's connection variables resolve before it starts.
AddProjectReference(builder, project, waitForProject: true);

// The default ACR is required for publish-time image push, but in run mode it adds noise to the dashboard.
// When a hosted agent references a Foundry project for local execution, remove the default registry resource.
Expand All @@ -144,6 +145,37 @@ private static void AddProjectReferenceForRunMode<T>(
}
}

// Injects the Foundry project reference into the compute resource so the agent can reach the project
// via the standard Aspire reference environment variables (ConnectionStrings__{name}, {NAME}_URI, ...).
//
// This must produce identical variable names in run and publish mode so an app that reads them locally
// keeps working once deployed. The connection-string variable is named "ConnectionStrings__{connectionName}".
// Foundry validates hosted agent environment variable names against ^[A-Za-z0-9_]+$ at deploy time (see
// HostedAgentConfiguration), so a project name containing '-' — including the auto-generated default
// "{resource.Name}-proj" — would otherwise emit "ConnectionStrings__{name}-proj" and fail deployment.
// Encode the connection name up front so both modes stay symmetric and deploy-safe; for already-valid names
// (e.g. "myproject") this is a no-op.
//
// We deliberately call the general WithReference overload (with an explicit connectionName) rather than the
// Foundry-specific WithReference(project) overload: the latter ignores connectionName and always emits the
// raw resource name. The Foundry overload also implicitly applies WaitFor(project); we reproduce that here
// for run mode via waitForProject, but skip it in publish mode where waiting has no meaning.
private static IResourceBuilder<T> AddProjectReference<T>(
IResourceBuilder<T> builder,
IResourceBuilder<AzureCognitiveServicesProjectResource> project,
bool waitForProject)
where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource
{
builder.WithReference(project, connectionName: EnvironmentVariableNameEncoder.Encode(project.Resource.Name));

if (waitForProject && builder is IResourceBuilder<IResourceWithWaitSupport> waitableBuilder)
{
waitableBuilder.WaitFor(project);
}
Comment thread
maddymontaquila marked this conversation as resolved.
Outdated

return builder;
}

private static IResourceBuilder<AzureCognitiveServicesProjectResource> ResolveProjectBuilderForPublish<T>(IResourceBuilder<T> builder)
where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource
{
Expand Down Expand Up @@ -338,6 +370,13 @@ private static void ConfigurePublishMode<T>(
ContainerRegistry = projectResource.ContainerRegistry
});

// Add project reference to the original builder so that environment variables
// collected from the target resource include the project reference. This is
// needed during environment variable resolution in the deployment phase, and
// mirrors the reference added in run mode so the variables match in both modes.
// WaitFor is omitted in publish mode where it has no meaning.
AddProjectReference(builder, project, waitForProject: false);

builder.ApplicationBuilder.AddResource(hostedAgent)
.WithIconName("Agents")
.WithReferenceRelationship(target)
Expand Down
55 changes: 55 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ public async Task WithComputeEnvironment_ResolvesExternalContainerAppReference()
var environment = Assert.Single(model.Resources.OfType<AzureContainerAppEnvironmentResource>());
environment.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = "example.azurecontainerapps.io";
environment.ProvisioningTaskCompletionSource?.TrySetResult();
SimulateProjectProvisioning(project);

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var environmentVariables = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
Expand Down Expand Up @@ -333,6 +334,8 @@ public async Task WithComputeEnvironment_DoesNotSetReservedFoundryProjectEndpoin
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var hostedAgent = Assert.Single(model.Resources.OfType<AzureHostedAgentResource>());

SimulateProjectProvisioning(project);

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var environmentVariables = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
builder.ExecutionContext,
Expand All @@ -344,6 +347,42 @@ public async Task WithComputeEnvironment_DoesNotSetReservedFoundryProjectEndpoin
Assert.DoesNotContain("FOUNDRY_PROJECT_ENDPOINT", environmentVariables.Keys);
}

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

// A project name containing '-' would otherwise emit "ConnectionStrings__my-project", which fails
// Foundry's deploy-time env var name validation (^[A-Za-z0-9_]+$).
var project = builder.AddFoundry("account")
.AddProject("my-project");

var advisorAgent = builder.AddProject<Project>("advisor-agent", launchProfileName: null)
.AsHostedAgent(project);

using var app = builder.Build();
await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);

var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var hostedAgent = Assert.Single(model.Resources.OfType<AzureHostedAgentResource>());

SimulateProjectProvisioning(project);

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var environmentVariables = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
builder.ExecutionContext,
hostedAgent,
advisorAgent.Resource,
NullLogger<FoundryExtensionsTests>.Instance,
cts.Token);

Assert.Contains("ConnectionStrings__my_project", environmentVariables.Keys);
Assert.DoesNotContain("ConnectionStrings__my-project", environmentVariables.Keys);

// Every resolved key must satisfy the deploy-time validation regex, otherwise deployment throws.
Assert.All(environmentVariables.Keys, key => Assert.Matches("^[A-Za-z0-9_]+$", key));
}

[Fact]
public async Task WithComputeEnvironment_ResolvesReferenceExpressionEnvironmentVariable()
{
Expand Down Expand Up @@ -372,6 +411,7 @@ public async Task WithComputeEnvironment_ResolvesReferenceExpressionEnvironmentV
var environment = Assert.Single(model.Resources.OfType<AzureContainerAppEnvironmentResource>());
environment.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = "example.azurecontainerapps.io";
environment.ProvisioningTaskCompletionSource?.TrySetResult();
SimulateProjectProvisioning(project);

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var environmentVariables = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
Expand Down Expand Up @@ -412,6 +452,7 @@ public async Task WithComputeEnvironment_ResolvesEndpointReferenceExpressionEnvi
var environment = Assert.Single(model.Resources.OfType<AzureContainerAppEnvironmentResource>());
environment.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = "example.azurecontainerapps.io";
environment.ProvisioningTaskCompletionSource?.TrySetResult();
SimulateProjectProvisioning(project);

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var environmentVariables = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
Expand Down Expand Up @@ -449,6 +490,8 @@ public async Task WithComputeEnvironment_ThrowsForInternalContainerAppReference(
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var hostedAgent = Assert.Single(model.Resources.OfType<AzureHostedAgentResource>());

SimulateProjectProvisioning(project);

var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
builder.ExecutionContext,
Expand All @@ -467,4 +510,16 @@ private sealed class Project : IProjectMetadata
public string ProjectPath => "project";
}

// GetResolvedEnvironmentVariablesAsync is a deploy-time path that awaits Azure provisioning for any
// referenced resource. After a hosted agent references its Foundry project (run/publish parity), the
// agent's resolved environment includes the project connection, which is backed by bicep outputs.
// Simulate provisioning here by publishing the referenced outputs and completing the provisioning task;
// otherwise resolution blocks until cancellation.
private static void SimulateProjectProvisioning(IResourceBuilder<AzureCognitiveServicesProjectResource> project)
{
project.Resource.Outputs["endpoint"] = "https://account.services.ai.azure.com/api/projects/my-project";
project.Resource.Outputs["APPLICATION_INSIGHTS_CONNECTION_STRING"] = "InstrumentationKey=00000000-0000-0000-0000-000000000000";
project.Resource.ProvisioningTaskCompletionSource?.TrySetResult();
}

}
55 changes: 55 additions & 0 deletions tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;

namespace Aspire.Hosting.Foundry.Tests;

Expand Down Expand Up @@ -280,6 +281,60 @@ public async Task FoundryProject_DefaultRegistryDoesNotAddGlobalRegistryTargets(
Assert.Same(registry.Resource, registryTarget.Registry);
}

[Fact]
public async Task AsHostedAgent_InPublishMode_WithProject_InjectsProjectConnectionEnvironmentVariables()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var project = builder.AddFoundry("account")
.AddProject("myproject");

var app = builder.AddPythonApp("agent", "./app.py", "main:app")
.AsHostedAgent(project);

using var built = builder.Build();
await ExecuteBeforeStartHooksAsync(built, default);

// The original app resource should have a reference to the project so that
// its environment variables include the project connection. In publish mode the
// hosted agent resolves env vars from its target (the original/swapped resource),
// so the reference must live on the original builder, mirroring run mode.
Assert.True(app.Resource.TryGetAnnotationsOfType<ResourceRelationshipAnnotation>(out var relationships));
Assert.Contains(
relationships,
r => r.Type == "Reference" && ReferenceEquals(r.Resource, project.Resource));

var model = built.Services.GetRequiredService<DistributedApplicationModel>();
var hostedAgent = Assert.Single(model.Resources.OfType<AzureHostedAgentResource>());

// GetResolvedEnvironmentVariablesAsync is a deploy-time path: the project connection
// values are backed by bicep outputs that BicepOutputReference.GetValueAsync awaits via
// ProvisioningTaskCompletionSource. In a real deploy provisioning has already completed,
// but a unit test never provisions, so simulate it by publishing the referenced outputs
// (the project endpoint and app insights connection string) and completing provisioning.
project.Resource.Outputs["endpoint"] = "https://account.services.ai.azure.com/api/projects/myproject";
project.Resource.Outputs["APPLICATION_INSIGHTS_CONNECTION_STRING"] = "InstrumentationKey=00000000-0000-0000-0000-000000000000";
project.Resource.ProvisioningTaskCompletionSource?.TrySetResult();

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var environmentVariables = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
builder.ExecutionContext,
hostedAgent,
hostedAgent.Target,
NullLogger<HostedAgentExtensionTests>.Instance,
cts.Token);

// WithReference(project) injects the project endpoint the agent needs to reach Foundry.
// The deployed samples consume these keys (e.g. main.py reads "<NAME>_URI" and the
// .NET sample reads "ConnectionStrings__<name>").
Assert.Contains("MYPROJECT_URI", environmentVariables.Keys);
Assert.Contains("ConnectionStrings__myproject", environmentVariables.Keys);

// FOUNDRY_PROJECT_ENDPOINT is reserved for the Foundry hosted-agent platform to inject
// at runtime; Aspire must not set it. See WithComputeEnvironment_DoesNotSet... in the
// Azure test suite for the companion guard.
Assert.DoesNotContain("FOUNDRY_PROJECT_ENDPOINT", environmentVariables.Keys);
}

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")]
private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken);
}
Loading