|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Buffers.Text;
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Aspire.Hosting.Pipelines;
using Azure.Provisioning;
using Azure.Provisioning.CognitiveServices;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Aspire.Hosting.Foundry;
/// <summary>
/// The Azure Cognitive Services project resource that can be used for Microsoft Foundry AI agents.
///
/// This also functions as an Aspire compute environment resource for deployment.
/// </summary>
public class AzureCognitiveServicesProjectResource :
AzureProvisionableAspireResourceWithParent<CognitiveServicesProject, FoundryResource>,
IResourceWithConnectionString,
IAzureComputeEnvironmentResource
{
/// <summary>
/// Creates a new Azure Cognitive Services project resource.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="configureInfrastructure">Configures the underlying Azure resource using Azure.Provisioning.</param>
/// <param name="parent">The parent Microsoft Foundry resource.</param>
public AzureCognitiveServicesProjectResource([ResourceName] string name, Action<AzureResourceInfrastructure> configureInfrastructure, FoundryResource parent)
: base(name, configureInfrastructure, parent)
{
// This is a bit long, but this sets up the necessary pipeline steps to
// deploy hosted agents into this project. At a high level, that sequence is:
// 1. (after project provisioning) Login to ACR (if needed)
// 2. (for each compute resource) Build and push container image to ACR if needed
// 3. For deployment target agent, deploy agent to project
// 4. Mark deployment done
Annotations.Add(new PipelineStepAnnotation(async (factoryContext) =>
{
var model = factoryContext.PipelineContext.Model;
var steps = new List<PipelineStep>();
var computeUrls = new PipelineStep
{
Name = $"compute-endpoints-{name}",
Action = async (context) =>
{
var configuration = context.Services.GetRequiredService<IConfiguration>();
var subscriptionId = configuration["Azure:SubscriptionId"];
var resourceGroupName = configuration["Azure:ResourceGroup"];
if (string.IsNullOrEmpty(subscriptionId) || string.IsNullOrEmpty(resourceGroupName))
{
return;
}
var encodedSubscriptionId = EncodeSubscriptionId(subscriptionId);
// Print dashboard URL
await context.ReportingStep.CompleteAsync(
$"https://ai.azure.com/nextgen/r/{encodedSubscriptionId},{resourceGroupName},,{Parent.Name},{Name}/home").ConfigureAwait(false);
},
Resource = this,
DependsOnSteps = [AzureEnvironmentResource.ProvisionInfrastructureStepName],
RequiredBySteps = [WellKnownPipelineSteps.Deploy]
};
steps.Add(computeUrls);
return steps;
}));
}
/// <inheritdoc/>
public override void SetName(CognitiveServicesProject provisionableResource, BicepValue<string> name)
{
provisionableResource.Name = name;
}
/// <inheritdoc/>
public override CognitiveServicesProject FromExisting(string bicepIdentifier)
{
return CognitiveServicesProject.FromExisting(bicepIdentifier);
}
/// <summary>
/// Azure Resource ID output reference.
/// </summary>
public BicepOutputReference Id => new("id", this);
/// <summary>
/// Gets the .NET-style connection string for the project API endpoint.
/// </summary>
public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"Endpoint={Endpoint}");
/// <summary>
/// Gets the Microsoft Foundry project endpoint as a ReferenceExpression that can be used in environment variables or connection strings.
///
/// This will be used to instantiate the AI project clients.
///
/// Will be of the format https:/{accountName}.services.ai.azure.com/api/projects/{projectName}?api-version={apiVersion}
/// </summary>
public ReferenceExpression UriExpression => ReferenceExpression.Create($"{Endpoint}");
/// <summary>
/// Gets the "endpoint" output reference from the Azure Cognitive Services project resource.
///
/// This will be used to instantiate the AI project clients.
///
/// Will be of the format https:/{accountName}.services.ai.azure.com/api/projects/{projectName}?api-version={apiVersion}
/// </summary>
public BicepOutputReference Endpoint => new("endpoint", this);
IEnumerable<KeyValuePair<string, ReferenceExpression>> IResourceWithConnectionString.GetConnectionProperties()
{
yield return new("Uri", UriExpression);
// Without this, `.WithReference(project)` causes an error like "waiting for connection string for resource {projectName}"
yield return new("ConnectionString", ConnectionStringExpression);
yield return new("ApplicationInsightsConnectionString", ReferenceExpression.Create($"{AppInsightsConnectionString}"));
}
/// <summary>
/// Gets the managed identity principal ID, whether system-assigned or user-assigned.
/// </summary>
public BicepOutputReference PrincipalId => new("principalId", this);
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
internal BicepOutputReference ContainerRegistryManagedIdentityId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", this);
IAzureContainerRegistryResource? IAzureComputeEnvironmentResource.ContainerRegistry => ContainerRegistry;
/// <summary>
/// Gets the Azure Container Registry resource used by this project.
/// </summary>
public AzureContainerRegistryResource? ContainerRegistry
{
get
{
var registry = GetContainerRegistry();
if (registry is null)
{
return null;
}
if (registry is not AzureContainerRegistryResource azureRegistry)
{
throw new InvalidOperationException(
$"The container registry configured for the Azure Cognitive Services project '{Name}' is not an Azure Container Registry. " +
$"Only Azure Container Registry resources are supported. Use '.WithContainerRegistry()' to configure an Azure Container Registry.");
}
return azureRegistry;
}
}
private IContainerRegistry? GetContainerRegistry()
{
if (this.TryGetLastAnnotation<ContainerRegistryReferenceAnnotation>(out var annotation))
{
return annotation.Registry;
}
return DefaultContainerRegistry;
}
/// <summary>
/// The Application Insights resource associated with this project, if any.
///
/// This will be used as the destination for server-side telemetry from hosted
/// agents and optionally for client-side telemetry if the application decides
/// to use it.
/// </summary>
public AzureApplicationInsightsResource? AppInsights { get; set; }
internal BicepOutputReference AppInsightsConnectionString => new("APPLICATION_INSIGHTS_CONNECTION_STRING", this);
/// <summary>
/// Connection to the user-assigned key vault, if any.
/// </summary>
public AzureCognitiveServicesProjectConnectionResource? KeyVaultConn { get; set; }
/// <summary>
/// The capability host resources associated with this project, if any
/// </summary>
public CapabilityHostConfiguration? CapabilityHostConfiguration { get; set; }
internal AzureContainerRegistryResource? DefaultContainerRegistry { get; set; }
/// <summary>
/// Get the address for the particular agent's endpoint.
/// </summary>
ReferenceExpression IComputeEnvironmentResource.GetHostAddressExpression(EndpointReference endpointReference)
{
var resource = endpointReference.Resource;
return ReferenceExpression.Create($"{Endpoint}/agents/{resource.Name}");
}
/// <summary>
/// This is the encoding that the Microsoft Foundry web portal uses in their URLs, for some reason
/// </summary>
internal static string EncodeSubscriptionId(string subscriptionId)
{
var guid = Guid.Parse(subscriptionId);
var encoded = Base64Url.EncodeToString(guid.ToByteArray());
return encoded.TrimEnd('=');
}
/// <summary>
/// Tries to get the application identity resource associated with this project via
/// the <see cref="AppIdentityAnnotation"/>. This is the identity that will be
/// used by the project to access other Azure resources such as the container registry.
/// </summary>
public bool TryGetAppIdentityResource([NotNullWhen(true)] out IAppIdentityResource? identity)
{
if (this.TryGetLastAnnotation<AppIdentityAnnotation>(out var identityAnnotation))
{
identity = identityAnnotation.IdentityResource;
return true;
}
else
{
identity = null;
return false;
}
}
}
/// <summary>
/// Configuration for an Azure Cognitive Services capability host.
/// </summary>
/// <param name="name">The name of the capability host.</param>
public class CapabilityHostConfiguration(string name)
{
/// <summary>
/// The name of the capability host.
/// </summary>
public string Name { get; set; } = name;
/// <summary>
/// The kind of capability host.
/// </summary>
public CapabilityHostKind Kind { get; set; } = CapabilityHostKind.Agents;
/// <summary>
/// The Cosmos DB resource to use for metadata and conversation state storage.
/// </summary>
public AzureCosmosDBResource? CosmosDB { get; set; }
/// <summary>
/// The Storage resource to use for file storage.
/// </summary>
public AzureStorageResource? Storage { get; set; }
/// <summary>
/// The Azure Search resource to use for vector search capabilities.
/// </summary>
public AzureSearchResource? Search { get; set; }
/// <summary>
/// An OpenAI-type Microsoft Foundry account to use for AI model calls, if any.
/// </summary>
public FoundryResource? AzureOpenAI { get; set; }
internal void Validate(string projectName)
{
if (CosmosDB is null)
{
throw new InvalidOperationException($"Capability host '{Name}' on project '{projectName}' requires a CosmosDB resource. Call WithCosmosDB() on the capability host builder.");
}
if (Storage is null)
{
throw new InvalidOperationException($"Capability host '{Name}' on project '{projectName}' requires a Storage resource. Call WithStorage() on the capability host builder.");
}
if (Search is null)
{
throw new InvalidOperationException($"Capability host '{Name}' on project '{projectName}' requires a Search resource. Call WithSearch() on the capability host builder.");
}
}
}
/// <summary>
/// A fluent builder for configuring a capability host on an Azure Cognitive Services project.
/// </summary>
public class CapabilityHostBuilder(IResourceBuilder<AzureCognitiveServicesProjectResource> projectBuilder, CapabilityHostConfiguration configuration)
{
/// <summary>
/// Gets the underlying project resource builder.
/// </summary>
public IResourceBuilder<AzureCognitiveServicesProjectResource> ProjectBuilder { get; } = projectBuilder;
/// <summary>
/// Configures the Cosmos DB resource for metadata and conversation state storage.
/// </summary>
/// <param name="cosmosDb">The Cosmos DB resource builder.</param>
/// <returns>The capability host builder for chaining.</returns>
public CapabilityHostBuilder WithCosmosDB(IResourceBuilder<AzureCosmosDBResource> cosmosDb)
{
ArgumentNullException.ThrowIfNull(cosmosDb);
configuration.CosmosDB = cosmosDb.Resource;
return this;
}
/// <summary>
/// Configures the Storage resource for file storage.
/// </summary>
/// <param name="storage">The Storage resource builder.</param>
/// <returns>The capability host builder for chaining.</returns>
public CapabilityHostBuilder WithStorage(IResourceBuilder<AzureStorageResource> storage)
{
ArgumentNullException.ThrowIfNull(storage);
configuration.Storage = storage.Resource;
return this;
}
/// <summary>
/// Configures the Azure Search resource for vector search capabilities.
/// </summary>
/// <param name="search">The Search resource builder.</param>
/// <returns>The capability host builder for chaining.</returns>
public CapabilityHostBuilder WithSearch(IResourceBuilder<AzureSearchResource> search)
{
ArgumentNullException.ThrowIfNull(search);
configuration.Search = search.Resource;
return this;
}
/// <summary>
/// Configures an Azure OpenAI-type Microsoft Foundry account for AI model calls.
/// </summary>
/// <param name="openAI">The Microsoft Foundry resource builder.</param>
/// <returns>The capability host builder for chaining.</returns>
public CapabilityHostBuilder WithAzureOpenAI(IResourceBuilder<FoundryResource> openAI)
{
ArgumentNullException.ThrowIfNull(openAI);
configuration.AzureOpenAI = openAI.Resource;
return this;
}
}
|