diff --git a/Aspire.sln b/Aspire.sln index 6ac4b364f7..8316402667 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -631,6 +631,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Sdk.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.RuntimeIdentifier.Tool", "src\Aspire.Hosting.Sdk\Aspire.RuntimeIdentifier.Tool\Aspire.RuntimeIdentifier.Tool.csproj", "{FF2895FC-A613-43DC-96B8-E5DFA69125CA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Azure.ContainerApps", "src\Aspire.Hosting.Azure.ContainerApps\Aspire.Hosting.Azure.ContainerApps.csproj", "{21FBDB19-0B8B-4F0F-8ED6-98560AD5DDA7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1661,6 +1663,10 @@ Global {FF2895FC-A613-43DC-96B8-E5DFA69125CA}.Debug|Any CPU.Build.0 = Debug|Any CPU {FF2895FC-A613-43DC-96B8-E5DFA69125CA}.Release|Any CPU.ActiveCfg = Release|Any CPU {FF2895FC-A613-43DC-96B8-E5DFA69125CA}.Release|Any CPU.Build.0 = Release|Any CPU + {21FBDB19-0B8B-4F0F-8ED6-98560AD5DDA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21FBDB19-0B8B-4F0F-8ED6-98560AD5DDA7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21FBDB19-0B8B-4F0F-8ED6-98560AD5DDA7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21FBDB19-0B8B-4F0F-8ED6-98560AD5DDA7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1965,6 +1971,7 @@ Global {F21F921E-1930-4BD5-B665-5EF1B82BD4D2} = {830A89EC-4029-4753-B25A-068BAE37DEC7} {AEF07BC6-76D8-4A45-89D5-54FC4483863F} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {FF2895FC-A613-43DC-96B8-E5DFA69125CA} = {F534D4F8-5E3A-42FC-BCD7-4C2D6060F9C8} + {21FBDB19-0B8B-4F0F-8ED6-98560AD5DDA7} = {77CFE74A-32EE-400C-8930-5025E8555256} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6DCEDFEC-988E-4CB3-B45B-191EB5086E0C} diff --git a/Directory.Packages.props b/Directory.Packages.props index 62f8c693d6..41c4591ffc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -42,6 +42,8 @@ + + diff --git a/src/Aspire.Hosting.Azure.ContainerApps/Aspire.Hosting.Azure.ContainerApps.csproj b/src/Aspire.Hosting.Azure.ContainerApps/Aspire.Hosting.Azure.ContainerApps.csproj new file mode 100644 index 0000000000..7a96d285fb --- /dev/null +++ b/src/Aspire.Hosting.Azure.ContainerApps/Aspire.Hosting.Azure.ContainerApps.csproj @@ -0,0 +1,21 @@ + + + + $(DefaultTargetFramework) + true + aspire hosting azure + Azure container apps resource types for .NET Aspire. + true + $(SharedDir)Azure_256x.png + + + + 0 + + + + + + + + diff --git a/src/Aspire.Hosting.Azure.ContainerApps/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.ContainerApps/AzureContainerAppExtensions.cs new file mode 100644 index 0000000000..11916814a8 --- /dev/null +++ b/src/Aspire.Hosting.Azure.ContainerApps/AzureContainerAppExtensions.cs @@ -0,0 +1,74 @@ +#pragma warning disable AZPROVISION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +// 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.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using Azure.Provisioning.AppContainers; + +namespace Aspire.Hosting.Azure; + +/// +/// +/// +public static class AzureContainerAppExtensions +{ + /// + /// + /// + /// + /// + public static IDistributedApplicationBuilder AddContainerAppsInfrastructure(this IDistributedApplicationBuilder builder) + { + builder.Services.TryAddLifecycleHook(); + + return builder; + } + + /// + /// + /// + /// + /// + /// + public static IResourceBuilder PublishAsContainerApp(this IResourceBuilder project, Action configure) + { + if (!project.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return project; + } + + project.ApplicationBuilder.AddContainerAppsInfrastructure(); + + project.WithAnnotation(new ContainerAppCustomizationAnnotation(configure)); + + return project; + } + + /// + /// + /// + /// + /// + /// + /// + public static IResourceBuilder PublishAsContainerApp(this IResourceBuilder container, Action configure) where T : ContainerResource + { + if (!container.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return container; + } + + container.ApplicationBuilder.AddContainerAppsInfrastructure(); + + container.WithAnnotation(new ContainerAppCustomizationAnnotation(configure)); + + return container; + } +} + +internal sealed class ContainerAppCustomizationAnnotation(Action configure) : IResourceAnnotation +{ + public Action Configure { get; } = configure; +} diff --git a/src/Aspire.Hosting.Azure.ContainerApps/AzureContanierAppsInfrastructure.cs b/src/Aspire.Hosting.Azure.ContainerApps/AzureContanierAppsInfrastructure.cs new file mode 100644 index 0000000000..acdf21ff86 --- /dev/null +++ b/src/Aspire.Hosting.Azure.ContainerApps/AzureContanierAppsInfrastructure.cs @@ -0,0 +1,999 @@ +#pragma warning disable AZPROVISION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using Azure.Provisioning; +using Azure.Provisioning.AppContainers; +using Azure.Provisioning.Expressions; +using Azure.Provisioning.Resources; + +namespace Aspire.Hosting.Azure; + +// Logic to generate compute and networking infrastructure for Azure Container Apps +// based deployments. +internal sealed class AzureContainerAppsInfastructure(DistributedApplicationExecutionContext executionContext) : IDistributedApplicationLifecycleHook +{ + public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + if (executionContext.IsRunMode) + { + return; + } + + var containerAppEnviromentContext = new ContainerAppEnviromentContext( + AzureContainerAppsEnvironment.AZURE_CONTAINER_APPS_ENVIRONMENT_ID, + AzureContainerAppsEnvironment.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN, + AzureContainerAppsEnvironment.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID, + AzureContainerAppsEnvironment.AZURE_CONTAINER_REGISTRY_ENDPOINT, + AzureContainerAppsEnvironment.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID, + AzureContainerAppsEnvironment.MANAGED_IDENTITY_CLIENT_ID); + + foreach (var r in appModel.Resources) + { + if (r.TryGetLastAnnotation(out var lastAnnotation) && lastAnnotation == ManifestPublishingCallbackAnnotation.Ignore) + { + continue; + } + + if (!r.IsContainer() && r is not ProjectResource) + { + continue; + } + + var containerApp = await containerAppEnviromentContext.CreateContainerAppAsync(r, executionContext, cancellationToken).ConfigureAwait(false); + + r.Annotations.Add(new DeploymentTargetAnnotation(containerApp)); + } + } + + private sealed class ContainerAppEnviromentContext( + IManifestExpressionProvider containerAppEnvironmentId, + IManifestExpressionProvider containerAppDomain, + IManifestExpressionProvider managedIdentityId, + IManifestExpressionProvider containerRegistryUrl, + IManifestExpressionProvider containerRegistryManagedIdentityId, + IManifestExpressionProvider clientId + ) + { + private IManifestExpressionProvider ContainerAppEnvironmentId => containerAppEnvironmentId; + private IManifestExpressionProvider ContainerAppDomain => containerAppDomain; + private IManifestExpressionProvider ManagedIdentityId => managedIdentityId; + private IManifestExpressionProvider ContainerRegistryUrl => containerRegistryUrl; + private IManifestExpressionProvider ContainerRegistryManagedIdentityId => containerRegistryManagedIdentityId; + private IManifestExpressionProvider ClientId => clientId; + + private readonly Dictionary _containerApps = []; + + public async Task CreateContainerAppAsync(IResource resource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + var context = await ProcessResourceAsync(resource, executionContext, cancellationToken).ConfigureAwait(false); + + var construct = new AzureConstructResource(resource.Name, context.BuildContainerApp); + + construct.Annotations.Add(new ManifestPublishingCallbackAnnotation(construct.WriteToManifest)); + + return construct; + } + + private async Task ProcessResourceAsync(IResource resource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + if (!_containerApps.TryGetValue(resource, out var context)) + { + _containerApps[resource] = context = new ContainerAppContext(resource, this); + await context.ProcessResourceAsync(executionContext, cancellationToken).ConfigureAwait(false); + } + + return context; + } + + private sealed class ContainerAppContext(IResource resource, ContainerAppEnviromentContext containerAppEnviromentContext) + { + private readonly Dictionary _allocatedParameters = []; + private readonly Dictionary _bicepParameters = []; + private readonly ContainerAppEnviromentContext _containerAppEnviromentContext = containerAppEnviromentContext; + + record struct EndpointMapping(string Scheme, string Host, int Port, int? TargetPort, bool IsHttpIngress, bool External); + + private readonly Dictionary _endpointMapping = []; + + private (int? Port, bool Http2, bool External)? _httpIngress; + private readonly List _additionalPorts = []; + + private BicepParameter? _managedIdentityIdParameter; + private BicepParameter? _containerRegistryUrlParameter; + private BicepParameter? _containerRegistryManagedIdentityIdParameter; + + public IResource Resource => resource; + + // Set the parameters to add to the bicep file + public Dictionary Parameters { get; } = []; + + public List EnvironmentVariables { get; } = []; + + public List Secrets { get; } = []; + + public List> Args { get; } = []; + + public List<(ContainerAppVolume, ContainerAppVolumeMount)> Volumes { get; } = []; + + public void BuildContainerApp(ResourceModuleConstruct c) + { + var containerAppIdParam = AllocateParameter(_containerAppEnviromentContext.ContainerAppEnvironmentId); + + BicepParameter? containerImageParam = null; + + if (!resource.TryGetContainerImageName(out var containerImageName)) + { + AllocateContainerRegistryParameters(); + + containerImageParam = AllocateContainerImageParameter(); + } + + var containerAppResource = new ContainerApp(resource.Name, "2024-03-01") + { + Name = resource.Name.ToLowerInvariant() + }; + + c.Add(containerAppResource); + + if (containerImageParam is not null) + { + AddManagedIdentites(containerAppResource); + } + + containerAppResource.EnvironmentId = containerAppIdParam; + + var configuration = new ContainerAppConfiguration() + { + ActiveRevisionsMode = ContainerAppActiveRevisionsMode.Single, + }; + containerAppResource.Configuration = configuration; + + AddIngress(configuration); + + AddContainerRegistryParameters(configuration); + AddSecrets(configuration); + + var template = new ContainerAppTemplate(); + containerAppResource.Template = template; + + foreach (var (volume, _) in Volumes) + { + template.Volumes.Add(volume); + } + + template.Scale = new ContainerAppScale() + { + MinReplicas = resource.GetReplicaCount() + }; + + var containerAppContainer = new ContainerAppContainer(); + template.Containers = [containerAppContainer]; + + containerAppContainer.Image = containerImageParam is null ? containerImageName : containerImageParam; + containerAppContainer.Name = resource.Name; + + AddEnvironmentVariablesAndCommandLineArgs(containerAppContainer); + + foreach (var (_, mountedVolume) in Volumes) + { + containerAppContainer.VolumeMounts.Add(mountedVolume); + } + + foreach (var (key, value) in Parameters) + { + c.Resource.Parameters[key] = value; + } + + if (resource.TryGetAnnotationsOfType(out var annotations)) + { + foreach (var a in annotations) + { + a.Configure(c, containerAppResource); + } + } + } + + public async Task ProcessResourceAsync(DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + ProcessEndpoints(); + + await ProcessEnvironmentAsync(executionContext, cancellationToken).ConfigureAwait(false); + await ProcessArgumentsAsync(executionContext, cancellationToken).ConfigureAwait(false); + ProcessVolumes(); + } + + private void ProcessEndpoints() + { + if (!resource.TryGetEndpoints(out var endpoints) || !endpoints.Any()) + { + return; + } + + // Only http, https, and tcp are supported + if (endpoints.Any(e => e.UriScheme is not ("tcp" or "http" or "https"))) + { + throw new NotSupportedException("Supported endpoints are http, https, and tcp"); + } + + // We can allocate ports per endpoint + var portAllocator = new PortAllocator(10000); + + var endpointIndexMap = new Dictionary(); + + // This is used to determine if an endpoint should be treated as the Default endpoint. + // Endpoints can come from 3 different sources (in this order): + // 1. Kestrel configuration + // 2. Default endpoints added by the framework + // 3. Explicitly added endpoints + // But wherever they come from, we treat the first one as Default, for each scheme. + var httpSchemesEncountered = new HashSet(); + + static bool IsHttpScheme(string scheme) => scheme is "http" or "https"; + + // Allocate ports for the endpoints + foreach (var endpoint in endpoints) + { + endpointIndexMap[endpoint.Name] = endpointIndexMap.Count; + + int? targetPort = (resource, endpoint.UriScheme, endpoint.TargetPort, endpoint.Port) switch + { + // The port was specified so use it + (_, _, int target, _) => target, + + // Container resources get their default listening port from the exposed port. + (ContainerResource, _, null, int port) => port, + + // Check whether the project view this endpoint as Default (for its scheme). + // If so, we don't specify the target port, as it will get one from the deployment tool. + (ProjectResource project, string uriScheme, null, _) when IsHttpScheme(uriScheme) && !httpSchemesEncountered.Contains(uriScheme) => null, + + // Allocate a dynamic port + _ => portAllocator.AllocatePort() + }; + + // We only keep track of schemes for project resources, since we don't want + // a non-project scheme to affect what project endpoints are considered default. + if (resource is ProjectResource && IsHttpScheme(endpoint.UriScheme)) + { + httpSchemesEncountered.Add(endpoint.UriScheme); + } + + int? exposedPort = (endpoint.UriScheme, endpoint.Port, targetPort) switch + { + // Exposed port and target port are the same, we don't need to mention the exposed port + (_, int p0, int p1) when p0 == p1 => null, + + // Port was specified, so use it + (_, int port, _) => port, + + // We have a target port, not need to specify an exposedPort + // it will default to the targetPort + (_, null, int port) => null, + + // Let the tool infer the default http and https ports + ("http", null, null) => null, + ("https", null, null) => null, + + // Other schemes just allocate a port + _ => portAllocator.AllocatePort() + }; + + if (exposedPort is int ep) + { + portAllocator.AddUsedPort(ep); + endpoint.Port = ep; + } + + if (targetPort is int tp) + { + portAllocator.AddUsedPort(tp); + endpoint.TargetPort = tp; + } + } + + // First we group the endpoints by container port (aka destinations), this gives us the logical bindings or destinations + var endpointsByTargetPort = endpoints.GroupBy(e => e.TargetPort) + .Select(g => new + { + Port = g.Key, + Endpoints = g.ToArray(), + External = g.Any(e => e.IsExternal), + IsHttpOnly = g.All(e => e.UriScheme is "http" or "https"), + AnyH2 = g.Any(e => e.Transport is "http2"), + UniqueSchemes = g.Select(e => e.UriScheme).Distinct().ToArray(), + Index = g.Min(e => endpointIndexMap[e.Name]) + }) + .ToList(); + + // Failure cases + + // Multiple external endpoints are not supported + if (endpointsByTargetPort.Count(g => g.External) > 1) + { + throw new NotSupportedException("Multiple external endpoints are not supported"); + } + + // Any external non-http endpoints are not supported + if (endpointsByTargetPort.Any(g => g.External && !g.IsHttpOnly)) + { + throw new NotSupportedException("External non-HTTP(s) endpoints are not supported"); + } + + // Don't allow mixing http and tcp endpoints + // This means we want to fail if we see a group with http/https and tcp endpoints + static bool Compatible(string[] schemes) => + schemes.All(s => s is "http" or "https") || schemes.All(s => s is "tcp"); + + if (endpointsByTargetPort.Any(g => !Compatible(g.UniqueSchemes))) + { + throw new NotSupportedException("HTTP(s) and TCP endpoints cannot be mixed"); + } + + // Get all http only groups + var httpOnlyEndpoints = endpointsByTargetPort.Where(g => g.IsHttpOnly).OrderBy(g => g.Index).ToArray(); + + // Do we only have one? + var httpIngress = httpOnlyEndpoints.Length == 1 ? httpOnlyEndpoints[0] : null; + + if (httpIngress is null) + { + // We have more than one, pick prefer external one + var externalHttp = httpOnlyEndpoints.Where(g => g.External).ToArray(); + + if (externalHttp.Length == 1) + { + httpIngress = externalHttp[0]; + } + else if (httpOnlyEndpoints.Length > 0) + { + httpIngress = httpOnlyEndpoints[0]; + } + } + + if (httpIngress is not null) + { + // We're processed the http ingress, remove it from the list + endpointsByTargetPort.Remove(httpIngress); + + var targetPort = httpIngress.Port ?? (resource is ProjectResource ? null : 80); + + _httpIngress = (targetPort, httpIngress.AnyH2, httpIngress.External); + + foreach (var e in httpIngress.Endpoints) + { + if (e.UriScheme is "http" && e.Port is not null and not 80) + { + throw new NotSupportedException($"The endpoint '{e.Name}' is an http endpoint and must use port 80"); + } + + if (e.UriScheme is "https" && e.Port is not null and not 443) + { + throw new NotSupportedException($"The endpoint '{e.Name}' is an https endpoint and must use port 443"); + } + + // For the http ingress port is always 80 or 443 + var port = e.UriScheme is "http" ? 80 : 443; + + _endpointMapping[e.Name] = new(e.UriScheme, resource.Name, port, targetPort, true, httpIngress.External); + } + } + + if (endpointsByTargetPort.Count > 5) + { + // TODO: Warn the user about the limitation + // throw new NotSupportedException("More than 5 additional ports are not supported. See https://learn.microsoft.com/en-us/azure/container-apps/ingress-overview#tcp for more details."); + } + + foreach (var g in endpointsByTargetPort) + { + if (g.Port is null) + { + throw new NotSupportedException("Container port is required for all endpoints"); + } + + _additionalPorts.Add(g.Port.Value); + + foreach (var e in g.Endpoints) + { + _endpointMapping[e.Name] = new(e.UriScheme, resource.Name, e.Port ?? g.Port.Value, g.Port.Value, false, g.External); + } + } + } + + private async Task ProcessArgumentsAsync(DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + if (resource.TryGetAnnotationsOfType(out var commandLineArgsCallbackAnnotations)) + { + var context = new CommandLineArgsCallbackContext([], cancellationToken: cancellationToken); + + foreach (var c in commandLineArgsCallbackAnnotations) + { + await c.Callback(context).ConfigureAwait(false); + } + + foreach (var arg in context.Args) + { + var (val, _) = await ProcessValueAsync(arg, executionContext, cancellationToken).ConfigureAwait(false); + + var argValue = ResolveValue(val); + + Args.Add(argValue); + } + } + } + + private async Task ProcessEnvironmentAsync(DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + if (resource.TryGetAnnotationsOfType(out var environmentCallbacks)) + { + var context = new EnvironmentCallbackContext(executionContext, cancellationToken: cancellationToken); + + foreach (var c in environmentCallbacks) + { + await c.Callback(context).ConfigureAwait(false); + } + + foreach (var kv in context.EnvironmentVariables) + { + var (val, secretType) = await ProcessValueAsync(kv.Value, executionContext, cancellationToken).ConfigureAwait(false); + + var argValue = ResolveValue(val); + + if (secretType != SecretType.None) + { + var secretName = kv.Key.Replace("__", "--").ToLowerInvariant(); + + var secret = new ContainerAppWritableSecret() + { + Name = secretName + }; + + if (secretType == SecretType.KeyVault) + { + var managedIdentityParameter = AllocateManagedIdentityIdParameter(); + secret.Identity = managedIdentityParameter; + secret.Value = argValue; + } + else + { + secret.Value = argValue; + } + + Secrets.Add(secret); + + // The value is the secret name + val = secretName; + } + + EnvironmentVariables.Add(secretType switch + { + SecretType.None => new ContainerAppEnvironmentVariable { Name = kv.Key, Value = argValue }, + SecretType.Normal or SecretType.KeyVault => new ContainerAppEnvironmentVariable { Name = kv.Key, SecretRef = (string)val }, + _ => throw new NotSupportedException() + }); + } + } + + // TODO: Add managed identity only if needed + AllocateManagedIdentityIdParameter(); + var clientIdParameter = AllocateParameter(_containerAppEnviromentContext.ClientId); + EnvironmentVariables.Add(new ContainerAppEnvironmentVariable { Name = "AZURE_CLIENT_ID", Value = clientIdParameter }); + } + + private static BicepValue ResolveValue(object val) + { + return val switch + { + BicepValue s => s, + string s => s, + BicepValueFormattableString fs => Interpolate(fs), + BicepParameter p => p, + _ => throw new NotSupportedException("Unsupported value type " + val.GetType()) + }; + } + + private void ProcessVolumes() + { + if (resource.TryGetContainerMounts(out var mounts)) + { + var bindMountIndex = 0; + var volumeIndex = 0; + + foreach (var volume in mounts) + { + var (index, volumeName) = volume.Type switch + { + ContainerMountType.BindMount => ($"{bindMountIndex}", $"bm{bindMountIndex}"), + ContainerMountType.Volume => ($"{volumeIndex}", $"v{volumeIndex}"), + _ => throw new NotSupportedException() + }; + + if (volume.Type == ContainerMountType.BindMount) + { + bindMountIndex++; + } + else + { + volumeIndex++; + } + + var volumeStorageParameter = AllocateVolumeStorageAccount(volume.Type, index); + + var containerAppVolume = new ContainerAppVolume + { + Name = volumeName, + StorageType = ContainerAppStorageType.AzureFile, + StorageName = volumeStorageParameter, + }; + + var containerAppVolumeMount = new ContainerAppVolumeMount + { + VolumeName = volumeName, + MountPath = volume.Target, + }; + + Volumes.Add((containerAppVolume, containerAppVolumeMount)); + } + } + } + + private BicepValue GetValue(EndpointMapping mapping, EndpointProperty property) + { + var (scheme, host, port, targetPort, isHttpIngress, external) = mapping; + + BicepValue GetHostValue(string? prefix = null, string? suffix = null) + { + if (isHttpIngress) + { + var domain = AllocateParameter(_containerAppEnviromentContext.ContainerAppDomain); + + return external ? BicepFunction.Interpolate($$"""{{prefix}}{{host}}.{{domain}}{{suffix}}""") : BicepFunction.Interpolate($$"""{{prefix}}{{host}}.internal.{{domain}}{{suffix}}"""); + } + + return $"{prefix}{host}{suffix}"; + } + + return property switch + { + EndpointProperty.Url => GetHostValue($"{scheme}://", suffix: isHttpIngress ? null : $":{port}"), + EndpointProperty.Host or EndpointProperty.IPV4Host => GetHostValue(), + EndpointProperty.Port => port.ToString(CultureInfo.InvariantCulture), + EndpointProperty.TargetPort => targetPort is null ? AllocateTargetPortParameter() : targetPort, + EndpointProperty.Scheme => scheme, + _ => throw new NotSupportedException(), + }; + } + + private async Task<(object, SecretType)> ProcessValueAsync(object value, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken, SecretType secretType = SecretType.None) + { + if (value is string s) + { + return (s, secretType); + } + + if (value is EndpointReference ep) + { + var context = ep.Resource == resource + ? this + : await _containerAppEnviromentContext.ProcessResourceAsync(ep.Resource, executionContext, cancellationToken).ConfigureAwait(false); + + var mapping = context._endpointMapping[ep.EndpointName]; + + var url = GetValue(mapping, EndpointProperty.Url); + + return (url, secretType); + } + + if (value is ConnectionStringReference cs) + { + return await ProcessValueAsync(cs.Resource.ConnectionStringExpression, executionContext, cancellationToken, secretType: SecretType.Normal).ConfigureAwait(false); + } + + if (value is IResourceWithConnectionString csrs) + { + return await ProcessValueAsync(csrs.ConnectionStringExpression, executionContext, cancellationToken, secretType: SecretType.Normal).ConfigureAwait(false); + } + + if (value is ParameterResource param) + { + // This gets translated to a parameter + var parameterName = AllocateParameter(param); + + return (parameterName, param.Secret ? SecretType.Normal : secretType); + } + + if (value is BicepOutputReference output) + { + var parameterName = AllocateParameter(output); + + return (parameterName, secretType); + } + + if (value is BicepSecretOutputReference secretOutputReference) + { + // Externalize secret outputs so azd can fill them in + var parameterName = AllocateParameter(secretOutputReference); + + return (parameterName, SecretType.KeyVault); + } + + if (value is EndpointReferenceExpression epExpr) + { + var context = epExpr.Endpoint.Resource == resource + ? this + : await _containerAppEnviromentContext.ProcessResourceAsync(epExpr.Endpoint.Resource, executionContext, cancellationToken).ConfigureAwait(false); + + var mapping = context._endpointMapping[epExpr.Endpoint.EndpointName]; + + var val = GetValue(mapping, epExpr.Property); + + return (val, secretType); + } + + if (value is ReferenceExpression expr) + { + var args = new object[expr.ValueProviders.Count]; + var index = 0; + var finalSecretType = SecretType.None; + + foreach (var vp in expr.ValueProviders) + { + var (val, secret) = await ProcessValueAsync(vp, executionContext, cancellationToken, secretType).ConfigureAwait(false); + + // Special case references to keyvault secrets + if (expr.Format == "{0}" && expr.ValueProviders.Count == 1 && secret == SecretType.KeyVault) + { + return (val, secret); + } + + if (secret != SecretType.None) + { + finalSecretType = SecretType.Normal; + } + + args[index++] = val; + } + + return (new BicepValueFormattableString(expr.Format, args), finalSecretType); + + } + + throw new NotSupportedException("Unsupported value type " + value.GetType()); + } + + private BicepParameter AllocateVolumeStorageAccount(ContainerMountType type, string volumeIndex) + { + return AllocateParameter(VolumeStorageExpression.GetVolumeStorage(resource, type, volumeIndex)); + } + + private BicepParameter AllocateContainerImageParameter() + => AllocateParameter(ProjectResourceExpression.GetContainerImageExpression((ProjectResource)resource)); + + private BicepValue AllocateTargetPortParameter() + => AllocateParameter(ProjectResourceExpression.GetTargetPortExpression((ProjectResource)resource)); + + private BicepParameter AllocateManagedIdentityIdParameter() + => _managedIdentityIdParameter ??= AllocateParameter(_containerAppEnviromentContext.ManagedIdentityId); + + private void AllocateContainerRegistryParameters() + { + _containerRegistryUrlParameter ??= AllocateParameter(_containerAppEnviromentContext.ContainerRegistryUrl); + _containerRegistryManagedIdentityIdParameter ??= AllocateParameter(_containerAppEnviromentContext.ContainerRegistryManagedIdentityId); + } + + private BicepParameter AllocateParameter(IManifestExpressionProvider parameter, Type? type = null) + { + if (!_allocatedParameters.TryGetValue(parameter, out var parameterName)) + { + parameterName = parameter.ValueExpression.Replace("{", "").Replace("}", "").Replace(".", "_").Replace("-", "_").ToLowerInvariant(); + + if (parameterName[0] == '_') + { + parameterName = parameterName[1..]; + } + + _allocatedParameters[parameter] = parameterName; + } + + if (!_bicepParameters.TryGetValue(parameterName, out var bicepParameter)) + { + var isSecure = parameter is BicepSecretOutputReference || parameter is ParameterResource { Secret: true }; + + _bicepParameters[parameterName] = bicepParameter = new BicepParameter(parameterName, type ?? typeof(string)) { IsSecure = isSecure }; + } + + Parameters[parameterName] = parameter; + return bicepParameter; + } + + private void AddIngress(ContainerAppConfiguration config) + { + if (_httpIngress is null && _additionalPorts.Count == 0) + { + return; + } + + // Now we map the remainig endpoints. These should be internal only tcp/http based endpoints + var skipAdditionalPort = 0; + + var caIngress = new ContainerAppIngressConfiguration(); + + if (_httpIngress is { } ingress) + { + caIngress.External = ingress.External; + caIngress.TargetPort = ingress.Port ?? AllocateTargetPortParameter(); + caIngress.Transport = ingress.Http2 ? ContainerAppIngressTransportMethod.Http2 : ContainerAppIngressTransportMethod.Http; + } + else if (_additionalPorts.Count > 0) + { + // First port is the default + var port = _additionalPorts[0]; + + skipAdditionalPort++; + + caIngress.External = false; + caIngress.TargetPort = port; + caIngress.Transport = ContainerAppIngressTransportMethod.Tcp; + } + + // Add additional ports + // https://learn.microsoft.com/en-us/azure/container-apps/ingress-how-to?pivots=azure-cli#use-additional-tcp-ports + var additionalPorts = _additionalPorts.Skip(skipAdditionalPort); + if (additionalPorts.Any()) + { + foreach (var port in additionalPorts) + { + caIngress.AdditionalPortMappings.Add(new IngressPortMapping + { + External = false, + TargetPort = port + }); + } + } + + config.Ingress = caIngress; + } + + private void AddEnvironmentVariablesAndCommandLineArgs(ContainerAppContainer container) + { + if (EnvironmentVariables.Count > 0) + { + container.Env = []; + + foreach (var ev in EnvironmentVariables) + { + container.Env.Add(ev); + } + } + + if (Args.Count > 0) + { + container.Args = new(Args); + } + } + + private void AddSecrets(ContainerAppConfiguration config) + { + if (Secrets.Count == 0) + { + return; + } + + config.Secrets = []; + + foreach (var s in Secrets) + { + config.Secrets.Add(s); + } + } + + private void AddManagedIdentites(ContainerApp app) + { + if (_managedIdentityIdParameter is null) + { + return; + } + + // REVIEW: This is is a little hacky, we should probably have a better way to do this + var id = BicepFunction.Interpolate($"{_managedIdentityIdParameter}").Compile().ToString(); + + app.Identity = new ManagedServiceIdentity() + { + ManagedServiceIdentityType = ManagedServiceIdentityType.UserAssigned, + UserAssignedIdentities = new() + { + [id] = new UserAssignedIdentityDetails() + } + }; + } + + private void AddContainerRegistryParameters(ContainerAppConfiguration app) + { + if (_containerRegistryUrlParameter is null || _containerRegistryManagedIdentityIdParameter is null) + { + return; + } + + app.Registries = [ + new ContainerAppRegistryCredentials + { + Server = _containerRegistryUrlParameter, + Identity = _containerRegistryManagedIdentityIdParameter + } + ]; + } + } + } + + // REVIEW: BicepFunction.Interpolate is buggy and doesn't handle nested formattable strings correctly + // This is a workaround to handle nested formattable strings until the bug is fixed. + private static BicepValue Interpolate(BicepValueFormattableString text) + { + var formatStringBuilder = new StringBuilder(); + var arguments = new List>(); + + void ProcessFormattableString(BicepValueFormattableString formattableString, int argumentIndex) + { + var span = formattableString.Format.AsSpan(); + var skip = 0; + + foreach (var match in Regex.EnumerateMatches(span, @"{\d+}")) + { + formatStringBuilder.Append(span[..(match.Index - skip)]); + + var argument = formattableString.GetArgument(argumentIndex); + + if (argument is BicepValueFormattableString nested) + { + // Inline the nested formattable string + ProcessFormattableString(nested, 0); + } + else + { + formatStringBuilder.Append(CultureInfo.InvariantCulture, $"{{{arguments.Count}}}"); + if (argument is BicepValue bicepValue) + { + arguments.Add(bicepValue); + } + else if (argument is string s) + { + arguments.Add(s); + } + else if (argument is BicepParameter bicepParameter) + { + arguments.Add(bicepParameter); + } + else + { + throw new NotSupportedException($"{argument} is not supported"); + } + } + + argumentIndex++; + span = span[(match.Index + match.Length - skip)..]; + skip = match.Index + match.Length; + } + + formatStringBuilder.Append(span); + } + + ProcessFormattableString(text, 0); + + var formatString = formatStringBuilder.ToString(); + + if (formatString == "{0}") + { + return arguments[0]; + } + + return BicepFunction.Interpolate(new BicepValueFormattableString(formatString, [.. arguments])); + } + + /// + /// A custom FormattableString implementation that allows us to inline nested formattable strings. + /// + private sealed class BicepValueFormattableString(string formatString, object[] values) : FormattableString + { + public override int ArgumentCount => values.Length; + public override string Format => formatString; + public override object? GetArgument(int index) => values[index]; + public override object?[] GetArguments() => values; + public override string ToString(IFormatProvider? formatProvider) => Format; + public override string ToString() => formatString; + } + + /// + /// These are referencing outputs from azd's main.bicep file. We represent the global namespace in the manifest + /// by using {.outputs.property} expressions. + /// + private sealed class AzureContainerAppsEnvironment + { + public static IManifestExpressionProvider MANAGED_IDENTITY_CLIENT_ID => GetExpression("MANAGED_IDENTITY_CLIENT_ID"); + public static IManifestExpressionProvider MANAGED_IDENTITY_NAME => GetExpression("MANAGED_IDENTITY_NAME"); + public static IManifestExpressionProvider MANAGED_IDENTITY_PRINCIPAL_ID => GetExpression("MANAGED_IDENTITY_PRINCIPAL_ID"); + public static IManifestExpressionProvider AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID => GetExpression("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"); + public static IManifestExpressionProvider AZURE_CONTAINER_REGISTRY_ENDPOINT => GetExpression("AZURE_CONTAINER_REGISTRY_ENDPOINT"); + public static IManifestExpressionProvider AZURE_CONTAINER_APPS_ENVIRONMENT_ID => GetExpression("AZURE_CONTAINER_APPS_ENVIRONMENT_ID"); + public static IManifestExpressionProvider AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN => GetExpression("AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"); + + public static IManifestExpressionProvider GetExpression(string propertyExpression) => + new ExpressionProvider(propertyExpression); + + private sealed class ExpressionProvider(string propertyExpression) : IManifestExpressionProvider + { + public string ValueExpression => $"{{.outputs.{propertyExpression}}}"; + } + } + + private static class ProjectResourceExpression + { + public static IManifestExpressionProvider GetContainerImageExpression(ProjectResource p) => + new ProjectContainerImage(p); + + public static IManifestExpressionProvider GetTargetPortExpression(ProjectResource p) => + new ProjectContainerPort(p); + + private sealed class ProjectContainerImage(ProjectResource resource) : IManifestExpressionProvider + { + public string ValueExpression => $"{{{resource.Name}.containerImage}}"; + } + + private sealed class ProjectContainerPort(ProjectResource resource) : IManifestExpressionProvider + { + public string ValueExpression => $"{{{resource.Name}.containerPort}}"; + } + } + + /// + /// Generates expressions for the volume storage account. That azd creates. + /// + private sealed class VolumeStorageExpression(IResource resource, ContainerMountType type, string index) : IManifestExpressionProvider + { + public string ValueExpression => type switch + { + ContainerMountType.BindMount => $"{{{resource.Name}.bindMounts.{index}.storage}}", + ContainerMountType.Volume => $"{{{resource.Name}.volumes.{index}.storage}}", + _ => throw new NotSupportedException() + }; + + public static IManifestExpressionProvider GetVolumeStorage(IResource resource, ContainerMountType type, string index) => + new VolumeStorageExpression(resource, type, index); + } + + private sealed class PortAllocator(int startPort = 8000) + { + private int _allocatedPortStart = startPort; + private readonly HashSet _usedPorts = []; + + public int AllocatePort() + { + while (true) + { + if (!_usedPorts.Contains(_allocatedPortStart)) + { + return _allocatedPortStart; + } + + _allocatedPortStart++; + } + } + + public void AddUsedPort(int port) + { + _usedPorts.Add(port); + } + } + + enum SecretType + { + None, + Normal, + KeyVault, + } +} diff --git a/src/Aspire.Hosting.Azure.ContainerApps/PublicAPI.Shipped.txt b/src/Aspire.Hosting.Azure.ContainerApps/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/Aspire.Hosting.Azure.ContainerApps/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Aspire.Hosting.Azure.ContainerApps/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.Azure.ContainerApps/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..4579b3bd89 --- /dev/null +++ b/src/Aspire.Hosting.Azure.ContainerApps/PublicAPI.Unshipped.txt @@ -0,0 +1,5 @@ +#nullable enable +Aspire.Hosting.Azure.AzureContainerAppExtensions +static Aspire.Hosting.Azure.AzureContainerAppExtensions.AddContainerAppsInfrastructure(this Aspire.Hosting.IDistributedApplicationBuilder! builder) -> Aspire.Hosting.IDistributedApplicationBuilder! +static Aspire.Hosting.Azure.AzureContainerAppExtensions.PublishAsContainerApp(this Aspire.Hosting.ApplicationModel.IResourceBuilder! project, System.Action! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.Azure.AzureContainerAppExtensions.PublishAsContainerApp(this Aspire.Hosting.ApplicationModel.IResourceBuilder! container, System.Action! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs index 6fb9de49b8..20f3d2563e 100644 --- a/src/Aspire.Hosting.Azure/AzureBicepResource.cs +++ b/src/Aspire.Hosting.Azure/AzureBicepResource.cs @@ -74,19 +74,20 @@ public virtual BicepTemplateFile GetBicepTemplateFile(string? directory = null, isTempFile = directory is null; path = TempDirectory is null - ? Path.Combine(directory ?? Directory.CreateTempSubdirectory("aspire").FullName, $"{Name.ToLowerInvariant()}.module.bicep") + ? Path.Combine(directory is not null ? Path.Combine(directory, "bicep") : Directory.CreateTempSubdirectory("aspire").FullName, $"{Name.ToLowerInvariant()}.module.bicep") : Path.Combine(TempDirectory, $"{Name.ToLowerInvariant()}.module.bicep"); if (TemplateResourceName is null) { // REVIEW: Consider making users specify a name for the template + Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, TemplateString); } else { path = directory is null ? path - : Path.Combine(directory, $"{TemplateResourceName.ToLowerInvariant()}"); + : Path.Combine(directory, "bicep", $"{TemplateResourceName.ToLowerInvariant()}"); // REVIEW: We should allow the user to specify the assembly where the resources reside. using var resourceStream = GetType().Assembly.GetManifestResourceStream(TemplateResourceName) diff --git a/src/Aspire.Hosting.Azure/AzureConstructResource.cs b/src/Aspire.Hosting.Azure/AzureConstructResource.cs index 13fc0ffa64..1f10bf4e85 100644 --- a/src/Aspire.Hosting.Azure/AzureConstructResource.cs +++ b/src/Aspire.Hosting.Azure/AzureConstructResource.cs @@ -66,7 +66,8 @@ public override BicepTemplateFile GetBicepTemplateFile(string? directory = null, var compiledBicep = compilation.First(); File.WriteAllText(moduleSourcePath, compiledBicep.Value); - var moduleDestinationPath = Path.Combine(directory ?? generationPath, $"{Name}.module.bicep"); + var moduleDestinationPath = Path.Combine(directory is not null ? Path.Combine(directory, "bicep") : generationPath, $"{Name}.module.bicep"); + Directory.CreateDirectory(Path.GetDirectoryName(moduleDestinationPath)!); File.Copy(moduleSourcePath, moduleDestinationPath, true); return new BicepTemplateFile(moduleDestinationPath, directory is null); diff --git a/src/Aspire.Hosting/ApplicationModel/DeploymentTargetAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DeploymentTargetAnnotation.cs new file mode 100644 index 0000000000..73caa33215 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/DeploymentTargetAnnotation.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents an annotation for a deployment target. +/// +public class DeploymentTargetAnnotation(IResource target) : IResourceAnnotation +{ + /// + /// The deployment target. + /// + public IResource DeploymentTarget { get; } = target; +} diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index a6c48fe6ff..eea9d57a93 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -44,6 +44,9 @@ Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.StartTimeStamp.get -> Sys Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.StartTimeStamp.init -> void Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.StopTimeStamp.get -> System.DateTime? Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.StopTimeStamp.init -> void +Aspire.Hosting.ApplicationModel.DeploymentTargetAnnotation +Aspire.Hosting.ApplicationModel.DeploymentTargetAnnotation.DeploymentTarget.get -> Aspire.Hosting.ApplicationModel.IResource! +Aspire.Hosting.ApplicationModel.DeploymentTargetAnnotation.DeploymentTargetAnnotation(Aspire.Hosting.ApplicationModel.IResource! target) -> void Aspire.Hosting.ApplicationModel.EndpointAnnotation.TargetHost.get -> string! Aspire.Hosting.ApplicationModel.EndpointAnnotation.TargetHost.set -> void Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.Volumes.get -> System.Collections.Immutable.ImmutableArray diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index 800ae3f863..8d3c09a500 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -146,8 +146,6 @@ private Task WriteConnectionStringAsync(IResourceWithConnectionString resource) private async Task WriteProjectAsync(ProjectResource project) { - Writer.WriteString("type", "project.v0"); - if (!project.TryGetLastAnnotation(out var metadata)) { throw new DistributedApplicationException("Project metadata not found."); @@ -155,14 +153,40 @@ private async Task WriteProjectAsync(ProjectResource project) var relativePathToProjectFile = GetManifestRelativePath(metadata.ProjectPath); + if (project.TryGetLastAnnotation(out var deploymentTarget)) + { + Writer.WriteString("type", "project.v1"); + } + else + { + Writer.WriteString("type", "project.v0"); + } + Writer.WriteString("path", relativePathToProjectFile); + if (deploymentTarget is not null) + { + await WriteDeploymentTarget(deploymentTarget).ConfigureAwait(false); + } + await WriteCommandLineArgumentsAsync(project).ConfigureAwait(false); await WriteEnvironmentVariablesAsync(project).ConfigureAwait(false); + WriteBindings(project); } + private async Task WriteDeploymentTarget(DeploymentTargetAnnotation deploymentTarget) + { + if (deploymentTarget.DeploymentTarget.TryGetLastAnnotation(out var manifestPublishingCallbackAnnotation) && + manifestPublishingCallbackAnnotation.Callback is not null) + { + Writer.WriteStartObject("deployment"); + await manifestPublishingCallbackAnnotation.Callback(this).ConfigureAwait(false); + Writer.WriteEndObject(); + } + } + private async Task WriteExecutableAsync(ExecutableResource executable) { Writer.WriteString("type", "executable.v0"); @@ -224,6 +248,8 @@ internal Task WriteParameterAsync(ParameterResource parameter) /// Thrown if the container resource does not contain a . public async Task WriteContainerAsync(ContainerResource container) { + container.TryGetLastAnnotation(out var deploymentTarget); + if (container.Annotations.OfType().Any()) { Writer.WriteString("type", "container.v1"); @@ -232,15 +258,29 @@ public async Task WriteContainerAsync(ContainerResource container) } else { - Writer.WriteString("type", "container.v0"); if (!container.TryGetContainerImageName(out var image)) { throw new DistributedApplicationException("Could not get container image name."); } + + if (deploymentTarget is not null) + { + Writer.WriteString("type", "container.v1"); + } + else + { + Writer.WriteString("type", "container.v0"); + } + WriteConnectionString(container); Writer.WriteString("image", image); } + if (deploymentTarget is not null) + { + await WriteDeploymentTarget(deploymentTarget).ConfigureAwait(false); + } + if (container.Entrypoint is not null) { Writer.WriteString("entrypoint", container.Entrypoint);