| File: Publishing\BackingResourceContractTests.cs | Web Access |
| Project: src\tests\Aspire.Hosting.Radius.Tests\Aspire.Hosting.Radius.Tests.csproj (Aspire.Hosting.Radius.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 ASPIRECOMPUTE002 using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Radius.Publishing; using Aspire.Hosting.Radius.ResourceMapping; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting.Radius.Tests.Publishing; /// <summary> /// Guards the invariants that keep https://github.com/microsoft/aspire/issues/18935 fixed: /// the connection-schema table stays total over the resource types the mapper emits, and the /// endpoint guard behaves identically no matter which entry point reaches it. /// </summary> public class BackingResourceContractTests { /// <summary> /// Every backing type <see cref="ResourceTypeMapper"/> can emit must have a connection schema. /// </summary> /// <remarks> /// Without this, adding a resource mapping — or dropping a <c>LegacyFallbackType</c> so a /// <c>Radius.*</c> UDT becomes the emitted type — would leave the new type with no schema. The /// publisher would then wire no credential at all and every consumer would silently receive the /// password Aspire generated for local run mode, which is precisely the reported defect. Failing /// here makes that a compile-and-test-time decision rather than a deploy-time surprise. /// </remarks> [Fact] public void EveryEmittedBackingType_HasAConnectionSchema() { var missing = ResourceTypeMapper.GetEmittedBackingTypes() .Where(t => RadiusBackingConnections.GetSchema(t.EmittedType) is null) .Select(t => $"{t.MappingKey} -> {t.EmittedType}") .OrderBy(t => t, StringComparer.Ordinal) .ToList(); Assert.Empty(missing); } /// <summary> /// The schema table must not describe a type the mapper never emits: a stale row is a claim /// about a schema nobody verifies, and it silences the guard above if that type is ever mapped. /// </summary> [Fact] public void ConnectionSchemaTable_DescribesOnlyEmittedBackingTypes() { var emitted = ResourceTypeMapper.GetEmittedBackingTypes() .Select(t => t.EmittedType) .ToHashSet(StringComparer.Ordinal); // No allowlist: every row in the schema table must correspond to a type the mapper can // actually emit. The Radius.Dapr/* rows that used to be exempted here were fictional — // Radius has no such namespace — and Dapr is now mapped directly to its legacy type. var stale = RadiusBackingConnections.KnownTypes .Where(t => !emitted.Contains(t)) .OrderBy(t => t, StringComparer.Ordinal) .ToList(); Assert.Empty(stale); } /// <summary> /// Every backing type the mapper emits must also have a default recipe, and each recipe's /// registry prefix must match its type's namespace. /// </summary> /// <remarks> /// <para> /// The recipe table has none of the compile-time protection the schema table has, yet it is the /// table a mapping change is most likely to outrun. A missing entry, or a <c>Radius.*</c> UDT /// pointed at a legacy <c>recipes/local-dev/</c> recipe, surfaces only as a /// <c>RecipeDownloadFailed</c> at <c>rad deploy</c> — in the deploy E2E job or in a user's /// cluster, never at publish time. /// </para> /// <para> /// The prefix half matters as much as the presence half: <c>Radius.*</c> UDT recipes are /// published under <c>kube-recipes/</c>, and only those recipes read the credentials Aspire /// sets from <c>context.resource.properties</c>. Pairing a UDT with a local-dev recipe both /// fails to pull and would ignore those credentials. /// </para> /// </remarks> [Fact] public void EveryEmittedBackingType_HasADefaultRecipeWithAMatchingPrefix() { const string UdtPrefix = "ghcr.io/radius-project/kube-recipes/"; const string LegacyPrefix = "ghcr.io/radius-project/recipes/local-dev/"; var recipes = RadiusInfrastructureBuilder.DefaultRecipeTemplates; var missing = ResourceTypeMapper.GetEmittedBackingTypes() .Where(t => !recipes.ContainsKey(t.EmittedType)) .Select(t => $"{t.MappingKey} -> {t.EmittedType}") .OrderBy(t => t, StringComparer.Ordinal) .ToList(); Assert.Empty(missing); // Checked over the whole table rather than only the emitted types so a row that is dead // today still cannot ship the wrong pairing for the day the legacy fallback is dropped. var mismatched = recipes .Where(kvp => !kvp.Value.StartsWith( kvp.Key.StartsWith("Radius.", StringComparison.Ordinal) ? UdtPrefix : LegacyPrefix, StringComparison.Ordinal)) .Select(kvp => $"{kvp.Key} -> {kvp.Value}") .OrderBy(t => t, StringComparer.Ordinal) .ToList(); Assert.Empty(mismatched); } /// <summary> /// The guard applies to every entry point, so a Kubernetes, Azure Container Apps, or Azure App /// Service consumer that reaches a Radius-owned backing resource through /// <c>ComputeEnvironmentEndpointResolver</c> gets the same accurate failure the Radius publisher /// does. /// </summary> /// <remarks> /// The resolver only delegates to a Radius environment for resources <em>that environment owns</em>, /// so this can never be a false positive: the address really is underivable. Suppressing it /// would substitute a container-shaped <c>{name}-{name}.{ns}.svc.cluster.local</c> FQDN that /// resolves to nothing, reintroducing the reported defect in a different publisher. /// </remarks> [Theory] [InlineData(EndpointProperty.Host)] [InlineData(EndpointProperty.Port)] [InlineData(EndpointProperty.Url)] [InlineData(EndpointProperty.HostAndPort)] [InlineData(EndpointProperty.TargetPort)] public void CrossEnvironmentEndpointResolution_FailsWithThePublicException(EndpointProperty property) { var (environment, cache) = CreateRadiusOwnedRedis(); var endpoint = cache.GetEndpoint("tcp"); var ex = Assert.Throws<RadiusBackingResourceProjectionException>( () => ((IComputeEnvironmentResource)environment).GetEndpointPropertyExpression(endpoint.Property(property))); Assert.Same(cache.Resource, ex.Resource); Assert.Contains("provisioned by a Radius recipe", ex.Message, StringComparison.Ordinal); Assert.Contains("ASPIRERADIUS069", ex.Message, StringComparison.Ordinal); } /// <summary> /// The public <see cref="RadiusEnvironmentResource.GetHostAddressExpression"/> — which /// <c>Aspire.Hosting.Azure.FrontDoor</c> calls directly — must fail the same way, so behaviour /// does not depend on which entry point the caller happened to use. /// </summary> [Fact] public void GetHostAddressExpression_FailsIdenticallyToTheInterfacePath() { var (environment, cache) = CreateRadiusOwnedRedis(); var direct = Assert.Throws<RadiusBackingResourceProjectionException>( () => environment.GetHostAddressExpression(cache.GetEndpoint("tcp"))); var viaInterface = Assert.Throws<RadiusBackingResourceProjectionException>( () => ((IComputeEnvironmentResource)environment) .GetEndpointPropertyExpression(cache.GetEndpoint("tcp").Property(EndpointProperty.Host))); Assert.Equal(viaInterface.Message, direct.Message); } /// <summary> /// A database child is represented by its parent in the Radius model, so referencing the child's /// endpoint must be classified the same way as referencing the server's. /// </summary> [Fact] public void ChildResourceEndpoint_IsClassifiedByItsParent() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var environment = builder.AddRadiusEnvironment("myenv").Resource; var pg = builder.AddPostgres("pg"); pg.AddDatabase("pgdb"); var ex = Assert.Throws<RadiusBackingResourceProjectionException>( () => environment.GetHostAddressExpression(pg.GetEndpoint("tcp"))); Assert.Equal("pg", ex.Resource.Name); } /// <summary> /// A container workload is still addressed through the Radius container recipe's ClusterIP /// Service, so the guard must not widen to compute resources. /// </summary> [Fact] public void ContainerEndpoint_IsStillResolvedThroughServiceDiscovery() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var environment = builder.AddRadiusEnvironment("myenv").Resource; var backend = builder.AddContainer("backend", "myapp/backend", "latest").WithHttpEndpoint(targetPort: 8080); var expression = environment.GetHostAddressExpression(backend.GetEndpoint("http")); Assert.Equal("backend-backend.default.svc.cluster.local", expression.Format); } /// <summary> /// <see cref="EndpointProperty.Scheme"/> and <see cref="EndpointProperty.TlsEnabled"/> are read /// from the endpoint declaration rather than derived from an address, so a Radius-owned backing /// resource can answer them. /// </summary> /// <remarks> /// Both properties resolved through <c>IComputeEnvironmentResource</c> before backing-resource /// projection existed. Rejecting them would break a Kubernetes, Azure Container Apps, or Azure /// App Service consumer that asks only for the scheme, even though no recipe-owned address is /// involved. /// </remarks> [Theory] [InlineData(EndpointProperty.Scheme, "redis")] [InlineData(EndpointProperty.TlsEnabled, "False")] public void NonAddressEndpointProperties_AreResolvedForBackingResources(EndpointProperty property, string expected) { var (environment, cache) = CreateRadiusOwnedRedis(); var endpoint = cache.GetEndpoint("tcp"); var expression = ((IComputeEnvironmentResource)environment) .GetEndpointPropertyExpression(endpoint.Property(property)); Assert.Equal(expected, expression.Format); } private static (RadiusEnvironmentResource Environment, IResourceBuilder<IResourceWithEndpoints> Cache) CreateRadiusOwnedRedis() { var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var environment = builder.AddRadiusEnvironment("myenv").Resource; var cache = builder.AddRedis("cache"); // Build so endpoint annotations are materialized; the builder is intentionally not disposed // here because the returned resources outlive it and hold no disposable state. _ = builder.Build().Services.GetRequiredService<DistributedApplicationModel>(); return (environment, cache); } }