| File: Publishing\RadiusServiceDiscovery.cs | Web Access |
| Project: src\src\Aspire.Hosting.Radius\Aspire.Hosting.Radius.csproj (Aspire.Hosting.Radius) |
// 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 ASPIREPROJECTS001 // ProjectLaunchDefaultsAnnotation is experimental. using System.Globalization; using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Radius.Publishing; /// <summary> /// Single source of truth for how Aspire addresses a Radius container across the cluster, so the /// service-discovery values emitted into consumer containers (the <c>services__*</c> env vars) can /// never disagree with the Kubernetes objects the Radius recipe actually creates. /// </summary> /// <remarks> /// The Radius Kubernetes container recipe (radius-project/resource-types-contrib) creates one /// ClusterIP <c>Service</c> per container that declares ports. The Service name is /// <c>${normalizedName}-${containerName}</c> and it is exposed on the container port /// (<c>port == targetPort == containerPort</c>). Pinned to an immutable commit so the documented /// contract can be verified even if <c>main</c> moves (Service name at line 338, port/targetPort at /// lines 329-330): /// <see href="https://github.com/radius-project/resource-types-contrib/blob/ab9722ac7027060693ef6d731f770436b03abbaf/Compute/containers/recipes/kubernetes/bicep/kubernetes-containers.bicep"/>. /// <para> /// For a Radius container emitted by Aspire, both <c>normalizedName</c> (the top-level <c>name:</c>) /// and <c>containerName</c> (the <c>properties.containers</c> map key) equal the Aspire resource /// name, so the Service name is <c>{resource.Name}-{resource.Name}</c>. The Radius recipe and the /// container v2 schema do <em>not</em> require the map key to equal <c>name:</c> — Radius permits /// distinct top-level and container-map names. Aspire requires them to match because it derives /// service discovery from the original resource name, so a callback that renames only one of them /// would make the emitted <c>services__*</c> values address a Service that is never produced; the /// publisher guards against that so this doubling holds for every manifest Aspire emits. /// </para> /// </remarks> internal static class RadiusServiceDiscovery { // The Kubernetes container port assigned to a project resource whose HTTP endpoint has no // explicit target port in publish mode. Mirrors the Kubernetes publisher's default // (GenerateDefaultProjectEndpointMapping) so a project turned into a Radius container declares // a port -> the recipe creates a Service for it -> it is reachable via service discovery. internal const int DefaultProjectContainerPort = 8080; /// <summary> /// Gets the Kubernetes <c>Service</c> name the Radius recipe creates for <paramref name="resource"/>. /// </summary> public static string GetServiceName(IResource resource) => GetServiceName(resource.Name); /// <summary> /// Gets the Kubernetes <c>Service</c> name the Radius recipe creates for a container whose /// Aspire resource name is <paramref name="resourceName"/>. Aspire keys both the top-level /// <c>name:</c> and the <c>properties.containers</c> map key by the resource name, so the /// recipe's <c>${normalizedName}-${containerName}</c> resolves to <c>{name}-{name}</c>. /// </summary> public static string GetServiceName(string resourceName) => GetServiceName(resourceName, resourceName); /// <summary> /// Gets the Kubernetes <c>Service</c> name the Radius recipe creates for a container whose /// top-level <c>name:</c> is <paramref name="topLevelName"/> and whose /// <c>properties.containers</c> map key is <paramref name="mapKey"/>. The recipe names the /// Service <c>${normalizedName}-${containerName}</c>, i.e. <c>{topLevelName}-{mapKey}</c>. Use /// this overload for callback-mutated containers whose top-level name is allowed to differ from /// the map key (no service-discovery contract exists for them). /// </summary> public static string GetServiceName(string topLevelName, string mapKey) => $"{topLevelName}-{mapKey}"; /// <summary> /// Resolves the port the Radius recipe's <c>Service</c> exposes for the endpoint named /// <paramref name="endpointName"/> on <paramref name="resource"/> (equivalently, the container /// port emitted into the Bicep). Returns <see langword="null"/> when no port should be emitted /// for this endpoint (so no Service is created for it). /// </summary> /// <remarks> /// Port resolution runs through <see cref="ResourceExtensions.ResolveEndpoints"/> — the same /// primitive the Kubernetes publisher uses — so the container/Service port is computed with the /// framework's resource-aware semantics rather than a hand-rolled <c>TargetPort ?? Port</c>: /// <list type="bullet"> /// <item>an explicit target port is used as-is;</item> /// <item>a <see cref="ContainerResource"/> with only a host port listens on that port;</item> /// <item>an endpoint without an explicit port that <see cref="ResourceExtensions.ResolveEndpoints"/> /// assigns a distinct allocated port to uses that allocated port, so multiple portless endpoints /// never collapse onto the same Service port;</item> /// <item>a <see cref="ProjectResource"/> endpoint that resolves to no port — the first portless /// HTTP or HTTPS endpoint for its scheme (the deployment tool would normally assign one) — is /// defaulted to <see cref="DefaultProjectContainerPort"/>, <em>except</em> the synthetic default /// HTTPS endpoint, which returns <see langword="null"/> (see the port-resolution code below).</item> /// </list> /// A resolution is stateless and deterministic (the allocator always starts from the same port /// and endpoints are enumerated in a stable order), so the two independent callers — the Bicep /// container-port emission and the <c>services__*</c> URL emission — always agree. /// </remarks> public static int? ResolveServicePort(IResource resource, string endpointName) { var resolved = resource.ResolveEndpoints() .FirstOrDefault(r => string.Equals(r.Endpoint.Name, endpointName, StringComparison.OrdinalIgnoreCase)); if (resolved is null) { return null; } // ResolveEndpoints computes the container (target) port with resource-aware rules. When it // yields a concrete port (explicit target, a container's host-derived port, or an allocated // port for an otherwise-portless endpoint), that is the container/Service port. if (resolved.TargetPort.Value is int targetPort) { return targetPort; } // No resolved target port: ResolveEndpoints returns None only for a project's default // endpoint for its scheme (the first portless http/https endpoint), which the deployment // tool would normally assign a port to. // // Skip only the *synthetic* default HTTPS endpoint: containers do not terminate TLS // in-cluster, and the framework reuses the HTTP port for it (see the Kubernetes publisher's // DefaultHttpsEndpoint handling and the core SetBothPortsEnvVariables behavior). Any other // portless project endpoint — the default HTTP endpoint or an explicit portless HTTP/HTTPS // endpoint — is given the standard container port so the container declares a port and the // recipe creates a Service, matching the Kubernetes publisher's 8080 default. // See: https://github.com/microsoft/aspire/issues/14029 if (resource.TryGetLastAnnotation<ProjectLaunchDefaultsAnnotation>(out var launchDefaults) && ReferenceEquals(resolved.Endpoint, launchDefaults.DefaultHttpsEndpoint)) { return null; } return DefaultProjectContainerPort; } public static string ToInvariantString(int value) => value.ToString(CultureInfo.InvariantCulture); }