File: FoundryDotnetProjectDeploymentTests.cs
Web Access
Project: src\tests\Aspire.Deployment.EndToEnd.Tests\Aspire.Deployment.EndToEnd.Tests.csproj (Aspire.Deployment.EndToEnd.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable OPENAI001
#pragma warning disable AAIP001
 
using System.ClientModel;
using System.Net.Http.Headers;
using System.Text.Json;
using Aspire.Deployment.EndToEnd.Tests.Helpers;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Deployment.EndToEnd.Tests;
 
public sealed class FoundryDotnetProjectDeploymentTests(ITestOutputHelper output)
{
    [Fact]
    public async Task DeployEchoDotnetProjectAsFoundryHostedAgent()
    {
        var subscriptionId = DotnetProjectDeploymentHelpers.GetSubscriptionId();
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        cts.CancelAfter(TimeSpan.FromMinutes(55));
        using var workspace = TemporaryWorkspace.Create(output);
        var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("foundry-echo-v2");
        var startTime = DateTime.UtcNow;
        const string projectName = "FoundryEcho";
        const string agentName = "echo-ha";
        var marker = Guid.NewGuid().ToString("N");
 
        try
        {
            using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
            var pendingRun = terminal.RunAsync(cts.Token);
            var counter = new SequenceCounter();
            var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
            await auto.PrepareEnvironmentAsync(workspace, counter);
            await auto.InstallCurrentBuildAspireBundleAsync(counter, output);
            await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.EmptyAppHost);
            await auto.RunCommandAsync($"cd {projectName}", counter);
            await DotnetProjectDeploymentHelpers.AddPackageAsync(auto, counter, "Aspire.Hosting.Foundry");
            await DotnetProjectDeploymentHelpers.AddPackageAsync(auto, counter, "Aspire.Hosting.Dotnet");
 
            var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
            var appHostFile = Path.Combine(projectDir, "apphost.cs");
            var directives = string.Join(Environment.NewLine,
                File.ReadLines(appHostFile).Where(line => line.StartsWith("#:", StringComparison.Ordinal)));
            File.WriteAllText(appHostFile, $$"""
                {{directives}}
                #pragma warning disable ASPIREDOTNETPROJECT001
                using Aspire.Hosting.Foundry;
 
                var builder = DistributedApplication.CreateBuilder(args);
                var project = builder.AddFoundry("foundry").AddProject("project");
                builder.AddDotnetProject("echo", Path.Combine("EchoAgent", "EchoAgent.csproj"))
                    .AsHostedAgent(project, HostedAgentProtocol.Responses, "2.0.0");
                builder.Build().Run();
                """);
            FoundryEchoTestApp.Write(Path.Combine(projectDir, "EchoAgent"), marker);
 
            await auto.RunCommandAsync(
                $"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=swedencentral AZURE__RESOURCEGROUP={resourceGroupName} AZURE__SUBSCRIPTIONID={subscriptionId}" +
                " && export AZURE__TENANTID=$(az account show --query tenantId -o tsv)", counter);
            await auto.TypeAsync("aspire deploy --clear-cache");
            await auto.EnterAsync();
            await auto.WaitForPipelineSuccessAsync(TimeSpan.FromMinutes(35), counter: counter);
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            var endpoint = await GetProjectEndpointAsync(subscriptionId, resourceGroupName, cts.Token);
            var client = new AIProjectClient(endpoint, AzureAuthenticationHelpers.GetAzureCredential());
            var versions = new List<ProjectsAgentVersion>();
            await foreach (var version in client.AgentAdministrationClient.GetAgentVersionsAsync(agentName, cancellationToken: cts.Token))
            {
                versions.Add(version);
            }
 
            // This is a new resource group and model-free project: precisely one version must have
            // been produced by this deploy, and it must reference the newly published registry image.
            var deployedVersion = Assert.Single(versions);
            Assert.Equal(agentName, deployedVersion.Name);
            Assert.True(deployedVersion.CreatedAt >= new DateTimeOffset(startTime).AddMinutes(-5),
                "The hosted version must have been created during this deployment.");
            var definition = Assert.IsType<HostedAgentDefinition>(deployedVersion.Definition);
            var container = Assert.IsType<ContainerConfiguration>(definition.ContainerConfiguration);
            var image = container.Image;
            Assert.Contains(".azurecr.io/", image, StringComparison.Ordinal);
            Assert.Collection(definition.ProtocolVersions, protocol =>
            {
                Assert.Equal(ProjectsAgentProtocol.Responses, protocol.Protocol);
                Assert.Equal("2.0.0", protocol.Version);
            });
            var agent = await client.AgentAdministrationClient.GetAgentAsync(agentName, cancellationToken: cts.Token);
            Assert.NotNull(agent.Value.AgentEndpoint);
            Assert.Contains(AgentEndpointProtocol.Responses, agent.Value.AgentEndpoint.Protocols);
            output.WriteLine($"Hosted agent {agentName}, version {deployedVersion.Version}, image {image}");
 
            await DotnetProjectDeploymentHelpers.RunScriptAsync(auto, counter, $$"""
                set -euo pipefail
                az() { command az "$@" --subscription {{subscriptionId}}; }
                image={{AspireCliShellCommandHelpers.QuoteBashArg(image)}}
                registry=$(az acr list -g {{resourceGroupName}} --query '[0].name' -o tsv)
                login=$(az acr show -g {{resourceGroupName}} -n "$registry" --query loginServer -o tsv)
                [ "${image%%/*}" = "$login" ]
                digest=$(az acr manifest show-metadata -r "$registry" -n "${image#*/}" --query digest -o tsv)
                case "$digest" in sha256:*) ;; *) exit 1;; esac
                echo "Published echo image: $image ($digest)"
                """, TimeSpan.FromMinutes(3));
 
            var nonce = $"{marker}:{Guid.NewGuid():N}";
            // The new agent has exactly one version, so its default route selects this deployment.
            // Invoke that hosted agent, not a prompt agent or a dashboard health URL.
            // Hosting can cold-start after version creation. The bounded retry is not a fallback
            // to another route: incorrect protocol/auth/routing must fail the deployment scenario.
            var responses = client.ProjectOpenAIClient.GetProjectResponsesClientForAgent(
                new AgentReference(name: agentName));
            string? lastFailure = null;
            var succeeded = false;
            for (var attempt = 0; attempt < 18; attempt++)
            {
                using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
                requestCts.CancelAfter(TimeSpan.FromSeconds(30));
                try
                {
                    var response = await responses.CreateResponseAsync(nonce, cancellationToken: requestCts.Token);
                    Assert.Equal($"Echo: {nonce}", response.Value.GetOutputText());
                    succeeded = true;
                    break;
                }
                catch (Exception ex) when (ex is ClientResultException || ex is OperationCanceledException && !cts.IsCancellationRequested)
                {
                    lastFailure = ex.Message;
                }
 
                await Task.Delay(TimeSpan.FromSeconds(10), cts.Token);
            }
 
            Assert.True(succeeded, $"The deployed hosted version did not answer its authenticated Responses request: {lastFailure}");
            await auto.TypeAsync("exit");
            await auto.EnterAsync();
            await pendingRun;
            DeploymentReporter.ReportDeploymentSuccess(nameof(DeployEchoDotnetProjectAsFoundryHostedAgent),
                resourceGroupName, new Dictionary<string, string> { [agentName] = endpoint.ToString() }, DateTime.UtcNow - startTime);
        }
        catch (Exception ex)
        {
            DeploymentReporter.ReportDeploymentFailure(nameof(DeployEchoDotnetProjectAsFoundryHostedAgent),
                resourceGroupName, ex.Message, ex.StackTrace);
            throw;
        }
        finally
        {
            await DotnetProjectDeploymentHelpers.CleanupAsync(resourceGroupName, subscriptionId, output);
        }
    }
 
    private static async Task<Uri> GetProjectEndpointAsync(string subscriptionId, string resourceGroupName, CancellationToken cancellationToken)
    {
        var token = await AzureAuthenticationHelpers.GetAzureCredential().GetTokenAsync(
            new TokenRequestContext(["https://management.azure.com/.default"]), cancellationToken);
        using var http = new HttpClient();
        http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
        using var resourcesResponse = await http.GetAsync(
            $"https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/resources?api-version=2021-04-01", cancellationToken);
        resourcesResponse.EnsureSuccessStatusCode();
        using var resources = JsonDocument.Parse(await resourcesResponse.Content.ReadAsStringAsync(cancellationToken));
        var project = resources.RootElement.GetProperty("value").EnumerateArray().Single(resource =>
            string.Equals(resource.GetProperty("type").GetString(), "Microsoft.CognitiveServices/accounts/projects", StringComparison.OrdinalIgnoreCase));
        var id = project.GetProperty("id").GetString();
        using var projectResponse = await http.GetAsync($"https://management.azure.com{id}?api-version=2025-06-01", cancellationToken);
        projectResponse.EnsureSuccessStatusCode();
        using var projectDocument = JsonDocument.Parse(await projectResponse.Content.ReadAsStringAsync(cancellationToken));
        return new Uri(projectDocument.RootElement.GetProperty("properties").GetProperty("endpoints").GetProperty("AI Foundry API").GetString()
            ?? throw new InvalidOperationException("The deployed Foundry project has no endpoint."));
    }
}