From a8bad022a32b66400b014eccaef52ed69481a140 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 3 Mar 2026 12:32:36 -0800 Subject: [PATCH 01/45] Add a new deferred value provider for tls connection properties --- .../RedisBuilderExtensions.cs | 12 +- src/Aspire.Hosting.Redis/RedisResource.cs | 27 ++-- .../ApplicationModel/DeferredValueProvider.cs | 74 +++++++++ .../ApplicationModel/EndpointAnnotation.cs | 12 ++ .../ApplicationModel/EndpointReference.cs | 28 ++++ .../AddRedisTests.cs | 73 ++++++++- .../ConnectionPropertiesTests.cs | 2 +- .../DeferredValueProviderTests.cs | 147 ++++++++++++++++++ 8 files changed, 348 insertions(+), 27 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs create mode 100644 tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 7197f8a64fb..dc6e227cedc 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -91,7 +91,7 @@ public static IResourceBuilder AddRedis( builder.Services.AddHealthChecks().AddRedis(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); var redisBuilder = builder.AddResource(redis) - .WithEndpoint(port: port, targetPort: 6379, name: RedisResource.PrimaryEndpointName) + .WithEndpoint(port: port, targetPort: 6379, name: RedisResource.PrimaryEndpointName, scheme: "redis") .WithImage(RedisContainerImageTags.Image, RedisContainerImageTags.Tag) .WithImageRegistry(RedisContainerImageTags.Registry) .WithHealthCheck(healthCheckKey) @@ -181,6 +181,11 @@ public static IResourceBuilder AddRedis( // configure the environment variables to use it. redisBuilder .WithEndpoint(targetPort: 6380, name: RedisResource.SecondaryEndpointName) + .WithEndpoint(RedisResource.PrimaryEndpointName, e => + { + e.UriScheme = "rediss"; + e.TlsEnabled = true; + }) .WithArgs(argsCtx => { argsCtx.Args.Add("--tls-port"); @@ -188,14 +193,9 @@ public static IResourceBuilder AddRedis( argsCtx.Args.Add("--port"); argsCtx.Args.Add(redis.GetEndpoint(RedisResource.SecondaryEndpointName).Property(EndpointProperty.Port)); }); - - redis.TlsEnabled = true; }); } - // Disable HTTPS developer certificate by default to avoid connection string timing issues - redisBuilder.WithoutHttpsCertificate(); - return redisBuilder; } diff --git a/src/Aspire.Hosting.Redis/RedisResource.cs b/src/Aspire.Hosting.Redis/RedisResource.cs index 97bf0059cfd..cc0acc2e973 100644 --- a/src/Aspire.Hosting.Redis/RedisResource.cs +++ b/src/Aspire.Hosting.Redis/RedisResource.cs @@ -53,9 +53,17 @@ public RedisResource(string name, ParameterResource password) : this(name) public ParameterResource? PasswordParameter { get; private set; } /// - /// Determines whether Tls is enabled for the resource + /// Gets or sets a value indicating whether TLS is enabled for the Redis server. /// - public bool TlsEnabled { get; internal set; } + /// + /// This property proxies through to on the + /// . When set to , the connection string + /// expression dynamically includes ,ssl=true and the URI expression uses the + /// rediss:// scheme. This value is resolved lazily at expression evaluation time, + /// avoiding timing issues when TLS is enabled later in the application lifecycle + /// (e.g., during the BeforeStartEvent). + /// + public bool TlsEnabled => PrimaryEndpoint.TlsEnabled; /// /// Arguments for the Dockerfile @@ -72,10 +80,7 @@ private ReferenceExpression BuildConnectionString() builder.Append($",password={PasswordParameter}"); } - if (TlsEnabled) - { - builder.Append($",ssl=true"); - } + builder.Append($"{PrimaryEndpoint.TlsValue(",ssl=true")}"); return builder.Build(); } @@ -127,14 +132,8 @@ public ReferenceExpression UriExpression get { var builder = new ReferenceExpressionBuilder(); - if (TlsEnabled) - { - builder.AppendLiteral("rediss://"); - } - else - { - builder.AppendLiteral("redis://"); - } + builder.Append($"{PrimaryEndpoint.Property(EndpointProperty.Scheme)}"); + builder.AppendLiteral("://"); if (PasswordParameter is not null) { diff --git a/src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs b/src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs new file mode 100644 index 00000000000..58034c47d78 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs @@ -0,0 +1,74 @@ +// 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; + +/// +/// A general-purpose value provider that resolves its value and manifest expression lazily via callbacks. +/// This enables dynamic values to be embedded in instances, where the +/// actual value is determined at resolution time rather than at expression build time. +/// +/// +/// +/// Use this type when a portion of a connection string or expression depends on state that isn't known +/// until later in the application lifecycle (e.g., whether TLS has been enabled on an endpoint). +/// +/// +/// Callbacks receive a that provides access to the execution context, +/// the calling resource, and network information. When no separate manifest expression callback is provided, +/// the value callback is used for both and . A +/// return from the value callback is treated as an empty string for the manifest expression. +/// +/// +public class DeferredValueProvider : IValueProvider, IManifestExpressionProvider +{ + private readonly Func _valueCallback; + private readonly Func? _manifestExpressionCallback; + + /// + /// Initializes a new instance of with a context-free callback. + /// + /// A callback that returns the value. A return is treated + /// as an empty string for the manifest expression. Called each time the value is resolved. + /// An optional callback that returns the manifest expression string. + /// When , the is used for both runtime and manifest values. + public DeferredValueProvider(Func valueCallback, Func? manifestExpressionCallback = null) + { + ArgumentNullException.ThrowIfNull(valueCallback); + _valueCallback = _ => valueCallback(); + _manifestExpressionCallback = manifestExpressionCallback; + } + + /// + /// Initializes a new instance of with a context-aware callback. + /// + /// A callback that receives a and returns + /// the value. A return is treated as an empty string for the manifest expression. + /// Called each time the value is resolved. + /// An optional callback that returns the manifest expression string. + /// When , the is invoked with an empty + /// for the manifest expression. + public DeferredValueProvider(Func valueCallback, Func? manifestExpressionCallback = null) + { + ArgumentNullException.ThrowIfNull(valueCallback); + _valueCallback = valueCallback; + _manifestExpressionCallback = manifestExpressionCallback; + } + + /// + public string ValueExpression => _manifestExpressionCallback is not null + ? _manifestExpressionCallback() + : _valueCallback(new ValueProviderContext()) ?? ""; + + /// + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + return GetValueAsync(new ValueProviderContext(), cancellationToken); + } + + /// + public ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default) + { + return new(_valueCallback(context)); + } +} diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index cccc6438924..b97b8492120 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -184,6 +184,18 @@ public string Transport /// Defaults to true. public bool IsProxied { get; set; } = true; + /// + /// Gets or sets a value indicating whether TLS is enabled for this endpoint. + /// + /// + /// This property is used to track TLS state on the endpoint so that connection string expressions + /// can dynamically include TLS-related parameters (e.g., ssl=true for Redis) at resolution time + /// rather than at expression build time. For HTTP-based endpoints, the property + /// being set to https already implies TLS. This property is primarily useful for non-HTTP protocols + /// (e.g., Redis, databases) that need explicit TLS configuration in their connection strings. + /// + public bool TlsEnabled { get; set; } + /// /// Gets or sets a value indicating whether the endpoint is from a launch profile. /// diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index 08f3fb8b104..a29f042fdc3 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -62,6 +62,15 @@ public sealed class EndpointReference : IManifestExpressionProvider, IValueProvi /// public bool IsHttps => StringComparers.EndpointAnnotationUriScheme.Equals(Scheme, "https"); + /// + /// Gets a value indicating whether TLS is enabled for this endpoint. + /// + /// + /// Returns if the endpoint annotation has not been added to the resource yet. + /// Once the annotation exists, this property delegates to . + /// + public bool TlsEnabled => Exists && EndpointAnnotation.TlsEnabled; + string IManifestExpressionProvider.ValueExpression => GetExpression(); /// @@ -116,6 +125,25 @@ public EndpointReferenceExpression Property(EndpointProperty property) return new(this, property); } + /// + /// Creates a that resolves to when + /// is on this endpoint, or to + /// otherwise. + /// + /// + /// The returned provider evaluates the TLS state lazily each time its value is resolved, making it + /// safe to embed in a that is built before TLS is configured + /// (e.g., before BeforeStartEvent fires). + /// + /// The value to return when TLS is enabled (e.g., ",ssl=true"). + /// The value to return when TLS is not enabled. Defaults to an empty string. + /// A whose value tracks the TLS state of this endpoint. + public DeferredValueProvider TlsValue(string enabledValue, string disabledValue = "") + { + return new DeferredValueProvider( + () => TlsEnabled ? enabledValue : disabledValue); + } + /// /// Gets the port for this endpoint. /// diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index 2d54dc90ef9..880475dc49e 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -44,7 +44,7 @@ public void AddRedisContainerWithDefaultsAddsAnnotationMetadata() Assert.Null(endpoint.Port); Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); Assert.Equal("tcp", endpoint.Transport); - Assert.Equal("tcp", endpoint.UriScheme); + Assert.Equal("redis", endpoint.UriScheme); var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); Assert.Equal(RedisContainerImageTags.Tag, containerAnnotation.Tag); @@ -72,7 +72,7 @@ public void AddRedisContainerAddsAnnotationMetadata() Assert.Equal(9813, endpoint.Port); Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); Assert.Equal("tcp", endpoint.Transport); - Assert.Equal("tcp", endpoint.UriScheme); + Assert.Equal("redis", endpoint.UriScheme); var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); Assert.Equal(RedisContainerImageTags.Tag, containerAnnotation.Tag); @@ -154,7 +154,7 @@ public async Task VerifyDefaultManifest() }, "bindings": { "tcp": { - "scheme": "tcp", + "scheme": "redis", "protocol": "tcp", "transport": "tcp", "targetPort": 6379 @@ -185,7 +185,7 @@ public async Task VerifyWithoutPasswordManifest() ], "bindings": { "tcp": { - "scheme": "tcp", + "scheme": "redis", "protocol": "tcp", "transport": "tcp", "targetPort": 6379 @@ -223,7 +223,7 @@ public async Task VerifyWithPasswordManifest() }, "bindings": { "tcp": { - "scheme": "tcp", + "scheme": "redis", "protocol": "tcp", "transport": "tcp", "targetPort": 6379 @@ -258,7 +258,7 @@ public async Task VerifyWithPasswordValueNotProvidedManifest() }, "bindings": { "tcp": { - "scheme": "tcp", + "scheme": "redis", "protocol": "tcp", "transport": "tcp", "targetPort": 6379 @@ -835,6 +835,63 @@ public async Task RedisWithCertificateHasCorrectConnectionString() await builder.Eventing.PublishAsync(beforeStartEvent); Assert.True(redis.Resource.TlsEnabled); + + // Verify the connection string expression includes ssl=true after TLS is enabled + var connectionStringExpression = redis.Resource.ConnectionStringExpression; + Assert.Contains(",ssl=true", connectionStringExpression.ValueExpression); + + // Verify the endpoint annotation also has TlsEnabled + var endpoint = Assert.Single(redis.Resource.Annotations.OfType(), e => e.Name == "tcp"); + Assert.True(endpoint.TlsEnabled); + Assert.Equal("rediss", endpoint.UriScheme); + + // Verify the URI expression uses the endpoint scheme + var uriExpression = redis.Resource.UriExpression; + Assert.Contains("{myredis.bindings.tcp.scheme}", uriExpression.ValueExpression); + } + + [Fact] + public async Task RedisConnectionStringResolvesWithTlsDynamically() + { + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + using var cert = CreateTestCertificate(); + + var redis = builder.AddRedis("myredis") + .WithHttpsCertificate(cert) + .WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6379)); + + // Before BeforeStartEvent, TLS is not yet enabled + Assert.False(redis.Resource.TlsEnabled); + + // The manifest expression does not include ssl=true before TLS is enabled + var expressionBeforeTls = redis.Resource.ConnectionStringExpression; + Assert.DoesNotContain(",ssl=true", expressionBeforeTls.ValueExpression); + + // But the expression has a DeferredValueProvider that will resolve dynamically + // Resolve the runtime value — should NOT have ssl=true yet + var resolvedBeforeTls = await expressionBeforeTls.GetValueAsync(default(CancellationToken)); + Assert.NotNull(resolvedBeforeTls); + Assert.DoesNotContain(",ssl=true", resolvedBeforeTls); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Simulate the BeforeStartEvent to enable TLS + var beforeStartEvent = new BeforeStartEvent(app.Services, appModel); + await builder.Eventing.PublishAsync(beforeStartEvent); + + // Now TLS is enabled + Assert.True(redis.Resource.TlsEnabled); + + // The deferred value provider resolves dynamically — the SAME captured expression + // now resolves with ssl=true because the callback reads current TlsEnabled state + var resolvedAfterTls = await expressionBeforeTls.GetValueAsync(default(CancellationToken)); + Assert.NotNull(resolvedAfterTls); + Assert.Contains(",ssl=true", resolvedAfterTls); + + // The new expression from the getter also reflects TLS in its manifest expression + var expressionAfterTls = redis.Resource.ConnectionStringExpression; + Assert.Contains(",ssl=true", expressionAfterTls.ValueExpression); } [Fact] @@ -850,6 +907,10 @@ public void RedisWithoutCertificateHasCorrectConnectionString() // Simulate the BeforeStartEvent var beforeStartEvent = new BeforeStartEvent(app.Services, appModel); Assert.False(redis.Resource.TlsEnabled); + + // Verify the connection string expression does NOT include ssl=true + var connectionStringExpression = redis.Resource.ConnectionStringExpression; + Assert.DoesNotContain(",ssl=true", connectionStringExpression.ValueExpression); } private static X509Certificate2 CreateTestCertificate() diff --git a/tests/Aspire.Hosting.Redis.Tests/ConnectionPropertiesTests.cs b/tests/Aspire.Hosting.Redis.Tests/ConnectionPropertiesTests.cs index dab72340674..c4e3a94a8b4 100644 --- a/tests/Aspire.Hosting.Redis.Tests/ConnectionPropertiesTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/ConnectionPropertiesTests.cs @@ -34,7 +34,7 @@ public void RedisResourceGetConnectionPropertiesReturnsExpectedValues() property => { Assert.Equal("Uri", property.Key); - Assert.Equal("redis://:{password.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", property.Value.ValueExpression); + Assert.Equal("{redis.bindings.tcp.scheme}://:{password.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", property.Value.ValueExpression); }); } } diff --git a/tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs b/tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs new file mode 100644 index 00000000000..330e6be6790 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs @@ -0,0 +1,147 @@ +// 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.Tests; + +public class DeferredValueProviderTests +{ + [Fact] + public async Task GetValueAsync_ReturnsCallbackResult() + { + var provider = new DeferredValueProvider( + () => "hello", + () => "manifest-expression"); + + var value = await provider.GetValueAsync(); + Assert.Equal("hello", value); + } + + [Fact] + public async Task GetValueAsync_ReturnsNullWhenCallbackReturnsNull() + { + var provider = new DeferredValueProvider( + () => null, + () => ""); + + var value = await provider.GetValueAsync(); + Assert.Null(value); + } + + [Fact] + public void ValueExpression_ReturnsManifestCallbackResult() + { + var provider = new DeferredValueProvider( + () => "runtime-value", + () => "manifest-expression"); + + Assert.Equal("manifest-expression", provider.ValueExpression); + } + + [Fact] + public async Task GetValueAsync_ResolvesLazilyFromCurrentState() + { + var enabled = false; + var provider = new DeferredValueProvider( + () => enabled ? ",ssl=true" : ""); + + // Before enabling + var valueBefore = await provider.GetValueAsync(); + Assert.Equal("", valueBefore); + Assert.Equal("", provider.ValueExpression); + + // After enabling + enabled = true; + var valueAfter = await provider.GetValueAsync(); + Assert.Equal(",ssl=true", valueAfter); + Assert.Equal(",ssl=true", provider.ValueExpression); + } + + [Fact] + public async Task GetValueAsync_WithContext_ReceivesValueProviderContext() + { + var context = new ValueProviderContext + { + Caller = null, + Network = null + }; + + ValueProviderContext? capturedContext = null; + var provider = new DeferredValueProvider( + (ctx) => + { + capturedContext = ctx; + return "context-aware-value"; + }); + + var value = await provider.GetValueAsync(context); + Assert.Equal("context-aware-value", value); + Assert.Same(context, capturedContext); + } + + [Fact] + public void ValueExpression_WithContextCallback_InvokesWithEmptyContext() + { + var provider = new DeferredValueProvider( + (ValueProviderContext _) => "context-value"); + + Assert.Equal("context-value", provider.ValueExpression); + } + + [Fact] + public async Task GetValueAsync_WithContextAndManifestCallback_UsesIndependentCallbacks() + { + var provider = new DeferredValueProvider( + (ValueProviderContext _) => "runtime-value", + () => "manifest-expression"); + + var value = await provider.GetValueAsync(); + Assert.Equal("runtime-value", value); + Assert.Equal("manifest-expression", provider.ValueExpression); + } + + [Fact] + public async Task SingleCallback_NullReturnsTreatedAsEmptyForManifest() + { + var provider = new DeferredValueProvider(() => (string?)null); + + var value = await provider.GetValueAsync(); + Assert.Null(value); + Assert.Equal("", provider.ValueExpression); + } + + [Fact] + public async Task DeferredValueProvider_WorksInReferenceExpressionBuilder() + { + var enabled = false; + var tlsFragment = new DeferredValueProvider( + () => enabled ? ",ssl=true" : ""); + + var builder = new ReferenceExpressionBuilder(); + builder.AppendLiteral("localhost:6379"); + builder.Append($"{tlsFragment}"); + var expression = builder.Build(); + + // Before enabling, runtime value does not include TLS + var valueBefore = await expression.GetValueAsync(new(), default); + Assert.Equal("localhost:6379", valueBefore); + + // Manifest expression also does not include TLS (captured at build time) + Assert.Equal("localhost:6379", expression.ValueExpression); + + // After enabling, runtime value includes TLS dynamically + enabled = true; + var valueAfter = await expression.GetValueAsync(new(), default); + Assert.Equal("localhost:6379,ssl=true", valueAfter); + + // But the manifest expression was captured at build time (when enabled was false), + // so it still shows the old value + Assert.Equal("localhost:6379", expression.ValueExpression); + + // A newly built expression captures the updated manifest expression + var builder2 = new ReferenceExpressionBuilder(); + builder2.AppendLiteral("localhost:6379"); + builder2.Append($"{tlsFragment}"); + var expression2 = builder2.Build(); + Assert.Equal("localhost:6379,ssl=true", expression2.ValueExpression); + } +} From a5aa70ab4cebd56c79a3a694c0498357614cf658 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 3 Mar 2026 15:26:07 -0800 Subject: [PATCH 02/45] Respond to PR feedback --- src/Aspire.Hosting.Redis/RedisResource.cs | 2 +- src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs | 7 ++++++- src/Aspire.Hosting/ApplicationModel/EndpointReference.cs | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Redis/RedisResource.cs b/src/Aspire.Hosting.Redis/RedisResource.cs index cc0acc2e973..1aad7e93f3b 100644 --- a/src/Aspire.Hosting.Redis/RedisResource.cs +++ b/src/Aspire.Hosting.Redis/RedisResource.cs @@ -80,7 +80,7 @@ private ReferenceExpression BuildConnectionString() builder.Append($",password={PasswordParameter}"); } - builder.Append($"{PrimaryEndpoint.TlsValue(",ssl=true")}"); + builder.Append($"{PrimaryEndpoint.GetTlsValue(enabledValue: ",ssl=true", disabledValue: null)}"); return builder.Build(); } diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index b97b8492120..fc367b73588 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -22,6 +22,7 @@ public sealed class EndpointAnnotation : IResourceAnnotation private bool _portSetToNull; private int? _targetPort; private bool _targetPortSetToNull; + private bool? _tlsEnabled; private readonly NetworkIdentifier _networkID; /// @@ -194,7 +195,11 @@ public string Transport /// being set to https already implies TLS. This property is primarily useful for non-HTTP protocols /// (e.g., Redis, databases) that need explicit TLS configuration in their connection strings. /// - public bool TlsEnabled { get; set; } + public bool TlsEnabled + { + get => _tlsEnabled ?? (UriScheme == "https"); + set => _tlsEnabled = value; + } /// /// Gets or sets a value indicating whether the endpoint is from a launch profile. diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index a29f042fdc3..8fbe7e58118 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -138,7 +138,7 @@ public EndpointReferenceExpression Property(EndpointProperty property) /// The value to return when TLS is enabled (e.g., ",ssl=true"). /// The value to return when TLS is not enabled. Defaults to an empty string. /// A whose value tracks the TLS state of this endpoint. - public DeferredValueProvider TlsValue(string enabledValue, string disabledValue = "") + public DeferredValueProvider GetTlsValue(string enabledValue, string? disabledValue) { return new DeferredValueProvider( () => TlsEnabled ? enabledValue : disabledValue); From 19280d26954d17fbb7468cba66506aa7c58e2755 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 3 Mar 2026 15:35:10 -0800 Subject: [PATCH 03/45] Update comment --- src/Aspire.Hosting.Redis/RedisResource.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Redis/RedisResource.cs b/src/Aspire.Hosting.Redis/RedisResource.cs index 1aad7e93f3b..ad9df607ee2 100644 --- a/src/Aspire.Hosting.Redis/RedisResource.cs +++ b/src/Aspire.Hosting.Redis/RedisResource.cs @@ -53,7 +53,7 @@ public RedisResource(string name, ParameterResource password) : this(name) public ParameterResource? PasswordParameter { get; private set; } /// - /// Gets or sets a value indicating whether TLS is enabled for the Redis server. + /// Indicates whether TLS is enabled for the Redis server. /// /// /// This property proxies through to on the From 9bde1bc7ae4842807b5a24407e491a87808743b2 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 3 Mar 2026 15:40:29 -0800 Subject: [PATCH 04/45] Update baselines for code generation tests --- .../TwoPassScanningGeneratedAspire.verified.go | 12 ++++++++++++ .../TwoPassScanningGeneratedAspire.verified.java | 7 +++++++ .../TwoPassScanningGeneratedAspire.verified.py | 5 +++++ .../TwoPassScanningGeneratedAspire.verified.rs | 8 ++++++++ .../TwoPassScanningGeneratedAspire.verified.ts | 10 ++++++++++ 5 files changed, 42 insertions(+) diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 866433c963f..a0b5b44d47e 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -1207,6 +1207,18 @@ func (s *EndpointReference) IsHttps() (*bool, error) { return result.(*bool), nil } +// TlsEnabled gets the TlsEnabled property +func (s *EndpointReference) TlsEnabled() (*bool, error) { + reqArgs := map[string]any{ + "context": SerializeValue(s.Handle()), + } + result, err := s.Client().InvokeCapability("Aspire.Hosting.ApplicationModel/EndpointReference.tlsEnabled", reqArgs) + if err != nil { + return nil, err + } + return result.(*bool), nil +} + // Port gets the Port property func (s *EndpointReference) Port() (*float64, error) { reqArgs := map[string]any{ diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 94497b86860..c05f824250a 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -1123,6 +1123,13 @@ public boolean isHttps() { return (boolean) getClient().invokeCapability("Aspire.Hosting.ApplicationModel/EndpointReference.isHttps", reqArgs); } + /** Gets the TlsEnabled property */ + public boolean tlsEnabled() { + Map reqArgs = new HashMap<>(); + reqArgs.put("context", AspireClient.serializeValue(getHandle())); + return (boolean) getClient().invokeCapability("Aspire.Hosting.ApplicationModel/EndpointReference.tlsEnabled", reqArgs); + } + /** Gets the Port property */ public double port() { Map reqArgs = new HashMap<>(); diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index a783ab1a047..f7e733b29c8 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -710,6 +710,11 @@ def is_https(self) -> bool: args: Dict[str, Any] = { "context": serialize_value(self._handle) } return self._client.invoke_capability("Aspire.Hosting.ApplicationModel/EndpointReference.isHttps", args) + def tls_enabled(self) -> bool: + """Gets the TlsEnabled property""" + args: Dict[str, Any] = { "context": serialize_value(self._handle) } + return self._client.invoke_capability("Aspire.Hosting.ApplicationModel/EndpointReference.tlsEnabled", args) + def port(self) -> float: """Gets the Port property""" args: Dict[str, Any] = { "context": serialize_value(self._handle) } diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 883d775f201..49b30c943d3 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -1396,6 +1396,14 @@ impl EndpointReference { Ok(serde_json::from_value(result)?) } + /// Gets the TlsEnabled property + pub fn tls_enabled(&self) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("context".to_string(), self.handle.to_json()); + let result = self.client.invoke_capability("Aspire.Hosting.ApplicationModel/EndpointReference.tlsEnabled", args)?; + Ok(serde_json::from_value(result)?) + } + /// Gets the Port property pub fn port(&self) -> Result> { let mut args: HashMap = HashMap::new(); diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index 6e081485406..2da866cacab 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -673,6 +673,16 @@ export class EndpointReference { }, }; + /** Gets the TlsEnabled property */ + tlsEnabled = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.tlsEnabled', + { context: this._handle } + ); + }, + }; + /** Gets the Port property */ port = { get: async (): Promise => { From 3fbf2eff665b58aaf326c381a1cf69b86fc31126 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 3 Mar 2026 15:56:47 -0800 Subject: [PATCH 05/45] Update comments with links to redis scheme registrations --- src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs | 4 ++-- src/Aspire.Hosting.Redis/RedisResource.cs | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index dc6e227cedc..1d7bb58275c 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -91,7 +91,7 @@ public static IResourceBuilder AddRedis( builder.Services.AddHealthChecks().AddRedis(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); var redisBuilder = builder.AddResource(redis) - .WithEndpoint(port: port, targetPort: 6379, name: RedisResource.PrimaryEndpointName, scheme: "redis") + .WithEndpoint(port: port, targetPort: 6379, name: RedisResource.PrimaryEndpointName, scheme: RedisResource.StandardRedisScheme) .WithImage(RedisContainerImageTags.Image, RedisContainerImageTags.Tag) .WithImageRegistry(RedisContainerImageTags.Registry) .WithHealthCheck(healthCheckKey) @@ -183,7 +183,7 @@ public static IResourceBuilder AddRedis( .WithEndpoint(targetPort: 6380, name: RedisResource.SecondaryEndpointName) .WithEndpoint(RedisResource.PrimaryEndpointName, e => { - e.UriScheme = "rediss"; + e.UriScheme = RedisResource.TlsRedisScheme; e.TlsEnabled = true; }) .WithArgs(argsCtx => diff --git a/src/Aspire.Hosting.Redis/RedisResource.cs b/src/Aspire.Hosting.Redis/RedisResource.cs index ad9df607ee2..5cfec153a00 100644 --- a/src/Aspire.Hosting.Redis/RedisResource.cs +++ b/src/Aspire.Hosting.Redis/RedisResource.cs @@ -30,6 +30,16 @@ public RedisResource(string name, ParameterResource password) : this(name) // The non-TLS endpoint if TLS is enabled, otherwise not allocated internal const string SecondaryEndpointName = "secondary"; + /// + /// The standard URI scheme registered for Redis, similar to http. See: https://github.com/redis/redis-specifications/blob/1252427cdbc497f66a7f8550c6b5f2f35367dc92/uri/redis.txt + /// + internal const string StandardRedisScheme = "redis"; + + /// + /// The TLS URI scheme registered for Redis, similar to https. See: https://github.com/redis/redis-specifications/blob/1252427cdbc497f66a7f8550c6b5f2f35367dc92/uri/rediss.txt + /// + internal const string TlsRedisScheme = "rediss"; + private EndpointReference? _primaryEndpoint; /// From 0ecdf865a72985a62b3efe8ae25f720673fdb13d Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 3 Mar 2026 16:18:40 -0800 Subject: [PATCH 06/45] Make DeferredValueProvider async, use appropriate string comparison --- .../ApplicationModel/DeferredValueProvider.cs | 47 +++++++++---------- .../ApplicationModel/EndpointAnnotation.cs | 4 +- .../ApplicationModel/EndpointReference.cs | 3 +- .../DeferredValueProviderTests.cs | 32 +++++-------- 4 files changed, 40 insertions(+), 46 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs b/src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs index 58034c47d78..918aee4f29e 100644 --- a/src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs +++ b/src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs @@ -14,51 +14,50 @@ namespace Aspire.Hosting.ApplicationModel; /// until later in the application lifecycle (e.g., whether TLS has been enabled on an endpoint). /// /// -/// Callbacks receive a that provides access to the execution context, -/// the calling resource, and network information. When no separate manifest expression callback is provided, -/// the value callback is used for both and . A -/// return from the value callback is treated as an empty string for the manifest expression. +/// Value callbacks are asynchronous and receive a that provides access +/// to the execution context, the calling resource, and network information. A separate synchronous manifest +/// expression callback is required because is a +/// synchronous property. /// /// public class DeferredValueProvider : IValueProvider, IManifestExpressionProvider { - private readonly Func _valueCallback; - private readonly Func? _manifestExpressionCallback; + private readonly Func> _valueCallback; + private readonly Func _manifestExpressionCallback; /// - /// Initializes a new instance of with a context-free callback. + /// Initializes a new instance of with a context-free async callback. /// - /// A callback that returns the value. A return is treated - /// as an empty string for the manifest expression. Called each time the value is resolved. - /// An optional callback that returns the manifest expression string. - /// When , the is used for both runtime and manifest values. - public DeferredValueProvider(Func valueCallback, Func? manifestExpressionCallback = null) + /// An async callback that returns the value. Called each time the value is resolved. + /// A callback that returns the manifest expression string. + /// This is required because is synchronous + /// and cannot call the async . + public DeferredValueProvider(Func> valueCallback, Func manifestExpressionCallback) { ArgumentNullException.ThrowIfNull(valueCallback); + ArgumentNullException.ThrowIfNull(manifestExpressionCallback); _valueCallback = _ => valueCallback(); _manifestExpressionCallback = manifestExpressionCallback; } /// - /// Initializes a new instance of with a context-aware callback. + /// Initializes a new instance of with a context-aware async callback. /// - /// A callback that receives a and returns - /// the value. A return is treated as an empty string for the manifest expression. - /// Called each time the value is resolved. - /// An optional callback that returns the manifest expression string. - /// When , the is invoked with an empty - /// for the manifest expression. - public DeferredValueProvider(Func valueCallback, Func? manifestExpressionCallback = null) + /// An async callback that receives a and returns + /// the value. Called each time the value is resolved. + /// A callback that returns the manifest expression string. + /// This is required because is synchronous + /// and cannot call the async . + public DeferredValueProvider(Func> valueCallback, Func manifestExpressionCallback) { ArgumentNullException.ThrowIfNull(valueCallback); + ArgumentNullException.ThrowIfNull(manifestExpressionCallback); _valueCallback = valueCallback; _manifestExpressionCallback = manifestExpressionCallback; } /// - public string ValueExpression => _manifestExpressionCallback is not null - ? _manifestExpressionCallback() - : _valueCallback(new ValueProviderContext()) ?? ""; + public string ValueExpression => _manifestExpressionCallback(); /// public ValueTask GetValueAsync(CancellationToken cancellationToken = default) @@ -69,6 +68,6 @@ public DeferredValueProvider(Func valueCallback, /// public ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default) { - return new(_valueCallback(context)); + return _valueCallback(context); } } diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index fc367b73588..4ed71ad3b52 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -169,7 +169,7 @@ public int? TargetPort /// public string Transport { - get => _transport ?? (UriScheme == "http" || UriScheme == "https" ? "http" : Protocol.ToString().ToLowerInvariant()); + get => _transport ?? (string.Equals(UriScheme, "http", StringComparisons.EndpointAnnotationUriScheme) || string.Equals(UriScheme, "https", StringComparisons.EndpointAnnotationUriScheme) ? "http" : Protocol.ToString().ToLowerInvariant()); set => _transport = value; } @@ -197,7 +197,7 @@ public string Transport /// public bool TlsEnabled { - get => _tlsEnabled ?? (UriScheme == "https"); + get => _tlsEnabled ?? string.Equals(UriScheme, "https", StringComparisons.EndpointAnnotationUriScheme); set => _tlsEnabled = value; } diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index 8fbe7e58118..75f3dd1e815 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -141,7 +141,8 @@ public EndpointReferenceExpression Property(EndpointProperty property) public DeferredValueProvider GetTlsValue(string enabledValue, string? disabledValue) { return new DeferredValueProvider( - () => TlsEnabled ? enabledValue : disabledValue); + () => new ValueTask(TlsEnabled ? enabledValue : disabledValue), + () => TlsEnabled ? enabledValue ?? "" : disabledValue ?? ""); } /// diff --git a/tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs b/tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs index 330e6be6790..62474fadb89 100644 --- a/tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs +++ b/tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs @@ -9,7 +9,7 @@ public class DeferredValueProviderTests public async Task GetValueAsync_ReturnsCallbackResult() { var provider = new DeferredValueProvider( - () => "hello", + () => new ValueTask("hello"), () => "manifest-expression"); var value = await provider.GetValueAsync(); @@ -20,7 +20,7 @@ public async Task GetValueAsync_ReturnsCallbackResult() public async Task GetValueAsync_ReturnsNullWhenCallbackReturnsNull() { var provider = new DeferredValueProvider( - () => null, + () => new ValueTask((string?)null), () => ""); var value = await provider.GetValueAsync(); @@ -31,7 +31,7 @@ public async Task GetValueAsync_ReturnsNullWhenCallbackReturnsNull() public void ValueExpression_ReturnsManifestCallbackResult() { var provider = new DeferredValueProvider( - () => "runtime-value", + () => new ValueTask("runtime-value"), () => "manifest-expression"); Assert.Equal("manifest-expression", provider.ValueExpression); @@ -42,6 +42,7 @@ public async Task GetValueAsync_ResolvesLazilyFromCurrentState() { var enabled = false; var provider = new DeferredValueProvider( + () => new ValueTask(enabled ? ",ssl=true" : ""), () => enabled ? ",ssl=true" : ""); // Before enabling @@ -70,8 +71,9 @@ public async Task GetValueAsync_WithContext_ReceivesValueProviderContext() (ctx) => { capturedContext = ctx; - return "context-aware-value"; - }); + return new ValueTask("context-aware-value"); + }, + () => "context-aware-value"); var value = await provider.GetValueAsync(context); Assert.Equal("context-aware-value", value); @@ -79,19 +81,20 @@ public async Task GetValueAsync_WithContext_ReceivesValueProviderContext() } [Fact] - public void ValueExpression_WithContextCallback_InvokesWithEmptyContext() + public void ValueExpression_UsesManifestCallback() { var provider = new DeferredValueProvider( - (ValueProviderContext _) => "context-value"); + (ValueProviderContext _) => new ValueTask("runtime-value"), + () => "manifest-expression"); - Assert.Equal("context-value", provider.ValueExpression); + Assert.Equal("manifest-expression", provider.ValueExpression); } [Fact] public async Task GetValueAsync_WithContextAndManifestCallback_UsesIndependentCallbacks() { var provider = new DeferredValueProvider( - (ValueProviderContext _) => "runtime-value", + (ValueProviderContext _) => new ValueTask("runtime-value"), () => "manifest-expression"); var value = await provider.GetValueAsync(); @@ -99,21 +102,12 @@ public async Task GetValueAsync_WithContextAndManifestCallback_UsesIndependentCa Assert.Equal("manifest-expression", provider.ValueExpression); } - [Fact] - public async Task SingleCallback_NullReturnsTreatedAsEmptyForManifest() - { - var provider = new DeferredValueProvider(() => (string?)null); - - var value = await provider.GetValueAsync(); - Assert.Null(value); - Assert.Equal("", provider.ValueExpression); - } - [Fact] public async Task DeferredValueProvider_WorksInReferenceExpressionBuilder() { var enabled = false; var tlsFragment = new DeferredValueProvider( + () => new ValueTask(enabled ? ",ssl=true" : ""), () => enabled ? ",ssl=true" : ""); var builder = new ReferenceExpressionBuilder(); From fc4a4b546eb3ae8e93a237e7cb30770a64929f7a Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 3 Mar 2026 17:19:59 -0800 Subject: [PATCH 07/45] Relax scheme constraints in manifest schema --- .../ContainerAppContext.cs | 20 ++++++++-------- src/Schema/aspire-8.0.json | 3 +-- .../AzureContainerAppsTests.cs | 23 +++++++++++++++---- .../AzureManagedRedisExtensionsTests.cs | 2 +- .../AzureRedisExtensionsTests.cs | 2 +- .../ManifestGenerationTests.cs | 4 ++-- 6 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs index 6fac60a28b1..8beee80b739 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs @@ -145,12 +145,13 @@ protected override void ProcessEndpoints() return; } - // Only http, https, and tcp are supported - var unsupportedEndpoints = resolvedEndpoints.Where(r => r.Endpoint.UriScheme is not ("tcp" or "http" or "https")).ToArray(); + // Validate transport layer: only http-based and tcp transports are supported by Container Apps. + // The URI scheme (e.g. "redis", "rediss", "foo") is independent of transport. + var unsupportedEndpoints = resolvedEndpoints.Where(r => r.Endpoint.Transport is not ("http" or "http2" or "tcp")).ToArray(); if (unsupportedEndpoints.Length > 0) { - throw new NotSupportedException($"The endpoint(s) {string.Join(", ", unsupportedEndpoints.Select(r => $"'{r.Endpoint.Name}'"))} specify an unsupported scheme. The supported schemes are 'http', 'https', and 'tcp'."); + throw new NotSupportedException($"The endpoint(s) {string.Join(", ", unsupportedEndpoints.Select(r => $"'{r.Endpoint.Name}'"))} specify an unsupported transport. The supported transports are 'http', 'http2', and 'tcp'."); } // Group resolved endpoints by target port (aka destinations), this gives us the logical bindings or destinations @@ -162,9 +163,9 @@ protected override void ProcessEndpoints() Port = g.Key, ResolvedEndpoints = g.Select(x => x.resolved).ToArray(), External = g.Any(x => x.resolved.Endpoint.IsExternal), - IsHttpOnly = g.All(x => x.resolved.Endpoint.UriScheme is "http" or "https"), + IsHttpOnly = g.All(x => x.resolved.Endpoint.Transport is "http" or "http2"), AnyH2 = g.Any(x => x.resolved.Endpoint.Transport is "http2"), - UniqueSchemes = g.Select(x => x.resolved.Endpoint.UriScheme).Distinct().ToArray(), + UniqueTransports = g.Select(x => x.resolved.Endpoint.Transport).Distinct().ToArray(), Index = g.Min(x => x.index) }) .ToList(); @@ -183,12 +184,11 @@ protected override void ProcessEndpoints() 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"); + // Don't allow mixing http and tcp transports on the same target port + static bool Compatible(string[] transports) => + transports.All(t => t is "http" or "http2") || transports.All(t => t is "tcp"); - if (endpointsByTargetPort.Any(g => !Compatible(g.UniqueSchemes))) + if (endpointsByTargetPort.Any(g => !Compatible(g.UniqueTransports))) { throw new NotSupportedException("HTTP(s) and TCP endpoints cannot be mixed"); } diff --git a/src/Schema/aspire-8.0.json b/src/Schema/aspire-8.0.json index b4de8f96213..935be7cd1cb 100644 --- a/src/Schema/aspire-8.0.json +++ b/src/Schema/aspire-8.0.json @@ -767,8 +767,7 @@ "properties": { "scheme": { "type": "string", - "description": "The scheme used in URIs for this binding.", - "enum": [ "http", "https", "tcp", "udp" ] + "description": "The scheme used in URIs for this binding." }, "protocol": { "type": "string", diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 805b116aead..180e7ce1c41 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -1104,22 +1104,37 @@ await Verify(manifest.ToString(), "json") } [Fact] - public async Task NonTcpHttpOrUdpSchemeThrows() + public async Task NonHttpSchemeWithTcpTransportIsAllowed() { var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); builder.AddAzureContainerAppEnvironment("env"); builder.AddContainer("api", "myimage") - .WithEndpoint(scheme: "foo"); + .WithEndpoint(scheme: "redis", targetPort: 6379); using var app = builder.Build(); - var model = app.Services.GetRequiredService(); + // Custom schemes that use TCP transport should not throw + await ExecuteBeforeStartHooksAsync(app, default); + } + + [Fact] + public async Task UnsupportedTransportThrows() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureContainerAppEnvironment("env"); + + builder.AddContainer("api", "myimage") + .WithEndpoint(scheme: "foo", targetPort: 443, name: "foo") + .WithEndpoint("foo", e => e.Transport = "quic"); + + using var app = builder.Build(); var ex = await Assert.ThrowsAsync(() => ExecuteBeforeStartHooksAsync(app, default)); - Assert.Equal("The endpoint(s) 'foo' specify an unsupported scheme. The supported schemes are 'http', 'https', and 'tcp'.", ex.Message); + Assert.Equal("The endpoint(s) 'foo' specify an unsupported transport. The supported transports are 'http', 'http2', and 'tcp'.", ex.Message); } [Fact] diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureManagedRedisExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureManagedRedisExtensionsTests.cs index 885b757ea20..4ac97402062 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureManagedRedisExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureManagedRedisExtensionsTests.cs @@ -91,7 +91,7 @@ public async Task AddAzureManagedRedisRunAsContainerProducesCorrectHostAndPasswo Assert.Equal(12455, endpoint.Port); Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); Assert.Equal("tcp", endpoint.Transport); - Assert.Equal("tcp", endpoint.UriScheme); + Assert.Equal("redis", endpoint.UriScheme); Assert.True(redis.Resource.IsContainer(), "The resource should now be a container resource."); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs index 4f49f92956a..9e7c7db5dfa 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs @@ -135,7 +135,7 @@ public async Task AddAzureRedisRunAsContainerProducesCorrectHostAndPassword() Assert.Equal(12455, endpoint.Port); Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); Assert.Equal("tcp", endpoint.Transport); - Assert.Equal("tcp", endpoint.UriScheme); + Assert.Equal("redis", endpoint.UriScheme); Assert.True(redis.Resource.IsContainer(), "The resource should now be a container resource."); diff --git a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs index 7ded2b36aba..f738d4a0f56 100644 --- a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs +++ b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs @@ -382,7 +382,7 @@ public void VerifyTestProgramFullManifest() "REDIS_HOST": "{redis.bindings.tcp.host}", "REDIS_PORT": "{redis.bindings.tcp.port}", "REDIS_PASSWORD": "{redis-password.value}", - "REDIS_URI": "redis://:{redis-password-uri-encoded.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", + "REDIS_URI": "{redis.bindings.tcp.scheme}://:{redis-password-uri-encoded.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", "ConnectionStrings__postgresdb": "{postgresdb.connectionString}", "POSTGRESDB_HOST": "{postgres.bindings.tcp.host}", "POSTGRESDB_PORT": "{postgres.bindings.tcp.port}", @@ -419,7 +419,7 @@ public void VerifyTestProgramFullManifest() }, "bindings": { "tcp": { - "scheme": "tcp", + "scheme": "redis", "protocol": "tcp", "transport": "tcp", "targetPort": 6379 From 42a8315af03a4258b688ab4d963c91a96eebf22b Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 3 Mar 2026 17:40:48 -0800 Subject: [PATCH 08/45] Omit the manifest schema going forward. Keeps schema tests to avoid regressions in output. --- .../Publishing/ManifestPublishingContext.cs | 1 - .../AzureContainerAppsTests.cs | 2 +- ...ServiceEnvironmentsSupported.verified.json | 1 - ...inerAppEnvironmentsSupported.verified.json | 1 - ...ourceSpecificPath_Dockerfile.verified.json | 1 - ...erfileToResourceSpecificPath.verified.json | 1 - .../ManifestGenerationTests.cs | 33 +++++++++---------- 7 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index 64d5b3a79ed..6e7232e77aa 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -85,7 +85,6 @@ internal async Task WriteModel(DistributedApplicationModel model, CancellationTo } Writer.WriteStartObject(); - Writer.WriteString("$schema", SchemaUtils.SchemaVersion); Writer.WriteStartObject("resources"); foreach (var resource in model.Resources) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 180e7ce1c41..c6476688bfa 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -2301,7 +2301,7 @@ public async Task MultipleComputeEnvironmentsOnlyProcessTargetedResources() var aca = builder.AddAzureContainerAppEnvironment("aca"); var appService = builder.AddAzureAppServiceEnvironment("appservice"); - // Project targeted to ACA + // Project targeted to ACA var webappaca = builder.AddProject("webappaca", launchProfileName: null) .WithHttpEndpoint() .WithExternalHttpEndpoints() diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.MultipleAzureAppServiceEnvironmentsSupported.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.MultipleAzureAppServiceEnvironmentsSupported.verified.json index 57a106651bc..f444e0a16c3 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.MultipleAzureAppServiceEnvironmentsSupported.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.MultipleAzureAppServiceEnvironmentsSupported.verified.json @@ -1,5 +1,4 @@ { - "$schema": "https://json.schemastore.org/aspire-8.0.json", "resources": { "env1-acr": { "type": "azure.bicep.v0", diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleAzureContainerAppEnvironmentsSupported.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleAzureContainerAppEnvironmentsSupported.verified.json index 5b1ff4d2335..90767a4c7c5 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleAzureContainerAppEnvironmentsSupported.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleAzureContainerAppEnvironmentsSupported.verified.json @@ -1,5 +1,4 @@ { - "$schema": "https://json.schemastore.org/aspire-8.0.json", "resources": { "env1-acr": { "type": "azure.bicep.v0", diff --git a/tests/Aspire.Hosting.Containers.Tests/Snapshots/ManifestPublishingWritesDockerfileToResourceSpecificPath_Dockerfile.verified.json b/tests/Aspire.Hosting.Containers.Tests/Snapshots/ManifestPublishingWritesDockerfileToResourceSpecificPath_Dockerfile.verified.json index 0e154091257..9eca9db1286 100644 --- a/tests/Aspire.Hosting.Containers.Tests/Snapshots/ManifestPublishingWritesDockerfileToResourceSpecificPath_Dockerfile.verified.json +++ b/tests/Aspire.Hosting.Containers.Tests/Snapshots/ManifestPublishingWritesDockerfileToResourceSpecificPath_Dockerfile.verified.json @@ -1,5 +1,4 @@ { - "$schema": "https://json.schemastore.org/aspire-8.0.json", "resources": { "testcontainer": { "type": "container.v1", diff --git a/tests/Aspire.Hosting.Containers.Tests/Snapshots/WithDockerfileTests.ManifestPublishingWritesDockerfileToResourceSpecificPath.verified.json b/tests/Aspire.Hosting.Containers.Tests/Snapshots/WithDockerfileTests.ManifestPublishingWritesDockerfileToResourceSpecificPath.verified.json index f3445a62e43..e550f44a350 100644 --- a/tests/Aspire.Hosting.Containers.Tests/Snapshots/WithDockerfileTests.ManifestPublishingWritesDockerfileToResourceSpecificPath.verified.json +++ b/tests/Aspire.Hosting.Containers.Tests/Snapshots/WithDockerfileTests.ManifestPublishingWritesDockerfileToResourceSpecificPath.verified.json @@ -1,5 +1,4 @@ { - "$schema": "https://json.schemastore.org/aspire-8.0.json", "resources": { "testcontainer": { "type": "container.v1", diff --git a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs index f738d4a0f56..1ff994232b2 100644 --- a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs +++ b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs @@ -296,7 +296,6 @@ public void VerifyTestProgramFullManifest() var expectedManifest = $$""" { - "$schema": "{{SchemaUtils.SchemaVersion}}", "resources": { "servicea": { "type": "project.v0", @@ -558,10 +557,10 @@ public async Task ContainerFilesAreWrittenToManifest() // Create a destination container with ContainerFilesDestinationAnnotation var destContainer = builder.AddContainer("dest", "nginx:alpine") - .WithAnnotation(new ContainerFilesDestinationAnnotation - { - Source = sourceContainer.Resource, - DestinationPath = "/usr/share/nginx/html" + .WithAnnotation(new ContainerFilesDestinationAnnotation + { + Source = sourceContainer.Resource, + DestinationPath = "/usr/share/nginx/html" }); builder.Build().Run(); @@ -601,10 +600,10 @@ public async Task ContainerFilesWithMultipleSourcesAreWrittenToManifest() // Create a destination container with ContainerFilesDestinationAnnotation var destContainer = builder.AddContainer("dest", "nginx:alpine") - .WithAnnotation(new ContainerFilesDestinationAnnotation - { - Source = sourceContainer.Resource, - DestinationPath = "/usr/share/nginx/html" + .WithAnnotation(new ContainerFilesDestinationAnnotation + { + Source = sourceContainer.Resource, + DestinationPath = "/usr/share/nginx/html" }); builder.Build().Run(); @@ -647,15 +646,15 @@ public async Task ContainerFilesWithMultipleDestinationsAreWrittenToManifest() // Create a destination container with multiple ContainerFilesDestinationAnnotations var destContainer = builder.AddContainer("dest", "nginx:alpine") - .WithAnnotation(new ContainerFilesDestinationAnnotation - { - Source = source1.Resource, - DestinationPath = "/usr/share/nginx/html" + .WithAnnotation(new ContainerFilesDestinationAnnotation + { + Source = source1.Resource, + DestinationPath = "/usr/share/nginx/html" }) - .WithAnnotation(new ContainerFilesDestinationAnnotation - { - Source = source2.Resource, - DestinationPath = "/usr/share/nginx/assets" + .WithAnnotation(new ContainerFilesDestinationAnnotation + { + Source = source2.Resource, + DestinationPath = "/usr/share/nginx/assets" }); builder.Build().Run(); From c47ee7905a6644f0b8ae966fc495daeecc8cdd22 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 6 Mar 2026 20:20:25 -0800 Subject: [PATCH 09/45] Add ConditionalReferenceExpression with polyglot codegen support Replace DeferredValueProvider with ConditionalReferenceExpression, a new type that models conditional values in connection strings (e.g., TLS ssl=true/empty). The CRE auto-generates its manifest name from the condition's ValueExpression at construction time. Key changes: - ConditionalReferenceExpression type with Create() factory, auto-name generation via condition sanitization, and manifest value.v0 support - EndpointReference.GetTlsValue returns CRE instead of using closure - ManifestPublishingContext writes CRE entries as value.v0 resources - Polyglot create()/toJSON() in all 5 ATS base files (TS, Go, Python, Java, Rust) with $condExpr JSON serialization format - ConditionalReferenceExpressionRef for server-side $condExpr unmarshalling in AtsMarshaller - Redis connection string tests updated with pattern matching to handle auto-generated CRE names and verify manifest value entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AtsGoCodeGenerator.cs | 15 +- .../Resources/base.go | 52 ++++ .../AtsJavaCodeGenerator.cs | 16 +- .../Resources/Base.java | 55 ++++ .../AtsPythonCodeGenerator.cs | 19 +- .../Resources/base.py | 39 +++ .../AtsRustCodeGenerator.cs | 24 +- .../Resources/base.rs | 65 +++++ .../AtsTypeScriptCodeGenerator.cs | 17 +- .../Resources/base.ts | 91 +++++++ src/Aspire.Hosting.Redis/RedisResource.cs | 4 +- .../Ats/AtsMarshaller.cs | 8 + .../Ats/ConditionalReferenceExpressionRef.cs | 135 ++++++++++ .../ConditionalReferenceExpression.cs | 176 ++++++++++++ .../ApplicationModel/DeferredValueProvider.cs | 73 ----- .../ApplicationModel/EndpointReference.cs | 32 ++- src/Aspire.Hosting/Ats/AtsConstants.cs | 5 + .../Publishing/ManifestPublishingContext.cs | 32 +++ ...TwoPassScanningGeneratedAspire.verified.go | 16 ++ ...oPassScanningGeneratedAspire.verified.java | 13 +- ...TwoPassScanningGeneratedAspire.verified.py | 18 +- ...TwoPassScanningGeneratedAspire.verified.rs | 16 +- ...TwoPassScanningGeneratedAspire.verified.ts | 21 +- .../AddRedisTests.cs | 219 ++++++++------- .../AtsMarshallerTests.cs | 251 ++++++++++++++++++ .../HandleRegistryTests.cs | 30 +++ .../ConditionalReferenceExpressionTests.cs | 220 +++++++++++++++ .../DeferredValueProviderTests.cs | 141 ---------- .../Utils/ManifestUtils.cs | 21 ++ 29 files changed, 1469 insertions(+), 355 deletions(-) create mode 100644 src/Aspire.Hosting.RemoteHost/Ats/ConditionalReferenceExpressionRef.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/ConditionalReferenceExpression.cs delete mode 100644 src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs create mode 100644 tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs delete mode 100644 tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs diff --git a/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs index d6372258a5e..f74c4f64201 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs @@ -160,6 +160,12 @@ private void GenerateDtoTypes(IReadOnlyList dtoTypes) continue; } + // Skip ConditionalReferenceExpression - it's defined in base.go + if (dto.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + continue; + } + var dtoName = _dtoNames[dto.TypeId]; WriteLine($"// {dtoName} represents {dto.Name}."); WriteLine($"type {dtoName} struct {{"); @@ -536,6 +542,7 @@ private IReadOnlyList BuildHandleTypes(AtsContext context) { // Skip ReferenceExpression and CancellationToken - they're defined in base.go/transport.go if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId + || handleType.AtsTypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(handleType.AtsTypeId)) { continue; @@ -658,6 +665,11 @@ private string MapTypeRefToGo(AtsTypeRef? typeRef, bool isOptional) return "*ReferenceExpression"; } + if (typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + return "*ConditionalReferenceExpression"; + } + var baseType = typeRef.Category switch { AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId), @@ -719,8 +731,9 @@ private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsType return; } - // Skip ReferenceExpression and CancellationToken - they're defined in base.go/transport.go + // Skip ReferenceExpression, ConditionalReferenceExpression and CancellationToken - they're defined in base.go/transport.go if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId + || typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(typeRef.TypeId)) { return; diff --git a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go index 2be797c6c7f..e19e0d086f0 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go +++ b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go @@ -62,6 +62,58 @@ func (r *ReferenceExpression) ToJSON() map[string]any { } } +// ConditionalReferenceExpression represents a conditional expression that selects +// between two ReferenceExpression branches based on a boolean condition. +// The condition and branches are evaluated on the AppHost server. +type ConditionalReferenceExpression struct { + // Expression mode fields (from CreateConditionalReferenceExpression) + Condition any + WhenTrue *ReferenceExpression + WhenFalse *ReferenceExpression + + // Handle mode fields (when wrapping a server-returned handle) + handle *Handle + client *AspireClient +} + +// NewConditionalReferenceExpression creates a new ConditionalReferenceExpression from a handle. +func NewConditionalReferenceExpression(handle *Handle, client *AspireClient) *ConditionalReferenceExpression { + return &ConditionalReferenceExpression{handle: handle, client: client} +} + +// CreateConditionalReferenceExpression creates a conditional reference expression from its parts. +func CreateConditionalReferenceExpression(condition any, whenTrue *ReferenceExpression, whenFalse *ReferenceExpression) *ConditionalReferenceExpression { + return &ConditionalReferenceExpression{ + Condition: condition, + WhenTrue: whenTrue, + WhenFalse: whenFalse, + } +} + +// ToJSON returns the conditional reference expression as a JSON-serializable map. +func (c *ConditionalReferenceExpression) ToJSON() map[string]any { + if c.handle != nil { + return c.handle.ToJSON() + } + return map[string]any{ + "$condExpr": map[string]any{ + "condition": SerializeValue(c.Condition), + "whenTrue": c.WhenTrue.ToJSON(), + "whenFalse": c.WhenFalse.ToJSON(), + }, + } +} + +// Handle returns the underlying handle, if in handle mode. +func (c *ConditionalReferenceExpression) Handle() *Handle { + return c.handle +} + +// Client returns the AspireClient, if in handle mode. +func (c *ConditionalReferenceExpression) Client() *AspireClient { + return c.client +} + // AspireList is a handle-backed list with lazy handle resolution. type AspireList[T any] struct { HandleWrapperBase diff --git a/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs index a69814b8ef7..e8a95f4bbc0 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs @@ -179,6 +179,11 @@ private void GenerateDtoTypes(IReadOnlyList dtoTypes) continue; } + if (dto.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + continue; + } + var dtoName = _dtoNames[dto.TypeId]; WriteLine($"/** {dto.Name} DTO. */"); WriteLine($"class {dtoName} {{"); @@ -501,8 +506,9 @@ private IReadOnlyList BuildHandleTypes(AtsContext context) var handleTypeIds = new HashSet(StringComparer.Ordinal); foreach (var handleType in context.HandleTypes) { - // Skip ReferenceExpression and CancellationToken - they're defined in Base.java/Transport.java + // Skip ReferenceExpression, ConditionalReferenceExpression and CancellationToken - they're defined in Base.java/Transport.java if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId + || handleType.AtsTypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(handleType.AtsTypeId)) { continue; @@ -623,6 +629,11 @@ private string MapTypeRefToJava(AtsTypeRef? typeRef, bool isOptional) return "ReferenceExpression"; } + if (typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + return "ConditionalReferenceExpression"; + } + var baseType = typeRef.Category switch { AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId, isOptional), @@ -683,8 +694,9 @@ private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsType return; } - // Skip ReferenceExpression and CancellationToken - they're defined in Base.java/Transport.java + // Skip ReferenceExpression, ConditionalReferenceExpression and CancellationToken - they're defined in Base.java/Transport.java if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId + || typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(typeRef.TypeId)) { return; diff --git a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java index 98a1aa43838..699e7fc696d 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java +++ b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java @@ -73,6 +73,61 @@ static ReferenceExpression refExpr(String format, Object... args) { } } +/** + * ConditionalReferenceExpression represents a conditional expression that selects + * between two ReferenceExpression branches. The condition and branches are evaluated + * on the AppHost server. + */ +class ConditionalReferenceExpression { + // Expression mode fields + private final Object condition; + private final ReferenceExpression whenTrue; + private final ReferenceExpression whenFalse; + + // Handle mode fields + private final Handle handle; + private final AspireClient client; + + // Handle mode constructor + ConditionalReferenceExpression(Handle handle, AspireClient client) { + this.handle = handle; + this.client = client; + this.condition = null; + this.whenTrue = null; + this.whenFalse = null; + } + + // Expression mode constructor + private ConditionalReferenceExpression(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { + this.handle = null; + this.client = null; + this.condition = condition; + this.whenTrue = whenTrue; + this.whenFalse = whenFalse; + } + + /** + * Creates a conditional reference expression from its parts. + */ + static ConditionalReferenceExpression create(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { + return new ConditionalReferenceExpression(condition, whenTrue, whenFalse); + } + + Map toJson() { + if (handle != null) { + return handle.toJson(); + } + var condExpr = new java.util.HashMap(); + condExpr.put("condition", AspireClient.serializeValue(condition)); + condExpr.put("whenTrue", whenTrue.toJson()); + condExpr.put("whenFalse", whenFalse.toJson()); + + var result = new java.util.HashMap(); + result.put("$condExpr", condExpr); + return result; + } +} + /** * AspireList is a handle-backed list with lazy handle resolution. */ diff --git a/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs index 7d995766203..2f79dad913b 100644 --- a/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs @@ -104,7 +104,7 @@ private void WriteHeader() WriteLine("from typing import Any, Callable, Dict, List"); WriteLine(); WriteLine("from transport import AspireClient, Handle, CapabilityError, register_callback, register_handle_wrapper, register_cancellation"); - WriteLine("from base import AspireDict, AspireList, ReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value"); + WriteLine("from base import AspireDict, AspireList, ReferenceExpression, ConditionalReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value"); WriteLine(); } @@ -203,6 +203,12 @@ private void GenerateHandleTypes( foreach (var handleType in handleTypes.OrderBy(t => t.ClassName, StringComparer.Ordinal)) { + // Skip types defined in base.py (ReferenceExpression, ConditionalReferenceExpression) + if (handleType.TypeId == AtsConstants.ReferenceExpressionTypeId || + handleType.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + continue; + } var baseClass = handleType.IsResourceBuilder ? "ResourceBuilderBase" : "HandleWrapperBase"; WriteLine($"class {handleType.ClassName}({baseClass}):"); WriteLine(" def __init__(self, handle: Handle, client: AspireClient):"); @@ -357,6 +363,12 @@ private void GenerateHandleWrapperRegistrations( foreach (var handleType in handleTypes) { + // Skip types defined in base.py + if (handleType.TypeId == AtsConstants.ReferenceExpressionTypeId || + handleType.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + continue; + } WriteLine($"register_handle_wrapper(\"{handleType.TypeId}\", lambda handle, client: {handleType.ClassName}(handle, client))"); } @@ -576,6 +588,11 @@ private string MapTypeRefToPython(AtsTypeRef? typeRef) return nameof(ReferenceExpression); } + if (typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + return "ConditionalReferenceExpression"; + } + return typeRef.Category switch { AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId), diff --git a/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py b/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py index 8ba4c32d25b..185cefb6e35 100644 --- a/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py +++ b/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py @@ -29,6 +29,45 @@ def __str__(self) -> str: return f"ReferenceExpression({self._format_string})" +class ConditionalReferenceExpression: + """Represents a conditional expression that selects between two ReferenceExpression branches. + The condition and branches are evaluated on the AppHost server.""" + + def __init__(self, handle: "Handle", client: "AspireClient") -> None: + self._handle = handle + self._client = client + self._condition: Any = None + self._when_true: ReferenceExpression | None = None + self._when_false: ReferenceExpression | None = None + + @staticmethod + def create(condition: Any, when_true: ReferenceExpression, when_false: ReferenceExpression) -> "ConditionalReferenceExpression": + """Creates a conditional reference expression from its parts.""" + expr = ConditionalReferenceExpression.__new__(ConditionalReferenceExpression) + expr._handle = None + expr._client = None + expr._condition = condition + expr._when_true = when_true + expr._when_false = when_false + return expr + + def to_json(self) -> Dict[str, Any]: + if self._handle is not None: + return self._handle.to_json() + return { + "$condExpr": { + "condition": serialize_value(self._condition), + "whenTrue": self._when_true.to_json(), + "whenFalse": self._when_false.to_json(), + } + } + + def __str__(self) -> str: + if self._handle is not None: + return "ConditionalReferenceExpression(handle)" + return "ConditionalReferenceExpression(expr)" + + def ref_expr(format_string: str, *values: Any) -> ReferenceExpression: """Create a reference expression using a format string.""" return ReferenceExpression.create(format_string, *values) diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs index 32cd3090232..ac31e26bf40 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs @@ -119,7 +119,7 @@ private void WriteHeader() WriteLine(" register_callback, register_cancellation, serialize_value,"); WriteLine("};"); WriteLine("use crate::base::{"); - WriteLine(" HandleWrapperBase, ResourceBuilderBase, ReferenceExpression,"); + WriteLine(" HandleWrapperBase, ResourceBuilderBase, ReferenceExpression, ConditionalReferenceExpression,"); WriteLine(" AspireList, AspireDict, serialize_handle, HasHandle,"); WriteLine("};"); WriteLine(); @@ -199,6 +199,11 @@ private void GenerateDtoTypes(IReadOnlyList dtoTypes) continue; } + if (dto.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + continue; + } + var dtoName = _dtoNames[dto.TypeId]; WriteLine($"/// {dto.Name}"); WriteLine("#[derive(Debug, Clone, Default, Serialize, Deserialize)]"); @@ -568,8 +573,9 @@ private IReadOnlyList BuildHandleTypes(AtsContext context) var handleTypeIds = new HashSet(StringComparer.Ordinal); foreach (var handleType in context.HandleTypes) { - // Skip ReferenceExpression and CancellationToken - they're defined in base.rs/transport.rs + // Skip ReferenceExpression, ConditionalReferenceExpression and CancellationToken - they're defined in base.rs/transport.rs if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId + || handleType.AtsTypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(handleType.AtsTypeId)) { continue; @@ -689,6 +695,11 @@ private string MapTypeRefToRust(AtsTypeRef? typeRef, bool isOptional) return isOptional ? "Option" : "ReferenceExpression"; } + if (typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + return isOptional ? "Option" : "ConditionalReferenceExpression"; + } + var baseType = typeRef.Category switch { AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId), @@ -727,6 +738,11 @@ private string MapTypeRefToRustForDto(AtsTypeRef? typeRef, bool isOptional) return isOptional ? "Option" : "ReferenceExpression"; } + if (typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + return isOptional ? "Option" : "ConditionalReferenceExpression"; + } + var baseType = typeRef.Category switch { AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId), @@ -806,6 +822,7 @@ AtsConstants.DateTime or AtsConstants.DateTimeOffset or private static bool IsHandleType(AtsTypeRef? typeRef) => typeRef?.Category == AtsTypeCategory.Handle && typeRef.TypeId != AtsConstants.ReferenceExpressionTypeId + && typeRef.TypeId != AtsConstants.ConditionalReferenceExpressionTypeId && !IsCancellationTokenTypeId(typeRef.TypeId); private static bool IsCancellationToken(AtsParameterInfo parameter) => @@ -822,8 +839,9 @@ private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsType return; } - // Skip ReferenceExpression and CancellationToken - they're defined in base.rs/transport.rs + // Skip ReferenceExpression, ConditionalReferenceExpression and CancellationToken - they're defined in base.rs/transport.rs if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId + || typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(typeRef.TypeId)) { return; diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs index 2554b29de2d..bc6acf4b79a 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs @@ -74,6 +74,71 @@ impl ReferenceExpression { } } +/// A conditional reference expression that selects between two ReferenceExpression branches. +/// The condition and branches are evaluated on the AppHost server. +pub struct ConditionalReferenceExpression { + // Expression mode fields + condition: Option, + when_true: Option, + when_false: Option, + + // Handle mode fields + handle: Option, + client: Option>, +} + +impl ConditionalReferenceExpression { + /// Creates a new ConditionalReferenceExpression from a server-returned handle. + pub fn new(handle: Handle, client: Arc) -> Self { + Self { + condition: None, + when_true: None, + when_false: None, + handle: Some(handle), + client: Some(client), + } + } + + /// Creates a conditional reference expression from its parts. + pub fn create(condition: Value, when_true: ReferenceExpression, when_false: ReferenceExpression) -> Self { + Self { + name: None, + condition: Some(condition), + when_true: Some(when_true), + when_false: Some(when_false), + handle: None, + client: None, + } + } + + pub fn handle(&self) -> Option<&Handle> { + self.handle.as_ref() + } + + pub fn client(&self) -> Option<&Arc> { + self.client.as_ref() + } + + pub fn to_json(&self) -> Value { + if let Some(ref handle) = self.handle { + return handle.to_json(); + } + json!({ + "$condExpr": { + "condition": serialize_value(self.condition.clone().unwrap()), + "whenTrue": self.when_true.as_ref().unwrap().to_json(), + "whenFalse": self.when_false.as_ref().unwrap().to_json() + } + }) + } +} + +impl HasHandle for ConditionalReferenceExpression { + fn handle(&self) -> &Handle { + self.handle.as_ref().expect("ConditionalReferenceExpression is in expression mode, not handle mode") + } +} + /// Convenience function to create a reference expression. pub fn ref_expr(format: impl Into, args: Vec) -> ReferenceExpression { ReferenceExpression::new(format, args) diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs index 877bb64782f..b3845009204 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs @@ -340,6 +340,7 @@ private string GenerateAspireSdk(AtsContext context) import { ResourceBuilderBase, ReferenceExpression, + ConditionalReferenceExpression, refExpr, AspireDict, AspireList @@ -437,6 +438,8 @@ private string GenerateAspireSdk(AtsContext context) } // Add ReferenceExpression (defined in base.ts, not generated) _wrapperClassNames[AtsConstants.ReferenceExpressionTypeId] = "ReferenceExpression"; + // Add ConditionalReferenceExpression (defined in base.ts, not generated) + _wrapperClassNames[AtsConstants.ConditionalReferenceExpressionTypeId] = "ConditionalReferenceExpression"; // Pre-scan all capabilities to collect options interfaces // This must happen AFTER wrapper class names are populated so types resolve correctly @@ -456,8 +459,14 @@ private string GenerateAspireSdk(AtsContext context) GenerateOptionsInterfaces(); // Generate type classes (context types and wrapper types) + // Skip types defined in base.ts (ReferenceExpression, ConditionalReferenceExpression) foreach (var typeClass in typeClasses) { + if (typeClass.TypeId == AtsConstants.ReferenceExpressionTypeId || + typeClass.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + continue; + } GenerateTypeClass(typeClass); } @@ -1431,7 +1440,7 @@ export async function createBuilder(options?: CreateBuilderOptions): Promise typeClasses, WriteLine("// Register wrapper factories for typed handle wrapping in callbacks"); // Register type classes (context types like EnvironmentCallbackContext) + // Skip types defined in base.ts (their wrapper registration is handled differently) foreach (var typeClass in typeClasses) { + if (typeClass.TypeId == AtsConstants.ReferenceExpressionTypeId || + typeClass.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + { + continue; + } var className = _wrapperClassNames.GetValueOrDefault(typeClass.TypeId) ?? DeriveClassName(typeClass.TypeId); var handleType = GetHandleTypeName(typeClass.TypeId); WriteLine($"registerHandleWrapper('{typeClass.TypeId}', (handle, client) => new {className}(handle as {handleType}, client));"); diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts index 7778b0f1737..f8152cb45ed 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts @@ -177,6 +177,97 @@ export function refExpr(strings: TemplateStringsArray, ...values: unknown[]): Re return ReferenceExpression.create(strings, ...values); } +// ============================================================================ +// Conditional Reference Expression +// ============================================================================ + +/** + * Represents a conditional reference expression that selects between two + * {@link ReferenceExpression} branches based on a boolean condition. + * + * The condition and branches are evaluated on the AppHost server; the client + * receives the resolved result. + * + * @example + * ```typescript + * const endpoint = await redis.getEndpoint("tcp"); + * const tlsExpr = await endpoint.getTlsValue( + * "redis-tcp-tls-value", + * refExpr`,ssl=true`, + * refExpr`` + * ); + * ``` + */ +export class ConditionalReferenceExpression { + // Expression mode fields (from create()) + private readonly _condition?: unknown; + private readonly _whenTrue?: ReferenceExpression; + private readonly _whenFalse?: ReferenceExpression; + + // Handle mode fields (when wrapping a server-returned handle) + private readonly _handle?: Handle; + private readonly _client?: AspireClient; + + constructor(condition: unknown, whenTrue: ReferenceExpression, whenFalse: ReferenceExpression); + constructor(handle: Handle, client: AspireClient); + constructor( + handleOrCondition: Handle | unknown, + clientOrWhenTrue: AspireClient | ReferenceExpression, + whenFalse?: ReferenceExpression + ) { + if (handleOrCondition instanceof Handle) { + this._handle = handleOrCondition; + this._client = clientOrWhenTrue as AspireClient; + } else { + this._condition = handleOrCondition; + this._whenTrue = clientOrWhenTrue as ReferenceExpression; + this._whenFalse = whenFalse; + } + } + + /** + * Creates a conditional reference expression from its constituent parts. + * + * @param condition - A value provider whose result is compared to "True" + * @param whenTrue - The expression to use when the condition is true + * @param whenFalse - The expression to use when the condition is false + * @returns A ConditionalReferenceExpression instance + */ + static create( + condition: unknown, + whenTrue: ReferenceExpression, + whenFalse: ReferenceExpression + ): ConditionalReferenceExpression { + return new ConditionalReferenceExpression(condition, whenTrue, whenFalse); + } + + /** + * Serializes the conditional reference expression for JSON-RPC transport. + * In expression mode, uses the $condExpr format. + * In handle mode, delegates to the handle's serialization. + */ + toJSON(): { $condExpr: { condition: unknown; whenTrue: unknown; whenFalse: unknown } } | MarshalledHandle { + if (this._handle) { + return this._handle.toJSON(); + } + + return { + $condExpr: { + condition: wrapIfHandle(this._condition), + whenTrue: this._whenTrue!.toJSON(), + whenFalse: this._whenFalse!.toJSON() + } + }; + } + + toString(): string { + if (this._handle) { + return `ConditionalReferenceExpression(handle)`; + } + return `ConditionalReferenceExpression(expr)`; + } +} + // ============================================================================ // ResourceBuilderBase // ============================================================================ diff --git a/src/Aspire.Hosting.Redis/RedisResource.cs b/src/Aspire.Hosting.Redis/RedisResource.cs index 175138eff3d..6bbfd01410e 100644 --- a/src/Aspire.Hosting.Redis/RedisResource.cs +++ b/src/Aspire.Hosting.Redis/RedisResource.cs @@ -91,7 +91,9 @@ private ReferenceExpression BuildConnectionString() builder.Append($",password={PasswordParameter}"); } - builder.Append($"{PrimaryEndpoint.GetTlsValue(enabledValue: ",ssl=true", disabledValue: null)}"); + builder.Append($"{PrimaryEndpoint.GetTlsValue( + enabledValue: ReferenceExpression.Create($",ssl=true"), + disabledValue: ReferenceExpression.Empty)}"); return builder.Build(); } diff --git a/src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs b/src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs index 6fb5cff2bd5..e384cdd0cdc 100644 --- a/src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs +++ b/src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs @@ -308,6 +308,14 @@ public static bool IsSimpleType(Type type) return exprRef.ToReferenceExpression(_handles, capabilityId, paramName); } + // Check for conditional reference expression + // Format: { "$condExpr": { "name": "...", "condition": , "whenTrue": <$expr>, "whenFalse": <$expr> } } + var condExprRef = ConditionalReferenceExpressionRef.FromJsonNode(node); + if (condExprRef != null) + { + return condExprRef.ToConditionalReferenceExpression(_handles, capabilityId, paramName); + } + // Handle callbacks - any delegate type is treated as a callback if (typeof(Delegate).IsAssignableFrom(targetType)) { diff --git a/src/Aspire.Hosting.RemoteHost/Ats/ConditionalReferenceExpressionRef.cs b/src/Aspire.Hosting.RemoteHost/Ats/ConditionalReferenceExpressionRef.cs new file mode 100644 index 00000000000..c116b854a87 --- /dev/null +++ b/src/Aspire.Hosting.RemoteHost/Ats/ConditionalReferenceExpressionRef.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Nodes; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.RemoteHost.Ats; + +/// +/// Reference to a ConditionalReferenceExpression in the ATS protocol. +/// Used when passing conditional reference expressions as arguments. +/// +/// +/// +/// Conditional reference expressions are serialized in JSON as: +/// +/// +/// { +/// "$condExpr": { +/// "condition": { "$handle": "Aspire.Hosting.ApplicationModel/EndpointReferenceExpression:1" }, +/// "whenTrue": { "$expr": { "format": ",ssl=true" } }, +/// "whenFalse": { "$expr": { "format": "" } } +/// } +/// } +/// +/// +/// The condition is a handle to an object implementing . +/// The whenTrue and whenFalse branches are reference expressions (using $expr format). +/// +/// +internal sealed class ConditionalReferenceExpressionRef +{ + /// + /// The JSON node representing the condition (a handle to an IValueProvider). + /// + public required JsonNode? Condition { get; init; } + + /// + /// The JSON node representing the whenTrue branch (a $expr reference expression). + /// + public required JsonNode? WhenTrue { get; init; } + + /// + /// The JSON node representing the whenFalse branch (a $expr reference expression). + /// + public required JsonNode? WhenFalse { get; init; } + + /// + /// Creates a ConditionalReferenceExpressionRef from a JSON node if it contains a $condExpr property. + /// + /// The JSON node to parse. + /// A ConditionalReferenceExpressionRef if the node represents a conditional expression, otherwise null. + public static ConditionalReferenceExpressionRef? FromJsonNode(JsonNode? node) + { + if (node is not JsonObject obj || !obj.TryGetPropertyValue("$condExpr", out var condExprNode)) + { + return null; + } + + if (condExprNode is not JsonObject condExprObj) + { + return null; + } + + // Get condition (required) + condExprObj.TryGetPropertyValue("condition", out var conditionNode); + + // Get whenTrue (required) + condExprObj.TryGetPropertyValue("whenTrue", out var whenTrueNode); + + // Get whenFalse (required) + condExprObj.TryGetPropertyValue("whenFalse", out var whenFalseNode); + + return new ConditionalReferenceExpressionRef + { + Condition = conditionNode, + WhenTrue = whenTrueNode, + WhenFalse = whenFalseNode + }; + } + + /// + /// Checks if a JSON node is a conditional reference expression. + /// + /// The JSON node to check. + /// True if the node contains a $condExpr property. + public static bool IsConditionalReferenceExpressionRef(JsonNode? node) + { + return node is JsonObject obj && obj.ContainsKey("$condExpr"); + } + + /// + /// Creates a ConditionalReferenceExpression from this reference by resolving handles. + /// + /// The handle registry to resolve handles from. + /// The capability ID for error messages. + /// The parameter name for error messages. + /// A constructed ConditionalReferenceExpression. + /// Thrown if handles cannot be resolved or are invalid types. + public ConditionalReferenceExpression ToConditionalReferenceExpression( + HandleRegistry handles, + string capabilityId, + string paramName) + { + // Resolve the condition handle to an IValueProvider + var conditionHandleRef = HandleRef.FromJsonNode(Condition) + ?? throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.condition", + "Condition must be a handle reference ({ $handle: \"...\" })"); + + if (!handles.TryGet(conditionHandleRef.HandleId, out var conditionObj, out _)) + { + throw CapabilityException.HandleNotFound(conditionHandleRef.HandleId, capabilityId); + } + + if (conditionObj is not IValueProvider condition) + { + throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.condition", + $"Condition handle must resolve to an IValueProvider, got {conditionObj?.GetType().Name ?? "null"}"); + } + + // Resolve whenTrue as a ReferenceExpression + var whenTrueExprRef = ReferenceExpressionRef.FromJsonNode(WhenTrue) + ?? throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.whenTrue", + "whenTrue must be a reference expression ({ $expr: { ... } })"); + var whenTrue = whenTrueExprRef.ToReferenceExpression(handles, capabilityId, $"{paramName}.whenTrue"); + + // Resolve whenFalse as a ReferenceExpression + var whenFalseExprRef = ReferenceExpressionRef.FromJsonNode(WhenFalse) + ?? throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.whenFalse", + "whenFalse must be a reference expression ({ $expr: { ... } })"); + var whenFalse = whenFalseExprRef.ToReferenceExpression(handles, capabilityId, $"{paramName}.whenFalse"); + + return ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + } +} diff --git a/src/Aspire.Hosting/ApplicationModel/ConditionalReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ConditionalReferenceExpression.cs new file mode 100644 index 00000000000..99d6e03d2d7 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ConditionalReferenceExpression.cs @@ -0,0 +1,176 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a conditional value expression that selects between two branches +/// based on the string value of a condition. +/// +/// +/// +/// This type provides a declarative ternary-style expression that polyglot code generators can translate +/// into native conditional constructs (e.g., condition ? trueVal : falseVal in TypeScript, +/// trueVal if condition else falseVal in Python). +/// +/// +/// At runtime, the condition is evaluated and compared to . If the condition +/// matches, the branch is resolved; otherwise the branch is used. +/// +/// +/// For the publish manifest, the conditional is resolved at publish time and a value.v0 entry is +/// emitted. The is auto-generated from the condition's +/// and the references that entry using the property. +/// +/// +[DebuggerDisplay("ConditionalReferenceExpression = {ValueExpression}")] +[AspireExport(Description = "Represents an expression that evaluates to one of two branches based on the string value of a condition.", ExposeProperties = true)] +public class ConditionalReferenceExpression : IValueProvider, IManifestExpressionProvider, IValueWithReferences +{ + private static readonly ConcurrentDictionary s_nameCounters = new(StringComparer.OrdinalIgnoreCase); + + private readonly IValueProvider _condition; + + /// + /// Initializes a new instance of . + /// + /// A value provider whose result is compared to + /// to determine which branch to evaluate. Typically an with + /// . + /// The expression to evaluate when the condition is . + /// The expression to evaluate when the condition is . + private ConditionalReferenceExpression(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) + { + ArgumentNullException.ThrowIfNull(condition); + ArgumentNullException.ThrowIfNull(whenTrue); + ArgumentNullException.ThrowIfNull(whenFalse); + + _condition = condition; + WhenTrue = whenTrue; + WhenFalse = whenFalse; + Name = GenerateName(condition); + } + + /// + /// Creates a new with the specified condition and branch expressions. + /// + /// A value provider whose result is compared to . + /// The expression to evaluate when the condition is . + /// The expression to evaluate when the condition is . + /// A new . + public static ConditionalReferenceExpression Create(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) + { + return new ConditionalReferenceExpression(condition, whenTrue, whenFalse); + } + + /// + /// Gets the name of this conditional expression, used as the manifest resource name for the value.v0 entry. + /// + public string Name { get; } + + /// + /// Gets the condition value provider whose result is compared to . + /// + public IValueProvider Condition => _condition; + + /// + /// Gets the expression to evaluate when evaluates to . + /// + public ReferenceExpression WhenTrue { get; } + + /// + /// Gets the expression to evaluate when does not evaluate to . + /// + public ReferenceExpression WhenFalse { get; } + + /// + /// Gets the manifest expression that references the value.v0 entry. + /// + public string ValueExpression => $"{{{Name}.value}}"; + + /// + public async ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + var conditionValue = await _condition.GetValueAsync(cancellationToken).ConfigureAwait(false); + var branch = string.Equals(conditionValue, bool.TrueString, StringComparison.OrdinalIgnoreCase) ? WhenTrue : WhenFalse; + return await branch.GetValueAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default) + { + var conditionValue = await _condition.GetValueAsync(context, cancellationToken).ConfigureAwait(false); + var branch = string.Equals(conditionValue, bool.TrueString, StringComparison.OrdinalIgnoreCase) ? WhenTrue : WhenFalse; + return await branch.GetValueAsync(context, cancellationToken).ConfigureAwait(false); + } + + /// + public IEnumerable References + { + get + { + if (_condition is IValueWithReferences conditionRefs) + { + foreach (var reference in conditionRefs.References) + { + yield return reference; + } + } + + foreach (var reference in ((IValueWithReferences)WhenTrue).References) + { + yield return reference; + } + + foreach (var reference in ((IValueWithReferences)WhenFalse).References) + { + yield return reference; + } + } + } + + private static string GenerateName(IValueProvider condition) + { + string baseName; + + if (condition is IManifestExpressionProvider expressionProvider) + { + var expression = expressionProvider.ValueExpression; + var sanitized = SanitizeExpression(expression); + baseName = sanitized.Length > 0 ? $"cond-{sanitized}" : "cond-expr"; + } + else + { + baseName = "cond-expr"; + } + + var count = s_nameCounters.AddOrUpdate(baseName, 1, (_, existing) => existing + 1); + return count == 1 ? baseName : $"{baseName}-{count}"; + } + + private static string SanitizeExpression(string expression) + { + var builder = new StringBuilder(expression.Length); + var lastWasSeparator = false; + + foreach (var ch in expression) + { + if (char.IsLetterOrDigit(ch)) + { + builder.Append(char.ToLowerInvariant(ch)); + lastWasSeparator = false; + } + else if (!lastWasSeparator && builder.Length > 0) + { + builder.Append('-'); + lastWasSeparator = true; + } + } + + return builder.ToString().TrimEnd('-'); + } +} diff --git a/src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs b/src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs deleted file mode 100644 index 918aee4f29e..00000000000 --- a/src/Aspire.Hosting/ApplicationModel/DeferredValueProvider.cs +++ /dev/null @@ -1,73 +0,0 @@ -// 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; - -/// -/// A general-purpose value provider that resolves its value and manifest expression lazily via callbacks. -/// This enables dynamic values to be embedded in instances, where the -/// actual value is determined at resolution time rather than at expression build time. -/// -/// -/// -/// Use this type when a portion of a connection string or expression depends on state that isn't known -/// until later in the application lifecycle (e.g., whether TLS has been enabled on an endpoint). -/// -/// -/// Value callbacks are asynchronous and receive a that provides access -/// to the execution context, the calling resource, and network information. A separate synchronous manifest -/// expression callback is required because is a -/// synchronous property. -/// -/// -public class DeferredValueProvider : IValueProvider, IManifestExpressionProvider -{ - private readonly Func> _valueCallback; - private readonly Func _manifestExpressionCallback; - - /// - /// Initializes a new instance of with a context-free async callback. - /// - /// An async callback that returns the value. Called each time the value is resolved. - /// A callback that returns the manifest expression string. - /// This is required because is synchronous - /// and cannot call the async . - public DeferredValueProvider(Func> valueCallback, Func manifestExpressionCallback) - { - ArgumentNullException.ThrowIfNull(valueCallback); - ArgumentNullException.ThrowIfNull(manifestExpressionCallback); - _valueCallback = _ => valueCallback(); - _manifestExpressionCallback = manifestExpressionCallback; - } - - /// - /// Initializes a new instance of with a context-aware async callback. - /// - /// An async callback that receives a and returns - /// the value. Called each time the value is resolved. - /// A callback that returns the manifest expression string. - /// This is required because is synchronous - /// and cannot call the async . - public DeferredValueProvider(Func> valueCallback, Func manifestExpressionCallback) - { - ArgumentNullException.ThrowIfNull(valueCallback); - ArgumentNullException.ThrowIfNull(manifestExpressionCallback); - _valueCallback = valueCallback; - _manifestExpressionCallback = manifestExpressionCallback; - } - - /// - public string ValueExpression => _manifestExpressionCallback(); - - /// - public ValueTask GetValueAsync(CancellationToken cancellationToken = default) - { - return GetValueAsync(new ValueProviderContext(), cancellationToken); - } - - /// - public ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default) - { - return _valueCallback(context); - } -} diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index 75f3dd1e815..d19df781519 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -109,6 +109,7 @@ internal string GetExpression(EndpointProperty property = EndpointProperty.Url) EndpointProperty.Scheme => Binding("scheme"), EndpointProperty.TargetPort => Binding("targetPort"), EndpointProperty.HostAndPort => $"{Binding("host")}:{Binding("port")}", + EndpointProperty.TlsEnabled => Binding("tlsEnabled"), _ => throw new InvalidOperationException($"The property '{property}' is not supported for the endpoint '{EndpointName}'.") }; @@ -126,23 +127,26 @@ public EndpointReferenceExpression Property(EndpointProperty property) } /// - /// Creates a that resolves to when + /// Creates a that resolves to when /// is on this endpoint, or to /// otherwise. /// /// - /// The returned provider evaluates the TLS state lazily each time its value is resolved, making it + /// The returned expression evaluates the TLS state lazily each time its value is resolved, making it /// safe to embed in a that is built before TLS is configured - /// (e.g., before BeforeStartEvent fires). + /// (e.g., before BeforeStartEvent fires). Because the condition and branches are declarative, + /// polyglot code generators can translate this into native conditional constructs in any target language. /// - /// The value to return when TLS is enabled (e.g., ",ssl=true"). - /// The value to return when TLS is not enabled. Defaults to an empty string. - /// A whose value tracks the TLS state of this endpoint. - public DeferredValueProvider GetTlsValue(string enabledValue, string? disabledValue) + /// The expression to evaluate when TLS is enabled (e.g., ",ssl=true"). + /// The expression to evaluate when TLS is not enabled. + /// A whose value tracks the TLS state of this endpoint. + [AspireExport(Description = "Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise.")] + public ConditionalReferenceExpression GetTlsValue(ReferenceExpression enabledValue, ReferenceExpression disabledValue) { - return new DeferredValueProvider( - () => new ValueTask(TlsEnabled ? enabledValue : disabledValue), - () => TlsEnabled ? enabledValue ?? "" : disabledValue ?? ""); + return ConditionalReferenceExpression.Create( + Property(EndpointProperty.TlsEnabled), + enabledValue, + disabledValue); } /// @@ -331,6 +335,7 @@ public class EndpointReferenceExpression(EndpointReference endpointReference, En return Property switch { EndpointProperty.Scheme => new(Endpoint.Scheme), + EndpointProperty.TlsEnabled => Endpoint.TlsEnabled ? bool.TrueString : bool.FalseString, EndpointProperty.IPV4Host when networkContext == KnownNetworkIdentifiers.LocalhostNetwork => "127.0.0.1", EndpointProperty.TargetPort when Endpoint.TargetPort is int port => new(port.ToString(CultureInfo.InvariantCulture)), _ => await ResolveValueWithAllocatedAddress().ConfigureAwait(false) @@ -399,5 +404,10 @@ public enum EndpointProperty /// /// The host and port of the endpoint in the format `{Host}:{Port}`. /// - HostAndPort + HostAndPort, + + /// + /// Whether TLS is enabled on the endpoint. Returns or . + /// + TlsEnabled } diff --git a/src/Aspire.Hosting/Ats/AtsConstants.cs b/src/Aspire.Hosting/Ats/AtsConstants.cs index b3cf2b905af..7d1412df637 100644 --- a/src/Aspire.Hosting/Ats/AtsConstants.cs +++ b/src/Aspire.Hosting/Ats/AtsConstants.cs @@ -225,6 +225,11 @@ internal static class AtsConstants /// public const string ReferenceExpressionTypeId = "Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression"; + /// + /// Type ID for ConditionalReferenceExpression. + /// + public const string ConditionalReferenceExpressionTypeId = "Aspire.Hosting/Aspire.Hosting.ApplicationModel.ConditionalReferenceExpression"; + #endregion #region Well-known Capability IDs diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index 6e7232e77aa..055dfac774d 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -47,6 +47,8 @@ public sealed class ManifestPublishingContext(DistributedApplicationExecutionCon private readonly Dictionary> _formattedParameters = []; + private readonly Dictionary _conditionalExpressions = new(StringComparers.ResourceName); + private readonly HashSet _manifestResourceNames = new(StringComparers.ResourceName); private readonly IPortAllocator _portAllocator = new PortAllocator(); @@ -96,6 +98,8 @@ internal async Task WriteModel(DistributedApplicationModel model, CancellationTo WriteRemainingFormattedParameters(); + await WriteConditionalExpressionsAsync().ConfigureAwait(false); + Writer.WriteEndObject(); Writer.WriteEndObject(); @@ -664,6 +668,7 @@ public void TryAddDependentResources(object? value) if (value is ReferenceExpression referenceExpression) { RegisterFormattedParameters(referenceExpression); + RegisterConditionalExpressions(referenceExpression); } if (value is IResource resource) @@ -740,6 +745,18 @@ private void RegisterFormattedParameters(ReferenceExpression referenceExpression } } + private void RegisterConditionalExpressions(ReferenceExpression referenceExpression) + { + foreach (var provider in referenceExpression.ValueProviders) + { + if (provider is ConditionalReferenceExpression conditional) + { + _conditionalExpressions.TryAdd(conditional.Name, conditional); + _manifestResourceNames.Add(conditional.Name); + } + } + } + private string RegisterFormattedParameter(ParameterResource parameter, string format) { if (!_formattedParameters.TryGetValue(parameter, out var formats)) @@ -835,6 +852,21 @@ private void WriteRemainingFormattedParameters() } } + private async Task WriteConditionalExpressionsAsync() + { + foreach (var (name, conditional) in _conditionalExpressions) + { + var resolvedValue = await conditional.GetValueAsync(CancellationToken).ConfigureAwait(false); + + Writer.WriteStartObject(name); + Writer.WriteString("type", "value.v0"); + Writer.WriteString("value", resolvedValue ?? string.Empty); + Writer.WriteEndObject(); + } + + _conditionalExpressions.Clear(); + } + private string? GetFormattedResourceNameForProvider(object provider, string format) { return provider switch diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 4f3033cd9e1..0cf08d125e9 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -80,6 +80,7 @@ const ( EndpointPropertyScheme EndpointProperty = "Scheme" EndpointPropertyTargetPort EndpointProperty = "TargetPort" EndpointPropertyHostAndPort EndpointProperty = "HostAndPort" + EndpointPropertyTlsEnabled EndpointProperty = "TlsEnabled" ) // IconVariant represents IconVariant. @@ -1294,6 +1295,21 @@ func (s *EndpointReference) GetValueAsync(cancellationToken *CancellationToken) return result.(*string), nil } +// GetTlsValue gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. +func (s *EndpointReference) GetTlsValue(parameterName string, enabledValue *ReferenceExpression, disabledValue *ReferenceExpression) (*ConditionalReferenceExpression, error) { + reqArgs := map[string]any{ + "context": SerializeValue(s.Handle()), + } + reqArgs["parameterName"] = SerializeValue(parameterName) + reqArgs["enabledValue"] = SerializeValue(enabledValue) + reqArgs["disabledValue"] = SerializeValue(disabledValue) + result, err := s.Client().InvokeCapability("Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue", reqArgs) + if err != nil { + return nil, err + } + return result.(*ConditionalReferenceExpression), nil +} + // EndpointReferenceExpression wraps a handle for Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression. type EndpointReferenceExpression struct { HandleWrapperBase diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 39c02b52a9e..cb8dee4ee7d 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -127,7 +127,8 @@ enum EndpointProperty { PORT("Port"), SCHEME("Scheme"), TARGET_PORT("TargetPort"), - HOST_AND_PORT("HostAndPort"); + HOST_AND_PORT("HostAndPort"), + TLS_ENABLED("TlsEnabled"); private final String value; @@ -1175,6 +1176,16 @@ public String getValueAsync(CancellationToken cancellationToken) { return (String) getClient().invokeCapability("Aspire.Hosting.ApplicationModel/getValueAsync", reqArgs); } + /** Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. */ + public ConditionalReferenceExpression getTlsValue(String parameterName, ReferenceExpression enabledValue, ReferenceExpression disabledValue) { + Map reqArgs = new HashMap<>(); + reqArgs.put("context", AspireClient.serializeValue(getHandle())); + reqArgs.put("parameterName", AspireClient.serializeValue(parameterName)); + reqArgs.put("enabledValue", AspireClient.serializeValue(enabledValue)); + reqArgs.put("disabledValue", AspireClient.serializeValue(disabledValue)); + return (ConditionalReferenceExpression) getClient().invokeCapability("Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue", reqArgs); + } + } /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression. */ diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 6645db936ce..4ff819eb8f8 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List from transport import AspireClient, Handle, CapabilityError, register_callback, register_handle_wrapper, register_cancellation -from base import AspireDict, AspireList, ReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value +from base import AspireDict, AspireList, ReferenceExpression, ConditionalReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value # ============================================================================ # Enums @@ -65,6 +65,7 @@ class EndpointProperty(str, Enum): SCHEME = "Scheme" TARGET_PORT = "TargetPort" HOST_AND_PORT = "HostAndPort" + TLS_ENABLED = "TlsEnabled" class IconVariant(str, Enum): REGULAR = "Regular" @@ -748,6 +749,14 @@ def get_value_async(self, cancellation_token: CancellationToken | None = None) - args["cancellationToken"] = cancellation_token_id return self._client.invoke_capability("Aspire.Hosting.ApplicationModel/getValueAsync", args) + def get_tls_value(self, parameter_name: str, enabled_value: ReferenceExpression, disabled_value: ReferenceExpression) -> ConditionalReferenceExpression: + """Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise.""" + args: Dict[str, Any] = { "context": serialize_value(self._handle) } + args["parameterName"] = serialize_value(parameter_name) + args["enabledValue"] = serialize_value(enabled_value) + args["disabledValue"] = serialize_value(disabled_value) + return self._client.invoke_capability("Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue", args) + class EndpointReferenceExpression(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): @@ -1927,12 +1936,6 @@ def with_cancellable_operation(self, operation: Callable[[CancellationToken], No return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withCancellableOperation", args) -class ReferenceExpression(HandleWrapperBase): - def __init__(self, handle: Handle, client: AspireClient): - super().__init__(handle, client) - - pass - class ResourceLoggerService(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -3522,7 +3525,6 @@ def __init__(self, handle: Handle, client: AspireClient): register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", lambda handle, client: IDistributedApplicationBuilder(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplication", lambda handle, client: DistributedApplication(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference", lambda handle, client: EndpointReference(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression", lambda handle, client: ReferenceExpression(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", lambda handle, client: IResource(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", lambda handle, client: IResourceWithEnvironment(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", lambda handle, client: IResourceWithEndpoints(handle, client)) diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 1b0f89723f8..1c43c80a920 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -12,7 +12,7 @@ use crate::transport::{ register_callback, register_cancellation, serialize_value, }; use crate::base::{ - HandleWrapperBase, ResourceBuilderBase, ReferenceExpression, + HandleWrapperBase, ResourceBuilderBase, ReferenceExpression, ConditionalReferenceExpression, AspireList, AspireDict, serialize_handle, HasHandle, }; @@ -189,6 +189,8 @@ pub enum EndpointProperty { TargetPort, #[serde(rename = "HostAndPort")] HostAndPort, + #[serde(rename = "TlsEnabled")] + TlsEnabled, } impl std::fmt::Display for EndpointProperty { @@ -201,6 +203,7 @@ impl std::fmt::Display for EndpointProperty { Self::Scheme => write!(f, "Scheme"), Self::TargetPort => write!(f, "TargetPort"), Self::HostAndPort => write!(f, "HostAndPort"), + Self::TlsEnabled => write!(f, "TlsEnabled"), } } } @@ -1455,6 +1458,17 @@ impl EndpointReference { let result = self.client.invoke_capability("Aspire.Hosting.ApplicationModel/getValueAsync", args)?; Ok(serde_json::from_value(result)?) } + + /// Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. + pub fn get_tls_value(&self, parameter_name: &str, enabled_value: ReferenceExpression, disabled_value: ReferenceExpression) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("context".to_string(), self.handle.to_json()); + args.insert("parameterName".to_string(), serde_json::to_value(¶meter_name).unwrap_or(Value::Null)); + args.insert("enabledValue".to_string(), serde_json::to_value(&enabled_value).unwrap_or(Value::Null)); + args.insert("disabledValue".to_string(), serde_json::to_value(&disabled_value).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue", args)?; + Ok(serde_json::from_value(result)?) + } } /// Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index 8de4ee4b17c..0eab38b2f74 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -17,6 +17,7 @@ import { import { ResourceBuilderBase, ReferenceExpression, + ConditionalReferenceExpression, refExpr, AspireDict, AspireList @@ -53,6 +54,9 @@ type TestVaultResourceHandle = Handle<'Aspire.Hosting.CodeGeneration.TypeScript. /** Handle to CommandLineArgsCallbackContext */ type CommandLineArgsCallbackContextHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext'>; +/** Handle to ConditionalReferenceExpression */ +type ConditionalReferenceExpressionHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ConditionalReferenceExpression'>; + /** Handle to ContainerResource */ type ContainerResourceHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource'>; @@ -177,6 +181,7 @@ export enum EndpointProperty { Scheme = "Scheme", TargetPort = "TargetPort", HostAndPort = "HostAndPort", + TlsEnabled = "TlsEnabled", } /** Enum type for IconVariant */ @@ -755,6 +760,15 @@ export class EndpointReference { ); } + /** Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. */ + async getTlsValue(parameterName: string, enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { + const rpcArgs: Record = { context: this._handle, parameterName, enabledValue, disabledValue }; + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue', + rpcArgs + ); + } + } /** @@ -775,6 +789,11 @@ export class EndpointReferencePromise implements PromiseLike return this._promise.then(obj => obj.getValueAsync(options)); } + /** Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. */ + getTlsValue(parameterName: string, enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { + return this._promise.then(obj => obj.getTlsValue(parameterName, enabledValue, disabledValue)); + } + } // ============================================================================ @@ -10513,7 +10532,7 @@ export async function createBuilder(options?: CreateBuilderOptions): Promise(); var connectionStringResource = Assert.Single(appModel.Resources.OfType()); - Assert.Equal("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={pass.value}", connectionStringResource.ConnectionStringExpression.ValueExpression); + var valueExpression = connectionStringResource.ConnectionStringExpression.ValueExpression; + Assert.StartsWith("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={pass.value}", valueExpression); + AssertContainsConditionalReference(valueExpression); } [Fact] @@ -111,7 +115,9 @@ public void RedisCreatesConnectionStringWithPasswordAndPort() var appModel = app.Services.GetRequiredService(); var connectionStringResource = Assert.Single(appModel.Resources.OfType()); - Assert.Equal("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={pass.value}", connectionStringResource.ConnectionStringExpression.ValueExpression); + var valueExpression = connectionStringResource.ConnectionStringExpression.ValueExpression; + Assert.StartsWith("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={pass.value}", valueExpression); + AssertContainsConditionalReference(valueExpression); } [Fact] @@ -127,7 +133,9 @@ public async Task RedisCreatesConnectionStringWithDefaultPassword() var connectionStringResource = Assert.Single(appModel.Resources.OfType()); var connectionString = await connectionStringResource.GetConnectionStringAsync(default); - Assert.Equal("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={myRedis-password.value}", connectionStringResource.ConnectionStringExpression.ValueExpression); + var valueExpression = connectionStringResource.ConnectionStringExpression.ValueExpression; + Assert.StartsWith("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={myRedis-password.value}", valueExpression); + AssertContainsConditionalReference(valueExpression); Assert.StartsWith("localhost:2000", connectionString); } @@ -137,32 +145,23 @@ public async Task VerifyDefaultManifest() using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); var redis = builder.AddRedis("redis"); - var manifest = await ManifestUtils.GetManifest(redis.Resource); + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var fullManifest = await ManifestUtils.GetManifestForModel(model); + var resources = fullManifest["resources"]!; + var manifest = resources["redis"]!; - var expectedManifest = $$""" - { - "type": "container.v0", - "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={redis-password.value}", - "image": "{{RedisContainerImageTags.Registry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}", - "entrypoint": "/bin/sh", - "args": [ - "-c", - "redis-server --requirepass $REDIS_PASSWORD" - ], - "env": { - "REDIS_PASSWORD": "{redis-password.value}" - }, - "bindings": { - "tcp": { - "scheme": "redis", - "protocol": "tcp", - "transport": "tcp", - "targetPort": 6379 - } - } - } - """; - Assert.Equal(expectedManifest, manifest.ToString()); + Assert.Equal("container.v0", manifest["type"]!.GetValue()); + var connectionString = manifest["connectionString"]!.GetValue(); + Assert.StartsWith("{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={redis-password.value}", connectionString); + var creName = AssertContainsConditionalReference(connectionString); + AssertConditionalExpressionInManifest(resources, creName); + + Assert.Equal($"{RedisContainerImageTags.Registry}/{RedisContainerImageTags.Image}:{RedisContainerImageTags.Tag}", manifest["image"]!.GetValue()); + Assert.Equal("/bin/sh", manifest["entrypoint"]!.GetValue()); + Assert.Equal("{redis-password.value}", manifest["env"]!["REDIS_PASSWORD"]!.GetValue()); + Assert.Equal("redis", manifest["bindings"]!["tcp"]!["scheme"]!.GetValue()); + Assert.Equal(6379, manifest["bindings"]!["tcp"]!["targetPort"]!.GetValue()); } [Fact] @@ -171,29 +170,22 @@ public async Task VerifyWithoutPasswordManifest() using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); var redis = builder.AddRedis("redis").WithPassword(null); - var manifest = await ManifestUtils.GetManifest(redis.Resource); + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var fullManifest = await ManifestUtils.GetManifestForModel(model); + var resources = fullManifest["resources"]!; + var manifest = resources["redis"]!; - var expectedManifest = $$""" - { - "type": "container.v0", - "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", - "image": "{{RedisContainerImageTags.Registry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}", - "entrypoint": "/bin/sh", - "args": [ - "-c", - "redis-server" - ], - "bindings": { - "tcp": { - "scheme": "redis", - "protocol": "tcp", - "transport": "tcp", - "targetPort": 6379 - } - } - } - """; - Assert.Equal(expectedManifest, manifest.ToString()); + Assert.Equal("container.v0", manifest["type"]!.GetValue()); + var connectionString = manifest["connectionString"]!.GetValue(); + Assert.StartsWith("{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", connectionString); + var creName = AssertContainsConditionalReference(connectionString); + AssertConditionalExpressionInManifest(resources, creName); + + Assert.Equal($"{RedisContainerImageTags.Registry}/{RedisContainerImageTags.Image}:{RedisContainerImageTags.Tag}", manifest["image"]!.GetValue()); + Assert.Equal("/bin/sh", manifest["entrypoint"]!.GetValue()); + Assert.Equal("redis", manifest["bindings"]!["tcp"]!["scheme"]!.GetValue()); + Assert.Equal(6379, manifest["bindings"]!["tcp"]!["targetPort"]!.GetValue()); } [Fact] @@ -206,32 +198,23 @@ public async Task VerifyWithPasswordManifest() var pass = builder.AddParameter("pass"); var redis = builder.AddRedis("redis", password: pass); - var manifest = await ManifestUtils.GetManifest(redis.Resource); - var expectedManifest = $$""" - { - "type": "container.v0", - "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={pass.value}", - "image": "{{RedisContainerImageTags.Registry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}", - "entrypoint": "/bin/sh", - "args": [ - "-c", - "redis-server --requirepass $REDIS_PASSWORD" - ], - "env": { - "REDIS_PASSWORD": "{pass.value}" - }, - "bindings": { - "tcp": { - "scheme": "redis", - "protocol": "tcp", - "transport": "tcp", - "targetPort": 6379 - } - } - } - """; - Assert.Equal(expectedManifest, manifest.ToString()); + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var fullManifest = await ManifestUtils.GetManifestForModel(model); + var resources = fullManifest["resources"]!; + var manifest = resources["redis"]!; + + Assert.Equal("container.v0", manifest["type"]!.GetValue()); + var connectionString = manifest["connectionString"]!.GetValue(); + Assert.StartsWith("{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={pass.value}", connectionString); + var creName = AssertContainsConditionalReference(connectionString); + AssertConditionalExpressionInManifest(resources, creName); + + Assert.Equal($"{RedisContainerImageTags.Registry}/{RedisContainerImageTags.Image}:{RedisContainerImageTags.Tag}", manifest["image"]!.GetValue()); + Assert.Equal("{pass.value}", manifest["env"]!["REDIS_PASSWORD"]!.GetValue()); + Assert.Equal("redis", manifest["bindings"]!["tcp"]!["scheme"]!.GetValue()); + Assert.Equal(6379, manifest["bindings"]!["tcp"]!["targetPort"]!.GetValue()); } [Fact] @@ -241,32 +224,23 @@ public async Task VerifyWithPasswordValueNotProvidedManifest() var pass = builder.AddParameter("pass"); var redis = builder.AddRedis("redis", password: pass); - var manifest = await ManifestUtils.GetManifest(redis.Resource); - var expectedManifest = $$""" - { - "type": "container.v0", - "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={pass.value}", - "image": "{{RedisContainerImageTags.Registry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}", - "entrypoint": "/bin/sh", - "args": [ - "-c", - "redis-server --requirepass $REDIS_PASSWORD" - ], - "env": { - "REDIS_PASSWORD": "{pass.value}" - }, - "bindings": { - "tcp": { - "scheme": "redis", - "protocol": "tcp", - "transport": "tcp", - "targetPort": 6379 - } - } - } - """; - Assert.Equal(expectedManifest, manifest.ToString()); + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var fullManifest = await ManifestUtils.GetManifestForModel(model); + var resources = fullManifest["resources"]!; + var manifest = resources["redis"]!; + + Assert.Equal("container.v0", manifest["type"]!.GetValue()); + var connectionString = manifest["connectionString"]!.GetValue(); + Assert.StartsWith("{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={pass.value}", connectionString); + var creName = AssertContainsConditionalReference(connectionString); + AssertConditionalExpressionInManifest(resources, creName); + + Assert.Equal($"{RedisContainerImageTags.Registry}/{RedisContainerImageTags.Image}:{RedisContainerImageTags.Tag}", manifest["image"]!.GetValue()); + Assert.Equal("{pass.value}", manifest["env"]!["REDIS_PASSWORD"]!.GetValue()); + Assert.Equal("redis", manifest["bindings"]!["tcp"]!["scheme"]!.GetValue()); + Assert.Equal(6379, manifest["bindings"]!["tcp"]!["targetPort"]!.GetValue()); } [Fact] @@ -486,14 +460,16 @@ public async Task VerifyRedisResourceWithPassword(bool withPassword) var connectionStringResource = Assert.Single(appModel.Resources.OfType()); var connectionString = await connectionStringResource.GetConnectionStringAsync(default); + var valueExpression = connectionStringResource.ConnectionStringExpression.ValueExpression; + AssertContainsConditionalReference(valueExpression); if (withPassword) { - Assert.Equal("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={pass.value}", connectionStringResource.ConnectionStringExpression.ValueExpression); + Assert.StartsWith("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={pass.value}", valueExpression); Assert.Equal($"localhost:5001,password={password}", connectionString); } else { - Assert.Equal("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port}", connectionStringResource.ConnectionStringExpression.ValueExpression); + Assert.StartsWith("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port}", valueExpression); Assert.Equal($"localhost:5001", connectionString); } } @@ -715,7 +691,9 @@ public async Task AddRedisContainerWithPasswordAnnotationMetadata() var connectionStringResource = Assert.Single(appModel.Resources.OfType()); var connectionString = await connectionStringResource.GetConnectionStringAsync(default); - Assert.Equal("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={pass.value}", connectionStringResource.ConnectionStringExpression.ValueExpression); + var valueExpression = connectionStringResource.ConnectionStringExpression.ValueExpression; + Assert.StartsWith("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={pass.value}", valueExpression); + AssertContainsConditionalReference(valueExpression); Assert.StartsWith($"localhost:5001,password={password}", connectionString); } @@ -836,9 +814,15 @@ public async Task RedisWithCertificateHasCorrectConnectionString() Assert.True(redis.Resource.TlsEnabled); - // Verify the connection string expression includes ssl=true after TLS is enabled + // The connection string expression uses a conditional reference for TLS var connectionStringExpression = redis.Resource.ConnectionStringExpression; - Assert.Contains(",ssl=true", connectionStringExpression.ValueExpression); + AssertContainsConditionalReference(connectionStringExpression.ValueExpression); + + // The resolved value should include ,ssl=true after TLS is enabled + redis.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6379)); + var resolved = await connectionStringExpression.GetValueAsync(default(CancellationToken)); + Assert.NotNull(resolved); + Assert.Contains(",ssl=true", resolved); // Verify the endpoint annotation also has TlsEnabled var endpoint = Assert.Single(redis.Resource.Annotations.OfType(), e => e.Name == "tcp"); @@ -863,11 +847,11 @@ public async Task RedisConnectionStringResolvesWithTlsDynamically() // Before BeforeStartEvent, TLS is not yet enabled Assert.False(redis.Resource.TlsEnabled); - // The manifest expression does not include ssl=true before TLS is enabled + // The manifest expression uses a conditional reference (not literal ,ssl=true) var expressionBeforeTls = redis.Resource.ConnectionStringExpression; + AssertContainsConditionalReference(expressionBeforeTls.ValueExpression); Assert.DoesNotContain(",ssl=true", expressionBeforeTls.ValueExpression); - // But the expression has a DeferredValueProvider that will resolve dynamically // Resolve the runtime value — should NOT have ssl=true yet var resolvedBeforeTls = await expressionBeforeTls.GetValueAsync(default(CancellationToken)); Assert.NotNull(resolvedBeforeTls); @@ -889,9 +873,9 @@ public async Task RedisConnectionStringResolvesWithTlsDynamically() Assert.NotNull(resolvedAfterTls); Assert.Contains(",ssl=true", resolvedAfterTls); - // The new expression from the getter also reflects TLS in its manifest expression + // The new expression from the getter also has a conditional reference var expressionAfterTls = redis.Resource.ConnectionStringExpression; - Assert.Contains(",ssl=true", expressionAfterTls.ValueExpression); + AssertContainsConditionalReference(expressionAfterTls.ValueExpression); } [Fact] @@ -908,8 +892,9 @@ public void RedisWithoutCertificateHasCorrectConnectionString() var beforeStartEvent = new BeforeStartEvent(app.Services, appModel); Assert.False(redis.Resource.TlsEnabled); - // Verify the connection string expression does NOT include ssl=true + // Verify the connection string expression uses a conditional reference (not literal ssl=true) var connectionStringExpression = redis.Resource.ConnectionStringExpression; + AssertContainsConditionalReference(connectionStringExpression.ValueExpression); Assert.DoesNotContain(",ssl=true", connectionStringExpression.ValueExpression); } @@ -930,4 +915,18 @@ private static X509Certificate2 CreateTestCertificate() return certificate; } + + private static string AssertContainsConditionalReference(string valueExpression) + { + var match = Regex.Match(valueExpression, @"\{(cond-[^.]+)\.value\}"); + Assert.True(match.Success, $"Expected value expression to contain a conditional reference '{{cond-*.value}}', but got: {valueExpression}"); + return match.Groups[1].Value; + } + + private static void AssertConditionalExpressionInManifest(JsonNode resources, string creName) + { + var creEntry = resources[creName]; + Assert.NotNull(creEntry); + Assert.Equal("value.v0", creEntry["type"]!.GetValue()); + } } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs index 1b4865ac792..61bfe986d7e 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs @@ -3,6 +3,7 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Ats; using Aspire.Hosting.RemoteHost.Ats; using Xunit; @@ -921,4 +922,254 @@ private sealed class DtoWithReadOnlyProperty public string? Name { get; set; } public string Computed { get; } = "read-only"; } + + [Fact] + public void MarshalToJson_MarshalsConditionalReferenceExpressionAsHandle() + { + var registry = new HandleRegistry(); + var marshaller = CreateMarshaller(registry); + var condition = new TestConditionValueProvider(bool.TrueString); + var whenTrue = ReferenceExpression.Create($",ssl=true"); + var whenFalse = ReferenceExpression.Empty; + var conditional = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + + var result = marshaller.MarshalToJson(conditional); + + Assert.NotNull(result); + Assert.IsType(result); + var jsonObj = (JsonObject)result; + Assert.NotNull(jsonObj["$handle"]); + Assert.NotNull(jsonObj["$type"]); + } + + [Fact] + public void MarshalToJson_ConditionalReferenceExpression_RoundTripsViaHandle() + { + var registry = new HandleRegistry(); + var marshaller = CreateMarshaller(registry); + var condition = new TestConditionValueProvider(bool.TrueString); + var whenTrue = ReferenceExpression.Create($",ssl=true"); + var whenFalse = ReferenceExpression.Empty; + var conditional = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + + var json = marshaller.MarshalToJson(conditional); + Assert.NotNull(json); + + var handleId = json["$handle"]!.GetValue(); + var found = registry.TryGet(handleId, out var retrieved, out _); + + Assert.True(found); + Assert.Same(conditional, retrieved); + } + + [Fact] + public async Task MarshalToJson_ConditionalReferenceExpression_PreservesValueAfterRoundTrip() + { + var registry = new HandleRegistry(); + var marshaller = CreateMarshaller(registry); + var condition = new TestConditionValueProvider(bool.TrueString); + var whenTrue = ReferenceExpression.Create($",ssl=true"); + var whenFalse = ReferenceExpression.Empty; + var conditional = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + + var json = marshaller.MarshalToJson(conditional); + var handleId = json!["$handle"]!.GetValue(); + registry.TryGet(handleId, out var retrieved, out _); + var retrievedConditional = Assert.IsType(retrieved); + + Assert.Equal("test-tls", retrievedConditional.Name); + Assert.Equal(",ssl=true", await retrievedConditional.GetValueAsync()); + } + + [Fact] + public async Task MarshalToJson_ConditionalReferenceExpression_FalseConditionRoundTrips() + { + var registry = new HandleRegistry(); + var marshaller = CreateMarshaller(registry); + var condition = new TestConditionValueProvider(bool.FalseString); + var whenTrue = ReferenceExpression.Create($",ssl=true"); + var whenFalse = ReferenceExpression.Empty; + var conditional = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + + var json = marshaller.MarshalToJson(conditional); + var handleId = json!["$handle"]!.GetValue(); + registry.TryGet(handleId, out var retrieved, out _); + var retrievedConditional = Assert.IsType(retrieved); + + Assert.Null(await retrievedConditional.GetValueAsync()); + } + + private sealed class TestConditionValueProvider(string value) : IValueProvider, IManifestExpressionProvider + { + public string ValueExpression => "test-condition"; + + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + => new(value); + + public ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default) + => new(value); + } + + [Fact] + public void UnmarshalFromJson_UnmarshalsCondExprToConditionalReferenceExpression() + { + var registry = new HandleRegistry(); + + // Register a condition IValueProvider as a handle + var condition = new TestConditionValueProvider(bool.TrueString); + var conditionHandleId = registry.Register(condition, AtsConstants.ConditionalReferenceExpressionTypeId); + + var (marshaller, context) = CreateMarshallerWithContext(registry); + + // Build the $condExpr JSON that a client would send + var json = new JsonObject + { + ["$condExpr"] = new JsonObject + { + ["condition"] = new JsonObject + { + ["$handle"] = conditionHandleId + }, + ["whenTrue"] = new JsonObject + { + ["$expr"] = new JsonObject + { + ["format"] = ",ssl=true" + } + }, + ["whenFalse"] = new JsonObject + { + ["$expr"] = new JsonObject + { + ["format"] = "" + } + } + } + }; + + var result = marshaller.UnmarshalFromJson(json, typeof(ConditionalReferenceExpression), context); + var cre = Assert.IsType(result); + + Assert.NotNull(cre); + } + + [Fact] + public async Task UnmarshalFromJson_CondExpr_TrueConditionReturnsWhenTrueValue() + { + var registry = new HandleRegistry(); + + var condition = new TestConditionValueProvider(bool.TrueString); + var conditionHandleId = registry.Register(condition, AtsConstants.ConditionalReferenceExpressionTypeId); + + var (marshaller, context) = CreateMarshallerWithContext(registry); + + var json = new JsonObject + { + ["$condExpr"] = new JsonObject + { + ["condition"] = new JsonObject + { + ["$handle"] = conditionHandleId + }, + ["whenTrue"] = new JsonObject + { + ["$expr"] = new JsonObject + { + ["format"] = ",ssl=true" + } + }, + ["whenFalse"] = new JsonObject + { + ["$expr"] = new JsonObject + { + ["format"] = "" + } + } + } + }; + + var result = marshaller.UnmarshalFromJson(json, typeof(ConditionalReferenceExpression), context); + var cre = Assert.IsType(result); + var value = await cre.GetValueAsync(); + + Assert.Equal(",ssl=true", value); + } + + [Fact] + public async Task UnmarshalFromJson_CondExpr_FalseConditionReturnsWhenFalseValue() + { + var registry = new HandleRegistry(); + + var condition = new TestConditionValueProvider(bool.FalseString); + var conditionHandleId = registry.Register(condition, AtsConstants.ConditionalReferenceExpressionTypeId); + + var (marshaller, context) = CreateMarshallerWithContext(registry); + + var json = new JsonObject + { + ["$condExpr"] = new JsonObject + { + ["condition"] = new JsonObject + { + ["$handle"] = conditionHandleId + }, + ["whenTrue"] = new JsonObject + { + ["$expr"] = new JsonObject + { + ["format"] = ",ssl=true" + } + }, + ["whenFalse"] = new JsonObject + { + ["$expr"] = new JsonObject + { + ["format"] = "" + } + } + } + }; + + var result = marshaller.UnmarshalFromJson(json, typeof(ConditionalReferenceExpression), context); + var cre = Assert.IsType(result); + var value = await cre.GetValueAsync(); + + // An empty format string with no value providers results in null from ReferenceExpression.GetValueAsync + Assert.Null(value); + } + + [Fact] + public void UnmarshalFromJson_CondExpr_ThrowsWhenConditionHandleMissing() + { + var registry = new HandleRegistry(); + var (marshaller, context) = CreateMarshallerWithContext(registry); + + var json = new JsonObject + { + ["$condExpr"] = new JsonObject + { + ["condition"] = new JsonObject + { + ["$handle"] = "nonexistent-handle-id" + }, + ["whenTrue"] = new JsonObject + { + ["$expr"] = new JsonObject + { + ["format"] = ",ssl=true" + } + }, + ["whenFalse"] = new JsonObject + { + ["$expr"] = new JsonObject + { + ["format"] = "" + } + } + } + }; + + Assert.Throws(() => + marshaller.UnmarshalFromJson(json, typeof(ConditionalReferenceExpression), context)); + } } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs index bd9eb63e29d..c705825d48a 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json.Nodes; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Ats; using Aspire.Hosting.RemoteHost.Ats; using Xunit; @@ -342,4 +344,32 @@ public void IsHandleRef_ReturnsTrueEvenWithNonStringHandle() Assert.True(HandleRef.IsHandleRef(json)); } + + [Fact] + public void Register_ConditionalReferenceExpression_CanBeRetrievedByTypeId() + { + var registry = new HandleRegistry(); + var condition = new TestConditionProvider(bool.TrueString); + var conditional = ConditionalReferenceExpression.Create( + condition, ReferenceExpression.Create($",ssl=true"), ReferenceExpression.Empty); + var typeId = AtsConstants.ConditionalReferenceExpressionTypeId; + + var handleId = registry.Register(conditional, typeId); + var found = registry.TryGet(handleId, out var retrieved, out var retrievedTypeId); + + Assert.True(found); + Assert.Same(conditional, retrieved); + Assert.Equal(typeId, retrievedTypeId); + } + + private sealed class TestConditionProvider(string value) : IValueProvider, IManifestExpressionProvider + { + public string ValueExpression => "test-condition"; + + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + => new(value); + + public ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default) + => new(value); + } } diff --git a/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs b/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs new file mode 100644 index 00000000000..2e6f5d6e91a --- /dev/null +++ b/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs @@ -0,0 +1,220 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; + +namespace Aspire.Hosting.Tests; + +public class ConditionalReferenceExpressionTests +{ + [Fact] + public async Task GetValueAsync_ReturnsTrueValue_WhenConditionIsTrue() + { + var condition = new TestValueProvider(bool.TrueString); + var whenTrue = ReferenceExpression.Create($",ssl=true"); + var whenFalse = ReferenceExpression.Empty; + + var expr = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + + var value = await expr.GetValueAsync(); + Assert.Equal(",ssl=true", value); + } + + [Fact] + public async Task GetValueAsync_ReturnsFalseValue_WhenConditionIsFalse() + { + var condition = new TestValueProvider(bool.FalseString); + var whenTrue = ReferenceExpression.Create($",ssl=true"); + var whenFalse = ReferenceExpression.Empty; + + var expr = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + + var value = await expr.GetValueAsync(); + Assert.Null(value); + } + + [Fact] + public void ValueExpression_ReturnsManifestParameterReference() + { + var condition = new TestValueProvider(bool.TrueString); + var whenTrue = ReferenceExpression.Create($",ssl=true"); + var whenFalse = ReferenceExpression.Empty; + + var expr = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + + Assert.StartsWith("{cond-", expr.ValueExpression); + Assert.EndsWith(".value}", expr.ValueExpression); + } + + [Fact] + public async Task GetValueAsync_WithContext_PassesContextToBranch() + { + var condition = new TestValueProvider(bool.TrueString); + var whenTrue = ReferenceExpression.Create($"true-value"); + var whenFalse = ReferenceExpression.Create($"false-value"); + + var expr = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + + var context = new ValueProviderContext(); + var value = await expr.GetValueAsync(context); + Assert.Equal("true-value", value); + } + + [Fact] + public async Task ConditionalReferenceExpression_WorksInReferenceExpressionBuilder() + { + var condition = new TestValueProvider(bool.FalseString); + var whenTrue = ReferenceExpression.Create($",ssl=true"); + var whenFalse = ReferenceExpression.Empty; + + var conditional = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + + var builder = new ReferenceExpressionBuilder(); + builder.AppendLiteral("localhost:6379"); + builder.Append($"{conditional}"); + var expression = builder.Build(); + + // Before enabling: runtime value does not include TLS + var valueBefore = await expression.GetValueAsync(new(), default); + Assert.Equal("localhost:6379", valueBefore); + + // After enabling: runtime value includes TLS dynamically + condition.Value = bool.TrueString; + var valueAfter = await expression.GetValueAsync(new(), default); + Assert.Equal("localhost:6379,ssl=true", valueAfter); + } + + [Fact] + public void References_IncludesConditionAndBranchReferences() + { + var resource = new TestResourceWithEndpoints("test"); + var annotation = new EndpointAnnotation(ProtocolType.Tcp, uriScheme: "http", name: "http"); + resource.Annotations.Add(annotation); + var endpointRef = new EndpointReference(resource, annotation); + + var condition = endpointRef.Property(EndpointProperty.TlsEnabled); + var whenTrue = ReferenceExpression.Create($",ssl=true"); + var whenFalse = ReferenceExpression.Empty; + + var expr = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + + var references = expr.References.ToList(); + Assert.Contains(endpointRef, references); + } + + [Fact] + public void Constructor_ThrowsOnNullCondition() + { + Assert.Throws(() => + ConditionalReferenceExpression.Create(null!, ReferenceExpression.Empty, ReferenceExpression.Empty)); + } + + [Fact] + public void Constructor_ThrowsOnNullWhenTrue() + { + Assert.Throws(() => + ConditionalReferenceExpression.Create(new TestValueProvider(bool.TrueString), null!, ReferenceExpression.Empty)); + } + + [Fact] + public void Constructor_ThrowsOnNullWhenFalse() + { + Assert.Throws(() => + ConditionalReferenceExpression.Create(new TestValueProvider(bool.TrueString), ReferenceExpression.Empty, null!)); + } + + [Fact] + public void Name_IsAutoGeneratedFromCondition() + { + var expr = ConditionalReferenceExpression.Create(new TestValueProvider(bool.TrueString), ReferenceExpression.Empty, ReferenceExpression.Empty); + Assert.StartsWith("cond-", expr.Name); + } + + private sealed class TestResourceWithEndpoints(string name) : Resource(name), IResourceWithEndpoints + { + } + + private sealed class TestValueProvider(string value) : IValueProvider, IManifestExpressionProvider + { + public string Value { get; set; } = value; + + public string ValueExpression => "test-condition"; + + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + return new ValueTask(Value); + } + + public ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default) + { + return new ValueTask(Value); + } + } +} + +public class EndpointPropertyTlsEnabledTests +{ + [Fact] + public async Task TlsEnabled_ReturnsFalseString_WhenTlsNotEnabled() + { + var resource = new TestResourceWithEndpoints("test"); + var annotation = new EndpointAnnotation(ProtocolType.Tcp, uriScheme: "http", name: "tcp"); + resource.Annotations.Add(annotation); + var endpointRef = new EndpointReference(resource, annotation); + + var tlsExpr = endpointRef.Property(EndpointProperty.TlsEnabled); + + var value = await tlsExpr.GetValueAsync(); + Assert.Equal(bool.FalseString, value); + } + + [Fact] + public async Task TlsEnabled_ReturnsTrueString_WhenTlsEnabled() + { + var resource = new TestResourceWithEndpoints("test"); + var annotation = new EndpointAnnotation(ProtocolType.Tcp, uriScheme: "https", name: "tcp") + { + TlsEnabled = true + }; + resource.Annotations.Add(annotation); + var endpointRef = new EndpointReference(resource, annotation); + + var tlsExpr = endpointRef.Property(EndpointProperty.TlsEnabled); + + var value = await tlsExpr.GetValueAsync(); + Assert.Equal(bool.TrueString, value); + } + + [Fact] + public void ValueExpression_ReturnsEndpointTlsExpression() + { + var resource = new TestResourceWithEndpoints("myresource"); + var annotation = new EndpointAnnotation(ProtocolType.Tcp, uriScheme: "http", name: "tcp"); + resource.Annotations.Add(annotation); + var endpointRef = new EndpointReference(resource, annotation); + + var tlsExpr = endpointRef.Property(EndpointProperty.TlsEnabled); + + Assert.Equal("{myresource.bindings.tcp.tlsEnabled}", tlsExpr.ValueExpression); + } + + [Fact] + public async Task TlsEnabled_ResolvesLazilyFromCurrentState() + { + var resource = new TestResourceWithEndpoints("test"); + var annotation = new EndpointAnnotation(ProtocolType.Tcp, uriScheme: "http", name: "tcp"); + resource.Annotations.Add(annotation); + var endpointRef = new EndpointReference(resource, annotation); + + var tlsExpr = endpointRef.Property(EndpointProperty.TlsEnabled); + + Assert.Equal(bool.FalseString, await tlsExpr.GetValueAsync()); + + annotation.TlsEnabled = true; + Assert.Equal(bool.TrueString, await tlsExpr.GetValueAsync()); + } + + private sealed class TestResourceWithEndpoints(string name) : Resource(name), IResourceWithEndpoints + { + } +} diff --git a/tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs b/tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs deleted file mode 100644 index 62474fadb89..00000000000 --- a/tests/Aspire.Hosting.Tests/DeferredValueProviderTests.cs +++ /dev/null @@ -1,141 +0,0 @@ -// 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.Tests; - -public class DeferredValueProviderTests -{ - [Fact] - public async Task GetValueAsync_ReturnsCallbackResult() - { - var provider = new DeferredValueProvider( - () => new ValueTask("hello"), - () => "manifest-expression"); - - var value = await provider.GetValueAsync(); - Assert.Equal("hello", value); - } - - [Fact] - public async Task GetValueAsync_ReturnsNullWhenCallbackReturnsNull() - { - var provider = new DeferredValueProvider( - () => new ValueTask((string?)null), - () => ""); - - var value = await provider.GetValueAsync(); - Assert.Null(value); - } - - [Fact] - public void ValueExpression_ReturnsManifestCallbackResult() - { - var provider = new DeferredValueProvider( - () => new ValueTask("runtime-value"), - () => "manifest-expression"); - - Assert.Equal("manifest-expression", provider.ValueExpression); - } - - [Fact] - public async Task GetValueAsync_ResolvesLazilyFromCurrentState() - { - var enabled = false; - var provider = new DeferredValueProvider( - () => new ValueTask(enabled ? ",ssl=true" : ""), - () => enabled ? ",ssl=true" : ""); - - // Before enabling - var valueBefore = await provider.GetValueAsync(); - Assert.Equal("", valueBefore); - Assert.Equal("", provider.ValueExpression); - - // After enabling - enabled = true; - var valueAfter = await provider.GetValueAsync(); - Assert.Equal(",ssl=true", valueAfter); - Assert.Equal(",ssl=true", provider.ValueExpression); - } - - [Fact] - public async Task GetValueAsync_WithContext_ReceivesValueProviderContext() - { - var context = new ValueProviderContext - { - Caller = null, - Network = null - }; - - ValueProviderContext? capturedContext = null; - var provider = new DeferredValueProvider( - (ctx) => - { - capturedContext = ctx; - return new ValueTask("context-aware-value"); - }, - () => "context-aware-value"); - - var value = await provider.GetValueAsync(context); - Assert.Equal("context-aware-value", value); - Assert.Same(context, capturedContext); - } - - [Fact] - public void ValueExpression_UsesManifestCallback() - { - var provider = new DeferredValueProvider( - (ValueProviderContext _) => new ValueTask("runtime-value"), - () => "manifest-expression"); - - Assert.Equal("manifest-expression", provider.ValueExpression); - } - - [Fact] - public async Task GetValueAsync_WithContextAndManifestCallback_UsesIndependentCallbacks() - { - var provider = new DeferredValueProvider( - (ValueProviderContext _) => new ValueTask("runtime-value"), - () => "manifest-expression"); - - var value = await provider.GetValueAsync(); - Assert.Equal("runtime-value", value); - Assert.Equal("manifest-expression", provider.ValueExpression); - } - - [Fact] - public async Task DeferredValueProvider_WorksInReferenceExpressionBuilder() - { - var enabled = false; - var tlsFragment = new DeferredValueProvider( - () => new ValueTask(enabled ? ",ssl=true" : ""), - () => enabled ? ",ssl=true" : ""); - - var builder = new ReferenceExpressionBuilder(); - builder.AppendLiteral("localhost:6379"); - builder.Append($"{tlsFragment}"); - var expression = builder.Build(); - - // Before enabling, runtime value does not include TLS - var valueBefore = await expression.GetValueAsync(new(), default); - Assert.Equal("localhost:6379", valueBefore); - - // Manifest expression also does not include TLS (captured at build time) - Assert.Equal("localhost:6379", expression.ValueExpression); - - // After enabling, runtime value includes TLS dynamically - enabled = true; - var valueAfter = await expression.GetValueAsync(new(), default); - Assert.Equal("localhost:6379,ssl=true", valueAfter); - - // But the manifest expression was captured at build time (when enabled was false), - // so it still shows the old value - Assert.Equal("localhost:6379", expression.ValueExpression); - - // A newly built expression captures the updated manifest expression - var builder2 = new ReferenceExpressionBuilder(); - builder2.AppendLiteral("localhost:6379"); - builder2.Append($"{tlsFragment}"); - var expression2 = builder2.Build(); - Assert.Equal("localhost:6379,ssl=true", expression2.ValueExpression); - } -} diff --git a/tests/Aspire.Hosting.Tests/Utils/ManifestUtils.cs b/tests/Aspire.Hosting.Tests/Utils/ManifestUtils.cs index 99560b83a9c..21190557d37 100644 --- a/tests/Aspire.Hosting.Tests/Utils/ManifestUtils.cs +++ b/tests/Aspire.Hosting.Tests/Utils/ManifestUtils.cs @@ -42,6 +42,27 @@ public static async Task GetManifest(IResource resource, string? manif return resourceNode; } + public static async Task GetManifestForModel(DistributedApplicationModel model, string? manifestDirectory = null) + { + manifestDirectory ??= Environment.CurrentDirectory; + + using var ms = new MemoryStream(); + var writer = new Utf8JsonWriter(ms, new() { Indented = true }); + var options = new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish) + { + ServiceProvider = new ServiceCollection().BuildServiceProvider() + }; + var executionContext = new DistributedApplicationExecutionContext(options); + var context = new ManifestPublishingContext(executionContext, Path.Combine(manifestDirectory, "manifest.json"), writer); + + await context.WriteModel(model, CancellationToken.None); + + ms.Position = 0; + var obj = JsonNode.Parse(ms); + Assert.NotNull(obj); + return obj; + } + public static async Task GetManifests(IResource[] resources) { using var ms = new MemoryStream(); From c914fcf030440d203d67c61f817d223f92bb036c Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 6 Mar 2026 20:42:03 -0800 Subject: [PATCH 10/45] Fix outdated code generation snapshots --- .../Snapshots/AtsGeneratedAspire.verified.py | 9 +-------- .../Snapshots/AtsGeneratedAspire.verified.rs | 2 +- .../Snapshots/AtsGeneratedAspire.verified.ts | 3 ++- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py index d84dd4677a6..40b73861611 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List from transport import AspireClient, Handle, CapabilityError, register_callback, register_handle_wrapper, register_cancellation -from base import AspireDict, AspireList, ReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value +from base import AspireDict, AspireList, ReferenceExpression, ConditionalReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value # ============================================================================ # Enums @@ -119,12 +119,6 @@ def __init__(self, handle: Handle, client: AspireClient): pass -class ReferenceExpression(HandleWrapperBase): - def __init__(self, handle: Handle, client: AspireClient): - super().__init__(handle, client) - - pass - class TestCallbackContext(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -709,7 +703,6 @@ def with_vault_direct(self, option: str) -> ITestVaultResource: register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestVaultResource", lambda handle, client: TestVaultResource(handle, client)) register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.ITestVaultResource", lambda handle, client: ITestVaultResource(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", lambda handle, client: IDistributedApplicationBuilder(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression", lambda handle, client: ReferenceExpression(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", lambda handle, client: IResourceWithEnvironment(handle, client)) register_handle_wrapper("Aspire.Hosting/List", lambda handle, client: AspireList(handle, client)) register_handle_wrapper("Aspire.Hosting/Dict", lambda handle, client: AspireDict(handle, client)) diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs index 9fc7729a4dd..28bb02d8793 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs @@ -12,7 +12,7 @@ use crate::transport::{ register_callback, register_cancellation, serialize_value, }; use crate::base::{ - HandleWrapperBase, ResourceBuilderBase, ReferenceExpression, + HandleWrapperBase, ResourceBuilderBase, ReferenceExpression, ConditionalReferenceExpression, AspireList, AspireDict, serialize_handle, HasHandle, }; diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts index 036593c7b91..84dfc4b9f51 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts @@ -17,6 +17,7 @@ import { import { ResourceBuilderBase, ReferenceExpression, + ConditionalReferenceExpression, refExpr, AspireDict, AspireList @@ -2345,7 +2346,7 @@ export async function createBuilder(options?: CreateBuilderOptions): Promise Date: Fri, 6 Mar 2026 21:00:14 -0800 Subject: [PATCH 11/45] Fix CRE marshaller test to use pattern matching for auto-generated name The MarshalToJson_ConditionalReferenceExpression_PreservesValueAfterRoundTrip test was asserting an exact name ('test-tls') but names are now auto-generated from the condition's ValueExpression. Use StartsWith assertion instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs index 61bfe986d7e..f2fa4e18255 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs @@ -977,7 +977,7 @@ public async Task MarshalToJson_ConditionalReferenceExpression_PreservesValueAft registry.TryGet(handleId, out var retrieved, out _); var retrievedConditional = Assert.IsType(retrieved); - Assert.Equal("test-tls", retrievedConditional.Name); + Assert.StartsWith("cond-test-condition", retrievedConditional.Name); Assert.Equal(",ssl=true", await retrievedConditional.GetValueAsync()); } From 02747ac47363c489a77850ea762442d45f724fc2 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 6 Mar 2026 22:08:14 -0800 Subject: [PATCH 12/45] Update TwoPassScanning snapshots after merge with release/13.2 The merge brought in updated EndpointReference.getTlsValue which no longer takes parameterName. Updated all 5 language snapshots accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Snapshots/TwoPassScanningGeneratedAspire.verified.go | 3 +-- .../TwoPassScanningGeneratedAspire.verified.java | 3 +-- .../Snapshots/TwoPassScanningGeneratedAspire.verified.py | 3 +-- .../Snapshots/TwoPassScanningGeneratedAspire.verified.rs | 3 +-- .../Snapshots/TwoPassScanningGeneratedAspire.verified.ts | 8 ++++---- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 0cf08d125e9..62759e5a142 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -1296,11 +1296,10 @@ func (s *EndpointReference) GetValueAsync(cancellationToken *CancellationToken) } // GetTlsValue gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. -func (s *EndpointReference) GetTlsValue(parameterName string, enabledValue *ReferenceExpression, disabledValue *ReferenceExpression) (*ConditionalReferenceExpression, error) { +func (s *EndpointReference) GetTlsValue(enabledValue *ReferenceExpression, disabledValue *ReferenceExpression) (*ConditionalReferenceExpression, error) { reqArgs := map[string]any{ "context": SerializeValue(s.Handle()), } - reqArgs["parameterName"] = SerializeValue(parameterName) reqArgs["enabledValue"] = SerializeValue(enabledValue) reqArgs["disabledValue"] = SerializeValue(disabledValue) result, err := s.Client().InvokeCapability("Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue", reqArgs) diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index cb8dee4ee7d..fde71ff8a6d 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -1177,10 +1177,9 @@ public String getValueAsync(CancellationToken cancellationToken) { } /** Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. */ - public ConditionalReferenceExpression getTlsValue(String parameterName, ReferenceExpression enabledValue, ReferenceExpression disabledValue) { + public ConditionalReferenceExpression getTlsValue(ReferenceExpression enabledValue, ReferenceExpression disabledValue) { Map reqArgs = new HashMap<>(); reqArgs.put("context", AspireClient.serializeValue(getHandle())); - reqArgs.put("parameterName", AspireClient.serializeValue(parameterName)); reqArgs.put("enabledValue", AspireClient.serializeValue(enabledValue)); reqArgs.put("disabledValue", AspireClient.serializeValue(disabledValue)); return (ConditionalReferenceExpression) getClient().invokeCapability("Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue", reqArgs); diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 4ff819eb8f8..52444b495a9 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -749,10 +749,9 @@ def get_value_async(self, cancellation_token: CancellationToken | None = None) - args["cancellationToken"] = cancellation_token_id return self._client.invoke_capability("Aspire.Hosting.ApplicationModel/getValueAsync", args) - def get_tls_value(self, parameter_name: str, enabled_value: ReferenceExpression, disabled_value: ReferenceExpression) -> ConditionalReferenceExpression: + def get_tls_value(self, enabled_value: ReferenceExpression, disabled_value: ReferenceExpression) -> ConditionalReferenceExpression: """Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise.""" args: Dict[str, Any] = { "context": serialize_value(self._handle) } - args["parameterName"] = serialize_value(parameter_name) args["enabledValue"] = serialize_value(enabled_value) args["disabledValue"] = serialize_value(disabled_value) return self._client.invoke_capability("Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue", args) diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 1c43c80a920..79d9ec6d106 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -1460,10 +1460,9 @@ impl EndpointReference { } /// Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. - pub fn get_tls_value(&self, parameter_name: &str, enabled_value: ReferenceExpression, disabled_value: ReferenceExpression) -> Result> { + pub fn get_tls_value(&self, enabled_value: ReferenceExpression, disabled_value: ReferenceExpression) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("context".to_string(), self.handle.to_json()); - args.insert("parameterName".to_string(), serde_json::to_value(¶meter_name).unwrap_or(Value::Null)); args.insert("enabledValue".to_string(), serde_json::to_value(&enabled_value).unwrap_or(Value::Null)); args.insert("disabledValue".to_string(), serde_json::to_value(&disabled_value).unwrap_or(Value::Null)); let result = self.client.invoke_capability("Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue", args)?; diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index 0eab38b2f74..101f1b5153b 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -761,8 +761,8 @@ export class EndpointReference { } /** Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. */ - async getTlsValue(parameterName: string, enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { - const rpcArgs: Record = { context: this._handle, parameterName, enabledValue, disabledValue }; + async getTlsValue(enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { + const rpcArgs: Record = { context: this._handle, enabledValue, disabledValue }; return await this._client.invokeCapability( 'Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue', rpcArgs @@ -790,8 +790,8 @@ export class EndpointReferencePromise implements PromiseLike } /** Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. */ - getTlsValue(parameterName: string, enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { - return this._promise.then(obj => obj.getTlsValue(parameterName, enabledValue, disabledValue)); + getTlsValue(enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { + return this._promise.then(obj => obj.getTlsValue(enabledValue, disabledValue)); } } From af482ba1f22822ac55a206db9dd60113e1205b08 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 10:26:21 -0800 Subject: [PATCH 13/45] Merge ConditionalReferenceExpression into ReferenceExpression - Add ReferenceExpression.CreateConditional() factory method - Add conditional mode with IsConditional, Condition, WhenTrue, WhenFalse properties - Unify ATS wire format: conditional uses $expr with condition/whenTrue/whenFalse - Remove ConditionalReferenceExpression class and ConditionalReferenceExpressionRef - Remove $condExpr from ATS protocol, remove ConditionalReferenceExpressionTypeId - Update all 5 polyglot base templates (TS, Python, Go, Java, Rust) - Update all 5 codegen generators to remove CRE special-casing - Make ReferenceExpression.Name internal - Migrate all tests to use new API --- .../AtsGoCodeGenerator.cs | 15 +- .../Resources/base.go | 92 +++++---- .../AtsJavaCodeGenerator.cs | 16 +- .../Resources/Base.java | 114 ++++++----- .../AtsPythonCodeGenerator.cs | 15 +- .../Resources/base.py | 74 +++---- .../AtsRustCodeGenerator.cs | 24 +-- .../Resources/base.rs | 90 +++++---- .../AtsTypeScriptCodeGenerator.cs | 13 +- .../Resources/base.ts | 158 ++++++--------- src/Aspire.Hosting.Redis/RedisResource.cs | 10 +- .../Ats/AtsMarshaller.cs | 13 +- .../Ats/ConditionalReferenceExpressionRef.cs | 135 ------------- .../Ats/ReferenceExpressionRef.cs | 109 +++++++++-- .../ConditionalReferenceExpression.cs | 176 ----------------- .../ApplicationModel/EndpointReference.cs | 8 +- .../ApplicationModel/ReferenceExpression.cs | 181 +++++++++++++++++- src/Aspire.Hosting/Ats/AtsConstants.cs | 5 - .../Publishing/ManifestPublishingContext.cs | 10 +- .../AddRedisTests.cs | 4 +- .../AtsMarshallerTests.cs | 52 ++--- .../HandleRegistryTests.cs | 4 +- .../ConditionalReferenceExpressionTests.cs | 31 ++- .../ManifestGenerationTests.cs | 9 +- 24 files changed, 616 insertions(+), 742 deletions(-) delete mode 100644 src/Aspire.Hosting.RemoteHost/Ats/ConditionalReferenceExpressionRef.cs delete mode 100644 src/Aspire.Hosting/ApplicationModel/ConditionalReferenceExpression.cs diff --git a/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs index f74c4f64201..d6372258a5e 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs @@ -160,12 +160,6 @@ private void GenerateDtoTypes(IReadOnlyList dtoTypes) continue; } - // Skip ConditionalReferenceExpression - it's defined in base.go - if (dto.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) - { - continue; - } - var dtoName = _dtoNames[dto.TypeId]; WriteLine($"// {dtoName} represents {dto.Name}."); WriteLine($"type {dtoName} struct {{"); @@ -542,7 +536,6 @@ private IReadOnlyList BuildHandleTypes(AtsContext context) { // Skip ReferenceExpression and CancellationToken - they're defined in base.go/transport.go if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId - || handleType.AtsTypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(handleType.AtsTypeId)) { continue; @@ -665,11 +658,6 @@ private string MapTypeRefToGo(AtsTypeRef? typeRef, bool isOptional) return "*ReferenceExpression"; } - if (typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) - { - return "*ConditionalReferenceExpression"; - } - var baseType = typeRef.Category switch { AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId), @@ -731,9 +719,8 @@ private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsType return; } - // Skip ReferenceExpression, ConditionalReferenceExpression and CancellationToken - they're defined in base.go/transport.go + // Skip ReferenceExpression and CancellationToken - they're defined in base.go/transport.go if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId - || typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(typeRef.TypeId)) { return; diff --git a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go index e19e0d086f0..896530e62f1 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go +++ b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go @@ -37,16 +37,44 @@ func NewResourceBuilderBase(handle *Handle, client *AspireClient) ResourceBuilde } // ReferenceExpression represents a reference expression. +// Supports value mode (Format + Args), conditional mode (Condition + WhenTrue + WhenFalse), +// and handle mode (wrapping a server-returned handle). type ReferenceExpression struct { + // Value mode fields Format string Args []any + + // Conditional mode fields + Condition any + WhenTrue *ReferenceExpression + WhenFalse *ReferenceExpression + + // Handle mode fields (when wrapping a server-returned handle) + handle *Handle + client *AspireClient + isConditional bool } -// NewReferenceExpression creates a new reference expression. +// NewReferenceExpression creates a new reference expression in value mode. func NewReferenceExpression(format string, args ...any) *ReferenceExpression { return &ReferenceExpression{Format: format, Args: args} } +// NewReferenceExpressionFromHandle creates a ReferenceExpression wrapping a server-returned handle. +func NewReferenceExpressionFromHandle(handle *Handle, client *AspireClient) *ReferenceExpression { + return &ReferenceExpression{handle: handle, client: client} +} + +// CreateConditionalReferenceExpression creates a conditional reference expression from its parts. +func CreateConditionalReferenceExpression(condition any, whenTrue *ReferenceExpression, whenFalse *ReferenceExpression) *ReferenceExpression { + return &ReferenceExpression{ + Condition: condition, + WhenTrue: whenTrue, + WhenFalse: whenFalse, + isConditional: true, + } +} + // RefExpr is a convenience function for creating reference expressions. func RefExpr(format string, args ...any) *ReferenceExpression { return NewReferenceExpression(format, args...) @@ -54,6 +82,18 @@ func RefExpr(format string, args ...any) *ReferenceExpression { // ToJSON returns the reference expression as a JSON-serializable map. func (r *ReferenceExpression) ToJSON() map[string]any { + if r.handle != nil { + return r.handle.ToJSON() + } + if r.isConditional { + return map[string]any{ + "$refExpr": map[string]any{ + "condition": SerializeValue(r.Condition), + "whenTrue": r.WhenTrue.ToJSON(), + "whenFalse": r.WhenFalse.ToJSON(), + }, + } + } return map[string]any{ "$refExpr": map[string]any{ "format": r.Format, @@ -62,56 +102,14 @@ func (r *ReferenceExpression) ToJSON() map[string]any { } } -// ConditionalReferenceExpression represents a conditional expression that selects -// between two ReferenceExpression branches based on a boolean condition. -// The condition and branches are evaluated on the AppHost server. -type ConditionalReferenceExpression struct { - // Expression mode fields (from CreateConditionalReferenceExpression) - Condition any - WhenTrue *ReferenceExpression - WhenFalse *ReferenceExpression - - // Handle mode fields (when wrapping a server-returned handle) - handle *Handle - client *AspireClient -} - -// NewConditionalReferenceExpression creates a new ConditionalReferenceExpression from a handle. -func NewConditionalReferenceExpression(handle *Handle, client *AspireClient) *ConditionalReferenceExpression { - return &ConditionalReferenceExpression{handle: handle, client: client} -} - -// CreateConditionalReferenceExpression creates a conditional reference expression from its parts. -func CreateConditionalReferenceExpression(condition any, whenTrue *ReferenceExpression, whenFalse *ReferenceExpression) *ConditionalReferenceExpression { - return &ConditionalReferenceExpression{ - Condition: condition, - WhenTrue: whenTrue, - WhenFalse: whenFalse, - } -} - -// ToJSON returns the conditional reference expression as a JSON-serializable map. -func (c *ConditionalReferenceExpression) ToJSON() map[string]any { - if c.handle != nil { - return c.handle.ToJSON() - } - return map[string]any{ - "$condExpr": map[string]any{ - "condition": SerializeValue(c.Condition), - "whenTrue": c.WhenTrue.ToJSON(), - "whenFalse": c.WhenFalse.ToJSON(), - }, - } -} - // Handle returns the underlying handle, if in handle mode. -func (c *ConditionalReferenceExpression) Handle() *Handle { - return c.handle +func (r *ReferenceExpression) Handle() *Handle { + return r.handle } // Client returns the AspireClient, if in handle mode. -func (c *ConditionalReferenceExpression) Client() *AspireClient { - return c.client +func (r *ReferenceExpression) Client() *AspireClient { + return r.client } // AspireList is a handle-backed list with lazy handle resolution. diff --git a/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs index e8a95f4bbc0..a69814b8ef7 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs @@ -179,11 +179,6 @@ private void GenerateDtoTypes(IReadOnlyList dtoTypes) continue; } - if (dto.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) - { - continue; - } - var dtoName = _dtoNames[dto.TypeId]; WriteLine($"/** {dto.Name} DTO. */"); WriteLine($"class {dtoName} {{"); @@ -506,9 +501,8 @@ private IReadOnlyList BuildHandleTypes(AtsContext context) var handleTypeIds = new HashSet(StringComparer.Ordinal); foreach (var handleType in context.HandleTypes) { - // Skip ReferenceExpression, ConditionalReferenceExpression and CancellationToken - they're defined in Base.java/Transport.java + // Skip ReferenceExpression and CancellationToken - they're defined in Base.java/Transport.java if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId - || handleType.AtsTypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(handleType.AtsTypeId)) { continue; @@ -629,11 +623,6 @@ private string MapTypeRefToJava(AtsTypeRef? typeRef, bool isOptional) return "ReferenceExpression"; } - if (typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) - { - return "ConditionalReferenceExpression"; - } - var baseType = typeRef.Category switch { AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId, isOptional), @@ -694,9 +683,8 @@ private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsType return; } - // Skip ReferenceExpression, ConditionalReferenceExpression and CancellationToken - they're defined in Base.java/Transport.java + // Skip ReferenceExpression and CancellationToken - they're defined in Base.java/Transport.java if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId - || typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(typeRef.TypeId)) { return; diff --git a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java index 699e7fc696d..3cbeac0f345 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java +++ b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java @@ -37,14 +37,58 @@ class ResourceBuilderBase extends HandleWrapperBase { /** * ReferenceExpression represents a reference expression. + * Supports value mode (format + args), conditional mode (condition + whenTrue + whenFalse), + * and handle mode (wrapping a server-returned handle). */ class ReferenceExpression { + // Value mode fields private final String format; private final Object[] args; + // Conditional mode fields + private final Object condition; + private final ReferenceExpression whenTrue; + private final ReferenceExpression whenFalse; + private final boolean isConditional; + + // Handle mode fields + private final Handle handle; + private final AspireClient client; + + // Value mode constructor ReferenceExpression(String format, Object... args) { this.format = format; this.args = args; + this.condition = null; + this.whenTrue = null; + this.whenFalse = null; + this.isConditional = false; + this.handle = null; + this.client = null; + } + + // Handle mode constructor + ReferenceExpression(Handle handle, AspireClient client) { + this.handle = handle; + this.client = client; + this.format = null; + this.args = null; + this.condition = null; + this.whenTrue = null; + this.whenFalse = null; + this.isConditional = false; + } + + // Conditional mode constructor + private ReferenceExpression(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { + this.condition = condition; + this.whenTrue = whenTrue; + this.whenFalse = whenFalse; + this.isConditional = true; + this.format = null; + this.args = null; + this.handle = null; + this.client = null; } String getFormat() { @@ -55,7 +99,25 @@ Object[] getArgs() { return args; } + Handle getHandle() { + return handle; + } + Map toJson() { + if (handle != null) { + return handle.toJson(); + } + if (isConditional) { + var condPayload = new java.util.HashMap(); + condPayload.put("condition", AspireClient.serializeValue(condition)); + condPayload.put("whenTrue", whenTrue.toJson()); + condPayload.put("whenFalse", whenFalse.toJson()); + + var result = new java.util.HashMap(); + result.put("$refExpr", condPayload); + return result; + } + Map refExpr = new HashMap<>(); refExpr.put("format", format); refExpr.put("args", Arrays.asList(args)); @@ -71,60 +133,12 @@ Map toJson() { static ReferenceExpression refExpr(String format, Object... args) { return new ReferenceExpression(format, args); } -} - -/** - * ConditionalReferenceExpression represents a conditional expression that selects - * between two ReferenceExpression branches. The condition and branches are evaluated - * on the AppHost server. - */ -class ConditionalReferenceExpression { - // Expression mode fields - private final Object condition; - private final ReferenceExpression whenTrue; - private final ReferenceExpression whenFalse; - - // Handle mode fields - private final Handle handle; - private final AspireClient client; - - // Handle mode constructor - ConditionalReferenceExpression(Handle handle, AspireClient client) { - this.handle = handle; - this.client = client; - this.condition = null; - this.whenTrue = null; - this.whenFalse = null; - } - - // Expression mode constructor - private ConditionalReferenceExpression(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { - this.handle = null; - this.client = null; - this.condition = condition; - this.whenTrue = whenTrue; - this.whenFalse = whenFalse; - } /** * Creates a conditional reference expression from its parts. */ - static ConditionalReferenceExpression create(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { - return new ConditionalReferenceExpression(condition, whenTrue, whenFalse); - } - - Map toJson() { - if (handle != null) { - return handle.toJson(); - } - var condExpr = new java.util.HashMap(); - condExpr.put("condition", AspireClient.serializeValue(condition)); - condExpr.put("whenTrue", whenTrue.toJson()); - condExpr.put("whenFalse", whenFalse.toJson()); - - var result = new java.util.HashMap(); - result.put("$condExpr", condExpr); - return result; + static ReferenceExpression createConditional(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { + return new ReferenceExpression(condition, whenTrue, whenFalse); } } diff --git a/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs index 2f79dad913b..d001a9ee14c 100644 --- a/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs @@ -104,7 +104,7 @@ private void WriteHeader() WriteLine("from typing import Any, Callable, Dict, List"); WriteLine(); WriteLine("from transport import AspireClient, Handle, CapabilityError, register_callback, register_handle_wrapper, register_cancellation"); - WriteLine("from base import AspireDict, AspireList, ReferenceExpression, ConditionalReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value"); + WriteLine("from base import AspireDict, AspireList, ReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value"); WriteLine(); } @@ -203,9 +203,8 @@ private void GenerateHandleTypes( foreach (var handleType in handleTypes.OrderBy(t => t.ClassName, StringComparer.Ordinal)) { - // Skip types defined in base.py (ReferenceExpression, ConditionalReferenceExpression) - if (handleType.TypeId == AtsConstants.ReferenceExpressionTypeId || - handleType.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + // Skip types defined in base.py (ReferenceExpression) + if (handleType.TypeId == AtsConstants.ReferenceExpressionTypeId) { continue; } @@ -364,8 +363,7 @@ private void GenerateHandleWrapperRegistrations( foreach (var handleType in handleTypes) { // Skip types defined in base.py - if (handleType.TypeId == AtsConstants.ReferenceExpressionTypeId || - handleType.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + if (handleType.TypeId == AtsConstants.ReferenceExpressionTypeId) { continue; } @@ -588,11 +586,6 @@ private string MapTypeRefToPython(AtsTypeRef? typeRef) return nameof(ReferenceExpression); } - if (typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) - { - return "ConditionalReferenceExpression"; - } - return typeRef.Category switch { AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId), diff --git a/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py b/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py index 185cefb6e35..8cdcab18a9a 100644 --- a/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py +++ b/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py @@ -8,64 +8,74 @@ class ReferenceExpression: - """Represents a reference expression passed to capabilities.""" + """Represents a reference expression passed to capabilities. + Supports both value mode (format + valueProviders) and conditional mode (condition + whenTrue + whenFalse).""" def __init__(self, format_string: str, value_providers: List[Any]) -> None: self._format_string = format_string self._value_providers = value_providers + self._handle: Handle | None = None + self._client: AspireClient | None = None + self._condition: Any = None + self._when_true: ReferenceExpression | None = None + self._when_false: ReferenceExpression | None = None + self._is_conditional = False @staticmethod def create(format_string: str, *values: Any) -> "ReferenceExpression": value_providers = [_extract_reference_value(value) for value in values] return ReferenceExpression(format_string, value_providers) - def to_json(self) -> Dict[str, Any]: - payload: Dict[str, Any] = {"format": self._format_string} - if self._value_providers: - payload["valueProviders"] = self._value_providers - return {"$expr": payload} - - def __str__(self) -> str: - return f"ReferenceExpression({self._format_string})" - - -class ConditionalReferenceExpression: - """Represents a conditional expression that selects between two ReferenceExpression branches. - The condition and branches are evaluated on the AppHost server.""" - - def __init__(self, handle: "Handle", client: "AspireClient") -> None: - self._handle = handle - self._client = client - self._condition: Any = None - self._when_true: ReferenceExpression | None = None - self._when_false: ReferenceExpression | None = None - @staticmethod - def create(condition: Any, when_true: ReferenceExpression, when_false: ReferenceExpression) -> "ConditionalReferenceExpression": + def create_conditional(condition: Any, when_true: "ReferenceExpression", when_false: "ReferenceExpression") -> "ReferenceExpression": """Creates a conditional reference expression from its parts.""" - expr = ConditionalReferenceExpression.__new__(ConditionalReferenceExpression) + expr = ReferenceExpression.__new__(ReferenceExpression) + expr._format_string = "" + expr._value_providers = [] expr._handle = None expr._client = None expr._condition = condition expr._when_true = when_true expr._when_false = when_false + expr._is_conditional = True + return expr + + @staticmethod + def _from_handle(handle: "Handle", client: "AspireClient") -> "ReferenceExpression": + """Creates a ReferenceExpression wrapping a server-returned handle.""" + expr = ReferenceExpression.__new__(ReferenceExpression) + expr._format_string = "" + expr._value_providers = [] + expr._handle = handle + expr._client = client + expr._condition = None + expr._when_true = None + expr._when_false = None + expr._is_conditional = False return expr def to_json(self) -> Dict[str, Any]: if self._handle is not None: return self._handle.to_json() - return { - "$condExpr": { - "condition": serialize_value(self._condition), - "whenTrue": self._when_true.to_json(), - "whenFalse": self._when_false.to_json(), + if self._is_conditional: + return { + "$expr": { + "condition": serialize_value(self._condition), + "whenTrue": self._when_true.to_json(), + "whenFalse": self._when_false.to_json(), + } } - } + payload: Dict[str, Any] = {"format": self._format_string} + if self._value_providers: + payload["valueProviders"] = self._value_providers + return {"$expr": payload} def __str__(self) -> str: if self._handle is not None: - return "ConditionalReferenceExpression(handle)" - return "ConditionalReferenceExpression(expr)" + return "ReferenceExpression(handle)" + if self._is_conditional: + return "ReferenceExpression(conditional)" + return f"ReferenceExpression({self._format_string})" def ref_expr(format_string: str, *values: Any) -> ReferenceExpression: diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs index ac31e26bf40..32cd3090232 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs @@ -119,7 +119,7 @@ private void WriteHeader() WriteLine(" register_callback, register_cancellation, serialize_value,"); WriteLine("};"); WriteLine("use crate::base::{"); - WriteLine(" HandleWrapperBase, ResourceBuilderBase, ReferenceExpression, ConditionalReferenceExpression,"); + WriteLine(" HandleWrapperBase, ResourceBuilderBase, ReferenceExpression,"); WriteLine(" AspireList, AspireDict, serialize_handle, HasHandle,"); WriteLine("};"); WriteLine(); @@ -199,11 +199,6 @@ private void GenerateDtoTypes(IReadOnlyList dtoTypes) continue; } - if (dto.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) - { - continue; - } - var dtoName = _dtoNames[dto.TypeId]; WriteLine($"/// {dto.Name}"); WriteLine("#[derive(Debug, Clone, Default, Serialize, Deserialize)]"); @@ -573,9 +568,8 @@ private IReadOnlyList BuildHandleTypes(AtsContext context) var handleTypeIds = new HashSet(StringComparer.Ordinal); foreach (var handleType in context.HandleTypes) { - // Skip ReferenceExpression, ConditionalReferenceExpression and CancellationToken - they're defined in base.rs/transport.rs + // Skip ReferenceExpression and CancellationToken - they're defined in base.rs/transport.rs if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId - || handleType.AtsTypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(handleType.AtsTypeId)) { continue; @@ -695,11 +689,6 @@ private string MapTypeRefToRust(AtsTypeRef? typeRef, bool isOptional) return isOptional ? "Option" : "ReferenceExpression"; } - if (typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) - { - return isOptional ? "Option" : "ConditionalReferenceExpression"; - } - var baseType = typeRef.Category switch { AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId), @@ -738,11 +727,6 @@ private string MapTypeRefToRustForDto(AtsTypeRef? typeRef, bool isOptional) return isOptional ? "Option" : "ReferenceExpression"; } - if (typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) - { - return isOptional ? "Option" : "ConditionalReferenceExpression"; - } - var baseType = typeRef.Category switch { AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId), @@ -822,7 +806,6 @@ AtsConstants.DateTime or AtsConstants.DateTimeOffset or private static bool IsHandleType(AtsTypeRef? typeRef) => typeRef?.Category == AtsTypeCategory.Handle && typeRef.TypeId != AtsConstants.ReferenceExpressionTypeId - && typeRef.TypeId != AtsConstants.ConditionalReferenceExpressionTypeId && !IsCancellationTokenTypeId(typeRef.TypeId); private static bool IsCancellationToken(AtsParameterInfo parameter) => @@ -839,9 +822,8 @@ private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsType return; } - // Skip ReferenceExpression, ConditionalReferenceExpression and CancellationToken - they're defined in base.rs/transport.rs + // Skip ReferenceExpression and CancellationToken - they're defined in base.rs/transport.rs if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId - || typeRef.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId || IsCancellationTokenTypeId(typeRef.TypeId)) { return; diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs index bc6acf4b79a..9fff49d6e38 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs @@ -50,62 +50,62 @@ impl ResourceBuilderBase { } /// A reference expression for dynamic values. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Supports value mode (format + args), conditional mode (condition + whenTrue + whenFalse), +/// and handle mode (wrapping a server-returned handle). pub struct ReferenceExpression { - pub format: String, - pub args: Vec, -} - -impl ReferenceExpression { - pub fn new(format: impl Into, args: Vec) -> Self { - Self { - format: format.into(), - args, - } - } - - pub fn to_json(&self) -> Value { - json!({ - "$refExpr": { - "format": self.format, - "args": self.args - } - }) - } -} + // Value mode fields + pub format: Option, + pub args: Option>, -/// A conditional reference expression that selects between two ReferenceExpression branches. -/// The condition and branches are evaluated on the AppHost server. -pub struct ConditionalReferenceExpression { - // Expression mode fields + // Conditional mode fields condition: Option, - when_true: Option, - when_false: Option, + when_true: Option>, + when_false: Option>, + is_conditional: bool, // Handle mode fields handle: Option, client: Option>, } -impl ConditionalReferenceExpression { - /// Creates a new ConditionalReferenceExpression from a server-returned handle. - pub fn new(handle: Handle, client: Arc) -> Self { +impl ReferenceExpression { + /// Creates a new value-mode reference expression. + pub fn new(format: impl Into, args: Vec) -> Self { Self { + format: Some(format.into()), + args: Some(args), condition: None, when_true: None, when_false: None, + is_conditional: false, + handle: None, + client: None, + } + } + + /// Creates a new handle-mode reference expression from a server-returned handle. + pub fn from_handle(handle: Handle, client: Arc) -> Self { + Self { + format: None, + args: None, + condition: None, + when_true: None, + when_false: None, + is_conditional: false, handle: Some(handle), client: Some(client), } } /// Creates a conditional reference expression from its parts. - pub fn create(condition: Value, when_true: ReferenceExpression, when_false: ReferenceExpression) -> Self { + pub fn create_conditional(condition: Value, when_true: ReferenceExpression, when_false: ReferenceExpression) -> Self { Self { - name: None, + format: None, + args: None, condition: Some(condition), - when_true: Some(when_true), - when_false: Some(when_false), + when_true: Some(Box::new(when_true)), + when_false: Some(Box::new(when_false)), + is_conditional: true, handle: None, client: None, } @@ -123,19 +123,27 @@ impl ConditionalReferenceExpression { if let Some(ref handle) = self.handle { return handle.to_json(); } + if self.is_conditional { + return json!({ + "$refExpr": { + "condition": serialize_value(self.condition.clone().unwrap()), + "whenTrue": self.when_true.as_ref().unwrap().to_json(), + "whenFalse": self.when_false.as_ref().unwrap().to_json() + } + }); + } json!({ - "$condExpr": { - "condition": serialize_value(self.condition.clone().unwrap()), - "whenTrue": self.when_true.as_ref().unwrap().to_json(), - "whenFalse": self.when_false.as_ref().unwrap().to_json() + "$refExpr": { + "format": self.format.as_ref().unwrap(), + "args": self.args.as_ref().unwrap() } }) } } -impl HasHandle for ConditionalReferenceExpression { +impl HasHandle for ReferenceExpression { fn handle(&self) -> &Handle { - self.handle.as_ref().expect("ConditionalReferenceExpression is in expression mode, not handle mode") + self.handle.as_ref().expect("ReferenceExpression is not in handle mode") } } diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs index b3845009204..8546030bfc9 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs @@ -340,7 +340,6 @@ private string GenerateAspireSdk(AtsContext context) import { ResourceBuilderBase, ReferenceExpression, - ConditionalReferenceExpression, refExpr, AspireDict, AspireList @@ -438,8 +437,6 @@ private string GenerateAspireSdk(AtsContext context) } // Add ReferenceExpression (defined in base.ts, not generated) _wrapperClassNames[AtsConstants.ReferenceExpressionTypeId] = "ReferenceExpression"; - // Add ConditionalReferenceExpression (defined in base.ts, not generated) - _wrapperClassNames[AtsConstants.ConditionalReferenceExpressionTypeId] = "ConditionalReferenceExpression"; // Pre-scan all capabilities to collect options interfaces // This must happen AFTER wrapper class names are populated so types resolve correctly @@ -459,11 +456,10 @@ private string GenerateAspireSdk(AtsContext context) GenerateOptionsInterfaces(); // Generate type classes (context types and wrapper types) - // Skip types defined in base.ts (ReferenceExpression, ConditionalReferenceExpression) + // Skip types defined in base.ts (ReferenceExpression) foreach (var typeClass in typeClasses) { - if (typeClass.TypeId == AtsConstants.ReferenceExpressionTypeId || - typeClass.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + if (typeClass.TypeId == AtsConstants.ReferenceExpressionTypeId) { continue; } @@ -1440,7 +1436,7 @@ export async function createBuilder(options?: CreateBuilderOptions): Promise typeClasses, // Skip types defined in base.ts (their wrapper registration is handled differently) foreach (var typeClass in typeClasses) { - if (typeClass.TypeId == AtsConstants.ReferenceExpressionTypeId || - typeClass.TypeId == AtsConstants.ConditionalReferenceExpressionTypeId) + if (typeClass.TypeId == AtsConstants.ReferenceExpressionTypeId) { continue; } diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts index f8152cb45ed..964b114b7e1 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts @@ -43,22 +43,43 @@ export class ReferenceExpression { private readonly _format?: string; private readonly _valueProviders?: unknown[]; + // Conditional mode fields + private readonly _condition?: unknown; + private readonly _whenTrue?: ReferenceExpression; + private readonly _whenFalse?: ReferenceExpression; + // Handle mode fields (when wrapping a server-returned handle) private readonly _handle?: Handle; private readonly _client?: AspireClient; constructor(format: string, valueProviders: unknown[]); constructor(handle: Handle, client: AspireClient); - constructor(handleOrFormat: Handle | string, clientOrValueProviders: AspireClient | unknown[]) { - if (typeof handleOrFormat === 'string') { - this._format = handleOrFormat; - this._valueProviders = clientOrValueProviders as unknown[]; + constructor(condition: unknown, whenTrue: ReferenceExpression, whenFalse: ReferenceExpression); + constructor( + handleOrFormatOrCondition: Handle | string | unknown, + clientOrValueProvidersOrWhenTrue: AspireClient | unknown[] | ReferenceExpression, + whenFalse?: ReferenceExpression + ) { + if (typeof handleOrFormatOrCondition === 'string') { + this._format = handleOrFormatOrCondition; + this._valueProviders = clientOrValueProvidersOrWhenTrue as unknown[]; + } else if (handleOrFormatOrCondition instanceof Handle) { + this._handle = handleOrFormatOrCondition; + this._client = clientOrValueProvidersOrWhenTrue as AspireClient; } else { - this._handle = handleOrFormat; - this._client = clientOrValueProviders as AspireClient; + this._condition = handleOrFormatOrCondition; + this._whenTrue = clientOrValueProvidersOrWhenTrue as ReferenceExpression; + this._whenFalse = whenFalse; } } + /** + * Gets whether this reference expression is conditional. + */ + get isConditional(): boolean { + return this._condition !== undefined; + } + /** * Creates a reference expression from a tagged template literal. * @@ -82,16 +103,43 @@ export class ReferenceExpression { return new ReferenceExpression(format, valueProviders); } + /** + * Creates a conditional reference expression from its constituent parts. + * + * @param condition - A value provider whose result is compared to "True" + * @param whenTrue - The expression to use when the condition is true + * @param whenFalse - The expression to use when the condition is false + * @returns A ReferenceExpression instance in conditional mode + */ + static createConditional( + condition: unknown, + whenTrue: ReferenceExpression, + whenFalse: ReferenceExpression + ): ReferenceExpression { + return new ReferenceExpression(condition, whenTrue, whenFalse); + } + /** * Serializes the reference expression for JSON-RPC transport. - * In template-literal mode, uses the $expr format. + * In expression mode, uses the $expr format with format + valueProviders. + * In conditional mode, uses the $expr format with condition + whenTrue + whenFalse. * In handle mode, delegates to the handle's serialization. */ - toJSON(): { $expr: { format: string; valueProviders?: unknown[] } } | MarshalledHandle { + toJSON(): { $expr: { format: string; valueProviders?: unknown[] } | { condition: unknown; whenTrue: unknown; whenFalse: unknown } } | MarshalledHandle { if (this._handle) { return this._handle.toJSON(); } + if (this.isConditional) { + return { + $expr: { + condition: wrapIfHandle(this._condition), + whenTrue: this._whenTrue!.toJSON(), + whenFalse: this._whenFalse!.toJSON() + } + }; + } + return { $expr: { format: this._format!, @@ -107,6 +155,9 @@ export class ReferenceExpression { if (this._handle) { return `ReferenceExpression(handle)`; } + if (this.isConditional) { + return `ReferenceExpression(conditional)`; + } return `ReferenceExpression(${this._format})`; } } @@ -177,97 +228,6 @@ export function refExpr(strings: TemplateStringsArray, ...values: unknown[]): Re return ReferenceExpression.create(strings, ...values); } -// ============================================================================ -// Conditional Reference Expression -// ============================================================================ - -/** - * Represents a conditional reference expression that selects between two - * {@link ReferenceExpression} branches based on a boolean condition. - * - * The condition and branches are evaluated on the AppHost server; the client - * receives the resolved result. - * - * @example - * ```typescript - * const endpoint = await redis.getEndpoint("tcp"); - * const tlsExpr = await endpoint.getTlsValue( - * "redis-tcp-tls-value", - * refExpr`,ssl=true`, - * refExpr`` - * ); - * ``` - */ -export class ConditionalReferenceExpression { - // Expression mode fields (from create()) - private readonly _condition?: unknown; - private readonly _whenTrue?: ReferenceExpression; - private readonly _whenFalse?: ReferenceExpression; - - // Handle mode fields (when wrapping a server-returned handle) - private readonly _handle?: Handle; - private readonly _client?: AspireClient; - - constructor(condition: unknown, whenTrue: ReferenceExpression, whenFalse: ReferenceExpression); - constructor(handle: Handle, client: AspireClient); - constructor( - handleOrCondition: Handle | unknown, - clientOrWhenTrue: AspireClient | ReferenceExpression, - whenFalse?: ReferenceExpression - ) { - if (handleOrCondition instanceof Handle) { - this._handle = handleOrCondition; - this._client = clientOrWhenTrue as AspireClient; - } else { - this._condition = handleOrCondition; - this._whenTrue = clientOrWhenTrue as ReferenceExpression; - this._whenFalse = whenFalse; - } - } - - /** - * Creates a conditional reference expression from its constituent parts. - * - * @param condition - A value provider whose result is compared to "True" - * @param whenTrue - The expression to use when the condition is true - * @param whenFalse - The expression to use when the condition is false - * @returns A ConditionalReferenceExpression instance - */ - static create( - condition: unknown, - whenTrue: ReferenceExpression, - whenFalse: ReferenceExpression - ): ConditionalReferenceExpression { - return new ConditionalReferenceExpression(condition, whenTrue, whenFalse); - } - - /** - * Serializes the conditional reference expression for JSON-RPC transport. - * In expression mode, uses the $condExpr format. - * In handle mode, delegates to the handle's serialization. - */ - toJSON(): { $condExpr: { condition: unknown; whenTrue: unknown; whenFalse: unknown } } | MarshalledHandle { - if (this._handle) { - return this._handle.toJSON(); - } - - return { - $condExpr: { - condition: wrapIfHandle(this._condition), - whenTrue: this._whenTrue!.toJSON(), - whenFalse: this._whenFalse!.toJSON() - } - }; - } - - toString(): string { - if (this._handle) { - return `ConditionalReferenceExpression(handle)`; - } - return `ConditionalReferenceExpression(expr)`; - } -} - // ============================================================================ // ResourceBuilderBase // ============================================================================ diff --git a/src/Aspire.Hosting.Redis/RedisResource.cs b/src/Aspire.Hosting.Redis/RedisResource.cs index 6bbfd01410e..8b373e2e715 100644 --- a/src/Aspire.Hosting.Redis/RedisResource.cs +++ b/src/Aspire.Hosting.Redis/RedisResource.cs @@ -81,8 +81,15 @@ public RedisResource(string name, ParameterResource password) : this(name) /// internal List Args { get; set; } = new(); + private ReferenceExpression? _cachedConnectionString; + private ReferenceExpression BuildConnectionString() { + if (_cachedConnectionString is not null) + { + return _cachedConnectionString; + } + var builder = new ReferenceExpressionBuilder(); builder.Append($"{PrimaryEndpoint.Property(EndpointProperty.HostAndPort)}"); @@ -95,7 +102,8 @@ private ReferenceExpression BuildConnectionString() enabledValue: ReferenceExpression.Create($",ssl=true"), disabledValue: ReferenceExpression.Empty)}"); - return builder.Build(); + _cachedConnectionString = builder.Build(); + return _cachedConnectionString; } /// diff --git a/src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs b/src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs index e384cdd0cdc..41112125fd8 100644 --- a/src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs +++ b/src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs @@ -300,22 +300,15 @@ public static bool IsSimpleType(Type type) return handleObj; } - // Check for reference expression (similar to handle, but constructs a ReferenceExpression) - // Format: { "$expr": { "format": "...", "valueProviders": [...] } } + // Check for reference expression (value or conditional) + // Value format: { "$expr": { "format": "...", "valueProviders": [...] } } + // Conditional format: { "$expr": { "condition": , "whenTrue": <$expr>, "whenFalse": <$expr> } } var exprRef = ReferenceExpressionRef.FromJsonNode(node); if (exprRef != null) { return exprRef.ToReferenceExpression(_handles, capabilityId, paramName); } - // Check for conditional reference expression - // Format: { "$condExpr": { "name": "...", "condition": , "whenTrue": <$expr>, "whenFalse": <$expr> } } - var condExprRef = ConditionalReferenceExpressionRef.FromJsonNode(node); - if (condExprRef != null) - { - return condExprRef.ToConditionalReferenceExpression(_handles, capabilityId, paramName); - } - // Handle callbacks - any delegate type is treated as a callback if (typeof(Delegate).IsAssignableFrom(targetType)) { diff --git a/src/Aspire.Hosting.RemoteHost/Ats/ConditionalReferenceExpressionRef.cs b/src/Aspire.Hosting.RemoteHost/Ats/ConditionalReferenceExpressionRef.cs deleted file mode 100644 index c116b854a87..00000000000 --- a/src/Aspire.Hosting.RemoteHost/Ats/ConditionalReferenceExpressionRef.cs +++ /dev/null @@ -1,135 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Nodes; -using Aspire.Hosting.ApplicationModel; - -namespace Aspire.Hosting.RemoteHost.Ats; - -/// -/// Reference to a ConditionalReferenceExpression in the ATS protocol. -/// Used when passing conditional reference expressions as arguments. -/// -/// -/// -/// Conditional reference expressions are serialized in JSON as: -/// -/// -/// { -/// "$condExpr": { -/// "condition": { "$handle": "Aspire.Hosting.ApplicationModel/EndpointReferenceExpression:1" }, -/// "whenTrue": { "$expr": { "format": ",ssl=true" } }, -/// "whenFalse": { "$expr": { "format": "" } } -/// } -/// } -/// -/// -/// The condition is a handle to an object implementing . -/// The whenTrue and whenFalse branches are reference expressions (using $expr format). -/// -/// -internal sealed class ConditionalReferenceExpressionRef -{ - /// - /// The JSON node representing the condition (a handle to an IValueProvider). - /// - public required JsonNode? Condition { get; init; } - - /// - /// The JSON node representing the whenTrue branch (a $expr reference expression). - /// - public required JsonNode? WhenTrue { get; init; } - - /// - /// The JSON node representing the whenFalse branch (a $expr reference expression). - /// - public required JsonNode? WhenFalse { get; init; } - - /// - /// Creates a ConditionalReferenceExpressionRef from a JSON node if it contains a $condExpr property. - /// - /// The JSON node to parse. - /// A ConditionalReferenceExpressionRef if the node represents a conditional expression, otherwise null. - public static ConditionalReferenceExpressionRef? FromJsonNode(JsonNode? node) - { - if (node is not JsonObject obj || !obj.TryGetPropertyValue("$condExpr", out var condExprNode)) - { - return null; - } - - if (condExprNode is not JsonObject condExprObj) - { - return null; - } - - // Get condition (required) - condExprObj.TryGetPropertyValue("condition", out var conditionNode); - - // Get whenTrue (required) - condExprObj.TryGetPropertyValue("whenTrue", out var whenTrueNode); - - // Get whenFalse (required) - condExprObj.TryGetPropertyValue("whenFalse", out var whenFalseNode); - - return new ConditionalReferenceExpressionRef - { - Condition = conditionNode, - WhenTrue = whenTrueNode, - WhenFalse = whenFalseNode - }; - } - - /// - /// Checks if a JSON node is a conditional reference expression. - /// - /// The JSON node to check. - /// True if the node contains a $condExpr property. - public static bool IsConditionalReferenceExpressionRef(JsonNode? node) - { - return node is JsonObject obj && obj.ContainsKey("$condExpr"); - } - - /// - /// Creates a ConditionalReferenceExpression from this reference by resolving handles. - /// - /// The handle registry to resolve handles from. - /// The capability ID for error messages. - /// The parameter name for error messages. - /// A constructed ConditionalReferenceExpression. - /// Thrown if handles cannot be resolved or are invalid types. - public ConditionalReferenceExpression ToConditionalReferenceExpression( - HandleRegistry handles, - string capabilityId, - string paramName) - { - // Resolve the condition handle to an IValueProvider - var conditionHandleRef = HandleRef.FromJsonNode(Condition) - ?? throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.condition", - "Condition must be a handle reference ({ $handle: \"...\" })"); - - if (!handles.TryGet(conditionHandleRef.HandleId, out var conditionObj, out _)) - { - throw CapabilityException.HandleNotFound(conditionHandleRef.HandleId, capabilityId); - } - - if (conditionObj is not IValueProvider condition) - { - throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.condition", - $"Condition handle must resolve to an IValueProvider, got {conditionObj?.GetType().Name ?? "null"}"); - } - - // Resolve whenTrue as a ReferenceExpression - var whenTrueExprRef = ReferenceExpressionRef.FromJsonNode(WhenTrue) - ?? throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.whenTrue", - "whenTrue must be a reference expression ({ $expr: { ... } })"); - var whenTrue = whenTrueExprRef.ToReferenceExpression(handles, capabilityId, $"{paramName}.whenTrue"); - - // Resolve whenFalse as a ReferenceExpression - var whenFalseExprRef = ReferenceExpressionRef.FromJsonNode(WhenFalse) - ?? throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.whenFalse", - "whenFalse must be a reference expression ({ $expr: { ... } })"); - var whenFalse = whenFalseExprRef.ToReferenceExpression(handles, capabilityId, $"{paramName}.whenFalse"); - - return ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); - } -} diff --git a/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs b/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs index c3e17861f43..cf201745bf7 100644 --- a/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs +++ b/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs @@ -12,8 +12,9 @@ namespace Aspire.Hosting.RemoteHost.Ats; /// /// /// -/// Reference expressions are serialized in JSON as: +/// Reference expressions are serialized in JSON using the $expr marker in two shapes: /// +/// Value mode — a format string with optional value-provider placeholders: /// /// { /// "$expr": { @@ -25,30 +26,40 @@ namespace Aspire.Hosting.RemoteHost.Ats; /// } /// } /// +/// Conditional mode — a ternary expression selecting between two branch expressions: +/// +/// { +/// "$expr": { +/// "condition": { "$handle": "Aspire.Hosting.ApplicationModel/EndpointReferenceExpression:1" }, +/// "whenTrue": { "$expr": { "format": ",ssl=true" } }, +/// "whenFalse": { "$expr": { "format": "" } } +/// } +/// } +/// /// -/// The format string uses {0}, {1}, etc. placeholders that correspond to the -/// value providers array. Each value provider can be: +/// The presence of a condition property inside the $expr object distinguishes +/// conditional mode from value mode. /// -/// -/// A handle to an object that implements both and -/// A string literal that will be included directly in the expression -/// /// internal sealed class ReferenceExpressionRef { - /// - /// The format string with placeholders (e.g., "redis://{0}:{1}"). - /// - public required string Format { get; init; } + // Value mode fields + public string? Format { get; init; } + public JsonNode?[]? ValueProviders { get; init; } + + // Conditional mode fields + public JsonNode? Condition { get; init; } + public JsonNode? WhenTrue { get; init; } + public JsonNode? WhenFalse { get; init; } /// - /// The value provider handles corresponding to placeholders in the format string. - /// Each element is the JSON representation of a handle reference. + /// Gets a value indicating whether this reference represents a conditional expression. /// - public JsonNode?[]? ValueProviders { get; init; } + public bool IsConditional => Condition is not null; /// /// Creates a ReferenceExpressionRef from a JSON node if it contains a $expr property. + /// Handles both value mode (format + valueProviders) and conditional mode (condition + whenTrue + whenFalse). /// /// The JSON node to parse. /// A ReferenceExpressionRef if the node represents an expression, otherwise null. @@ -64,7 +75,21 @@ internal sealed class ReferenceExpressionRef return null; } - // Get the format string (required) + // Check for conditional mode: presence of "condition" property + if (exprObj.TryGetPropertyValue("condition", out var conditionNode)) + { + exprObj.TryGetPropertyValue("whenTrue", out var whenTrueNode); + exprObj.TryGetPropertyValue("whenFalse", out var whenFalseNode); + + return new ReferenceExpressionRef + { + Condition = conditionNode, + WhenTrue = whenTrueNode, + WhenFalse = whenFalseNode + }; + } + + // Value mode: format + optional valueProviders if (!exprObj.TryGetPropertyValue("format", out var formatNode) || formatNode is not JsonValue formatValue || !formatValue.TryGetValue(out var format)) @@ -103,6 +128,7 @@ public static bool IsReferenceExpressionRef(JsonNode? node) /// /// Creates a ReferenceExpression from this reference by resolving handles. + /// Handles both value mode and conditional mode. /// /// The handle registry to resolve handles from. /// The capability ID for error messages. @@ -113,13 +139,62 @@ public ReferenceExpression ToReferenceExpression( HandleRegistry handles, string capabilityId, string paramName) + { + if (IsConditional) + { + return ToConditionalReferenceExpression(handles, capabilityId, paramName); + } + + return ToValueReferenceExpression(handles, capabilityId, paramName); + } + + private ReferenceExpression ToConditionalReferenceExpression( + HandleRegistry handles, + string capabilityId, + string paramName) + { + // Resolve the condition handle to an IValueProvider + var conditionHandleRef = HandleRef.FromJsonNode(Condition) + ?? throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.condition", + "Condition must be a handle reference ({ $handle: \"...\" })"); + + if (!handles.TryGet(conditionHandleRef.HandleId, out var conditionObj, out _)) + { + throw CapabilityException.HandleNotFound(conditionHandleRef.HandleId, capabilityId); + } + + if (conditionObj is not IValueProvider condition) + { + throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.condition", + $"Condition handle must resolve to an IValueProvider, got {conditionObj?.GetType().Name ?? "null"}"); + } + + // Resolve whenTrue as a ReferenceExpression + var whenTrueExprRef = FromJsonNode(WhenTrue) + ?? throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.whenTrue", + "whenTrue must be a reference expression ({ $expr: { ... } })"); + var whenTrue = whenTrueExprRef.ToReferenceExpression(handles, capabilityId, $"{paramName}.whenTrue"); + + // Resolve whenFalse as a ReferenceExpression + var whenFalseExprRef = FromJsonNode(WhenFalse) + ?? throw CapabilityException.InvalidArgument(capabilityId, $"{paramName}.whenFalse", + "whenFalse must be a reference expression ({ $expr: { ... } })"); + var whenFalse = whenFalseExprRef.ToReferenceExpression(handles, capabilityId, $"{paramName}.whenFalse"); + + return ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + } + + private ReferenceExpression ToValueReferenceExpression( + HandleRegistry handles, + string capabilityId, + string paramName) { var builder = new ReferenceExpressionBuilder(); if (ValueProviders == null || ValueProviders.Length == 0) { // No value providers - just a literal string - builder.AppendLiteral(Format); + builder.AppendLiteral(Format!); } else { @@ -152,7 +227,7 @@ public ReferenceExpression ToReferenceExpression( } // Parse the format string and interleave with value providers - var parts = SplitFormatString(Format); + var parts = SplitFormatString(Format!); foreach (var part in parts) { if (part.StartsWith("{") && part.EndsWith("}") && diff --git a/src/Aspire.Hosting/ApplicationModel/ConditionalReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ConditionalReferenceExpression.cs deleted file mode 100644 index 99d6e03d2d7..00000000000 --- a/src/Aspire.Hosting/ApplicationModel/ConditionalReferenceExpression.cs +++ /dev/null @@ -1,176 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Text; - -namespace Aspire.Hosting.ApplicationModel; - -/// -/// Represents a conditional value expression that selects between two branches -/// based on the string value of a condition. -/// -/// -/// -/// This type provides a declarative ternary-style expression that polyglot code generators can translate -/// into native conditional constructs (e.g., condition ? trueVal : falseVal in TypeScript, -/// trueVal if condition else falseVal in Python). -/// -/// -/// At runtime, the condition is evaluated and compared to . If the condition -/// matches, the branch is resolved; otherwise the branch is used. -/// -/// -/// For the publish manifest, the conditional is resolved at publish time and a value.v0 entry is -/// emitted. The is auto-generated from the condition's -/// and the references that entry using the property. -/// -/// -[DebuggerDisplay("ConditionalReferenceExpression = {ValueExpression}")] -[AspireExport(Description = "Represents an expression that evaluates to one of two branches based on the string value of a condition.", ExposeProperties = true)] -public class ConditionalReferenceExpression : IValueProvider, IManifestExpressionProvider, IValueWithReferences -{ - private static readonly ConcurrentDictionary s_nameCounters = new(StringComparer.OrdinalIgnoreCase); - - private readonly IValueProvider _condition; - - /// - /// Initializes a new instance of . - /// - /// A value provider whose result is compared to - /// to determine which branch to evaluate. Typically an with - /// . - /// The expression to evaluate when the condition is . - /// The expression to evaluate when the condition is . - private ConditionalReferenceExpression(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) - { - ArgumentNullException.ThrowIfNull(condition); - ArgumentNullException.ThrowIfNull(whenTrue); - ArgumentNullException.ThrowIfNull(whenFalse); - - _condition = condition; - WhenTrue = whenTrue; - WhenFalse = whenFalse; - Name = GenerateName(condition); - } - - /// - /// Creates a new with the specified condition and branch expressions. - /// - /// A value provider whose result is compared to . - /// The expression to evaluate when the condition is . - /// The expression to evaluate when the condition is . - /// A new . - public static ConditionalReferenceExpression Create(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) - { - return new ConditionalReferenceExpression(condition, whenTrue, whenFalse); - } - - /// - /// Gets the name of this conditional expression, used as the manifest resource name for the value.v0 entry. - /// - public string Name { get; } - - /// - /// Gets the condition value provider whose result is compared to . - /// - public IValueProvider Condition => _condition; - - /// - /// Gets the expression to evaluate when evaluates to . - /// - public ReferenceExpression WhenTrue { get; } - - /// - /// Gets the expression to evaluate when does not evaluate to . - /// - public ReferenceExpression WhenFalse { get; } - - /// - /// Gets the manifest expression that references the value.v0 entry. - /// - public string ValueExpression => $"{{{Name}.value}}"; - - /// - public async ValueTask GetValueAsync(CancellationToken cancellationToken = default) - { - var conditionValue = await _condition.GetValueAsync(cancellationToken).ConfigureAwait(false); - var branch = string.Equals(conditionValue, bool.TrueString, StringComparison.OrdinalIgnoreCase) ? WhenTrue : WhenFalse; - return await branch.GetValueAsync(cancellationToken).ConfigureAwait(false); - } - - /// - public async ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default) - { - var conditionValue = await _condition.GetValueAsync(context, cancellationToken).ConfigureAwait(false); - var branch = string.Equals(conditionValue, bool.TrueString, StringComparison.OrdinalIgnoreCase) ? WhenTrue : WhenFalse; - return await branch.GetValueAsync(context, cancellationToken).ConfigureAwait(false); - } - - /// - public IEnumerable References - { - get - { - if (_condition is IValueWithReferences conditionRefs) - { - foreach (var reference in conditionRefs.References) - { - yield return reference; - } - } - - foreach (var reference in ((IValueWithReferences)WhenTrue).References) - { - yield return reference; - } - - foreach (var reference in ((IValueWithReferences)WhenFalse).References) - { - yield return reference; - } - } - } - - private static string GenerateName(IValueProvider condition) - { - string baseName; - - if (condition is IManifestExpressionProvider expressionProvider) - { - var expression = expressionProvider.ValueExpression; - var sanitized = SanitizeExpression(expression); - baseName = sanitized.Length > 0 ? $"cond-{sanitized}" : "cond-expr"; - } - else - { - baseName = "cond-expr"; - } - - var count = s_nameCounters.AddOrUpdate(baseName, 1, (_, existing) => existing + 1); - return count == 1 ? baseName : $"{baseName}-{count}"; - } - - private static string SanitizeExpression(string expression) - { - var builder = new StringBuilder(expression.Length); - var lastWasSeparator = false; - - foreach (var ch in expression) - { - if (char.IsLetterOrDigit(ch)) - { - builder.Append(char.ToLowerInvariant(ch)); - lastWasSeparator = false; - } - else if (!lastWasSeparator && builder.Length > 0) - { - builder.Append('-'); - lastWasSeparator = true; - } - } - - return builder.ToString().TrimEnd('-'); - } -} diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index d19df781519..d5ec8d02d25 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -127,7 +127,7 @@ public EndpointReferenceExpression Property(EndpointProperty property) } /// - /// Creates a that resolves to when + /// Creates a conditional that resolves to when /// is on this endpoint, or to /// otherwise. /// @@ -139,11 +139,11 @@ public EndpointReferenceExpression Property(EndpointProperty property) /// /// The expression to evaluate when TLS is enabled (e.g., ",ssl=true"). /// The expression to evaluate when TLS is not enabled. - /// A whose value tracks the TLS state of this endpoint. + /// A conditional whose value tracks the TLS state of this endpoint. [AspireExport(Description = "Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise.")] - public ConditionalReferenceExpression GetTlsValue(ReferenceExpression enabledValue, ReferenceExpression disabledValue) + public ReferenceExpression GetTlsValue(ReferenceExpression enabledValue, ReferenceExpression disabledValue) { - return ConditionalReferenceExpression.Create( + return ReferenceExpression.CreateConditional( Property(EndpointProperty.TlsEnabled), enabledValue, disabledValue); diff --git a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs index 249fa2d3f60..db7d86a02f1 100644 --- a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs +++ b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Globalization; +using System.IO.Hashing; using System.Runtime.CompilerServices; using System.Text; using Aspire.Hosting.Utils; @@ -12,6 +13,18 @@ namespace Aspire.Hosting.ApplicationModel; /// Represents an expression that might be made up of multiple resource properties. For example, /// a connection string might be made up of a host, port, and password from different endpoints. /// +/// +/// +/// A operates in one of two modes: +/// +/// +/// Value mode — a format string with interpolated parameters +/// (e.g., "redis://{0}:{1}"). +/// Conditional mode — a ternary-style expression that selects between two branch +/// expressions based on the string value of a . Created via +/// . +/// +/// [AspireExport] [DebuggerDisplay("ReferenceExpression = {ValueExpression}, Providers = {ValueProviders.Count}")] public class ReferenceExpression : IManifestExpressionProvider, IValueProvider, IValueWithReferences @@ -22,10 +35,15 @@ public class ReferenceExpression : IManifestExpressionProvider, IValueProvider, /// Use this field to represent a default or uninitialized reference expression. The instance has /// an empty name and contains no value providers or arguments. public static readonly ReferenceExpression Empty = Create(string.Empty, [], [], []); - private readonly string[] _manifestExpressions; private readonly string?[] _stringFormats; + // Conditional mode fields (null when in value mode) + private readonly IValueProvider? _condition; + private readonly ReferenceExpression? _whenTrue; + private readonly ReferenceExpression? _whenFalse; + private readonly string? _name; + private ReferenceExpression(string format, IValueProvider[] valueProviders, string[] manifestExpressions, string?[] stringFormats) { ArgumentNullException.ThrowIfNull(format); @@ -38,6 +56,24 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri _stringFormats = stringFormats; } + private ReferenceExpression(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) + { + ArgumentNullException.ThrowIfNull(condition); + ArgumentNullException.ThrowIfNull(whenTrue); + ArgumentNullException.ThrowIfNull(whenFalse); + + _condition = condition; + _whenTrue = whenTrue; + _whenFalse = whenFalse; + _name = GenerateConditionalName(condition, whenTrue, whenFalse); + + // Set value-mode fields to safe defaults so callers never see null. + Format = string.Empty; + ValueProviders = []; + _manifestExpressions = []; + _stringFormats = []; + } + /// /// The format string for this expression. /// @@ -58,13 +94,77 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri /// public IReadOnlyList ValueProviders { get; } - IEnumerable IValueWithReferences.References => ValueProviders; + /// + /// Gets a value indicating whether this expression is a conditional expression that selects + /// between two branches based on a condition. + /// + public bool IsConditional => _condition is not null; + + /// + /// Gets the condition value provider whose result is compared to , + /// or when is . + /// + public IValueProvider? Condition => _condition; + + /// + /// Gets the expression to evaluate when evaluates to , + /// or when is . + /// + public ReferenceExpression? WhenTrue => _whenTrue; + + /// + /// Gets the expression to evaluate when does not evaluate to , + /// or when is . + /// + public ReferenceExpression? WhenFalse => _whenFalse; + + /// + /// Gets the name of this conditional expression, used as the manifest resource name for the value.v0 entry, + /// or when is . + /// + internal string? Name => _name; + + IEnumerable IValueWithReferences.References + { + get + { + if (IsConditional) + { + if (_condition is IValueWithReferences conditionRefs) + { + foreach (var reference in conditionRefs.References) + { + yield return reference; + } + } + + foreach (var reference in ((IValueWithReferences)_whenTrue!).References) + { + yield return reference; + } + + foreach (var reference in ((IValueWithReferences)_whenFalse!).References) + { + yield return reference; + } + + yield break; + } + + foreach (var vp in ValueProviders) + { + yield return vp; + } + } + } /// /// The value expression for the format string. /// public string ValueExpression => - string.Format(CultureInfo.InvariantCulture, Format, _manifestExpressions); + IsConditional + ? $"{{{_name}.connectionString}}" + : string.Format(CultureInfo.InvariantCulture, Format, _manifestExpressions); /// /// Gets the value of the expression. The final string value after evaluating the format string and its parameters. @@ -73,6 +173,13 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri /// A . public async ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken) { + if (IsConditional) + { + var conditionValue = await _condition!.GetValueAsync(context, cancellationToken).ConfigureAwait(false); + var branch = string.Equals(conditionValue, bool.TrueString, StringComparison.OrdinalIgnoreCase) ? _whenTrue! : _whenFalse!; + return await branch.GetValueAsync(context, cancellationToken).ConfigureAwait(false); + } + // NOTE: any logical changes to this method should also be made to ExpressionResolver.EvalExpressionAsync if (Format.Length == 0) { @@ -119,6 +226,74 @@ public static ReferenceExpression Create(in ExpressionInterpolatedStringHandler return handler.GetExpression(); } + /// + /// Creates a conditional that selects between two branch expressions + /// based on the string value of a condition. + /// + /// A value provider whose result is compared to + /// to determine which branch to evaluate. + /// The expression to evaluate when the condition is . + /// The expression to evaluate when the condition is . + /// A new conditional . + public static ReferenceExpression CreateConditional(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) + { + return new ReferenceExpression(condition, whenTrue, whenFalse); + } + + private static string GenerateConditionalName(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) + { + string baseName; + + if (condition is IManifestExpressionProvider expressionProvider) + { + var expression = expressionProvider.ValueExpression; + var sanitized = SanitizeExpression(expression); + baseName = sanitized.Length > 0 ? $"cond-{sanitized}" : "cond-expr"; + } + else + { + baseName = "cond-expr"; + } + + var hash = ComputeConditionalHash(condition, whenTrue, whenFalse); + return $"{baseName}-{hash}"; + } + + private static string ComputeConditionalHash(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) + { + var xxHash = new XxHash32(); + + var conditionExpr = condition is IManifestExpressionProvider mep ? mep.ValueExpression : condition.GetType().Name; + xxHash.Append(Encoding.UTF8.GetBytes(conditionExpr)); + xxHash.Append(Encoding.UTF8.GetBytes(whenTrue.ValueExpression)); + xxHash.Append(Encoding.UTF8.GetBytes(whenFalse.ValueExpression)); + + var hashBytes = xxHash.GetCurrentHash(); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + + private static string SanitizeExpression(string expression) + { + var builder = new StringBuilder(expression.Length); + var lastWasSeparator = false; + + foreach (var ch in expression) + { + if (char.IsLetterOrDigit(ch)) + { + builder.Append(char.ToLowerInvariant(ch)); + lastWasSeparator = false; + } + else if (!lastWasSeparator && builder.Length > 0) + { + builder.Append('-'); + lastWasSeparator = true; + } + } + + return builder.ToString().TrimEnd('-'); + } + /// /// Represents a handler for interpolated strings that contain expressions. Those expressions will either be literal strings or /// instances of types that implement both and . diff --git a/src/Aspire.Hosting/Ats/AtsConstants.cs b/src/Aspire.Hosting/Ats/AtsConstants.cs index 7d1412df637..b3cf2b905af 100644 --- a/src/Aspire.Hosting/Ats/AtsConstants.cs +++ b/src/Aspire.Hosting/Ats/AtsConstants.cs @@ -225,11 +225,6 @@ internal static class AtsConstants /// public const string ReferenceExpressionTypeId = "Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression"; - /// - /// Type ID for ConditionalReferenceExpression. - /// - public const string ConditionalReferenceExpressionTypeId = "Aspire.Hosting/Aspire.Hosting.ApplicationModel.ConditionalReferenceExpression"; - #endregion #region Well-known Capability IDs diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index 055dfac774d..501318326ed 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -47,7 +47,7 @@ public sealed class ManifestPublishingContext(DistributedApplicationExecutionCon private readonly Dictionary> _formattedParameters = []; - private readonly Dictionary _conditionalExpressions = new(StringComparers.ResourceName); + private readonly Dictionary _conditionalExpressions = new(StringComparers.ResourceName); private readonly HashSet _manifestResourceNames = new(StringComparers.ResourceName); @@ -749,10 +749,10 @@ private void RegisterConditionalExpressions(ReferenceExpression referenceExpress { foreach (var provider in referenceExpression.ValueProviders) { - if (provider is ConditionalReferenceExpression conditional) + if (provider is ReferenceExpression { IsConditional: true, Name: string name } conditional) { - _conditionalExpressions.TryAdd(conditional.Name, conditional); - _manifestResourceNames.Add(conditional.Name); + _conditionalExpressions.TryAdd(name, conditional); + _manifestResourceNames.Add(name); } } } @@ -860,7 +860,7 @@ private async Task WriteConditionalExpressionsAsync() Writer.WriteStartObject(name); Writer.WriteString("type", "value.v0"); - Writer.WriteString("value", resolvedValue ?? string.Empty); + Writer.WriteString("connectionString", resolvedValue ?? string.Empty); Writer.WriteEndObject(); } diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index 9f42d3780f1..6cfea90b8c9 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -918,8 +918,8 @@ private static X509Certificate2 CreateTestCertificate() private static string AssertContainsConditionalReference(string valueExpression) { - var match = Regex.Match(valueExpression, @"\{(cond-[^.]+)\.value\}"); - Assert.True(match.Success, $"Expected value expression to contain a conditional reference '{{cond-*.value}}', but got: {valueExpression}"); + var match = Regex.Match(valueExpression, @"\{(cond-[^.]+)\.connectionString\}"); + Assert.True(match.Success, $"Expected value expression to contain a conditional reference '{{cond-*.connectionString}}', but got: {valueExpression}"); return match.Groups[1].Value; } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs index f2fa4e18255..cf9f5e7a10d 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs @@ -931,7 +931,7 @@ public void MarshalToJson_MarshalsConditionalReferenceExpressionAsHandle() var condition = new TestConditionValueProvider(bool.TrueString); var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var conditional = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + var conditional = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); var result = marshaller.MarshalToJson(conditional); @@ -950,7 +950,7 @@ public void MarshalToJson_ConditionalReferenceExpression_RoundTripsViaHandle() var condition = new TestConditionValueProvider(bool.TrueString); var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var conditional = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + var conditional = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); var json = marshaller.MarshalToJson(conditional); Assert.NotNull(json); @@ -970,15 +970,15 @@ public async Task MarshalToJson_ConditionalReferenceExpression_PreservesValueAft var condition = new TestConditionValueProvider(bool.TrueString); var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var conditional = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + var conditional = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); var json = marshaller.MarshalToJson(conditional); var handleId = json!["$handle"]!.GetValue(); registry.TryGet(handleId, out var retrieved, out _); - var retrievedConditional = Assert.IsType(retrieved); + var retrievedConditional = Assert.IsType(retrieved); Assert.StartsWith("cond-test-condition", retrievedConditional.Name); - Assert.Equal(",ssl=true", await retrievedConditional.GetValueAsync()); + Assert.Equal(",ssl=true", await retrievedConditional.GetValueAsync(default)); } [Fact] @@ -989,14 +989,14 @@ public async Task MarshalToJson_ConditionalReferenceExpression_FalseConditionRou var condition = new TestConditionValueProvider(bool.FalseString); var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var conditional = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + var conditional = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); var json = marshaller.MarshalToJson(conditional); var handleId = json!["$handle"]!.GetValue(); registry.TryGet(handleId, out var retrieved, out _); - var retrievedConditional = Assert.IsType(retrieved); + var retrievedConditional = Assert.IsType(retrieved); - Assert.Null(await retrievedConditional.GetValueAsync()); + Assert.Null(await retrievedConditional.GetValueAsync(default)); } private sealed class TestConditionValueProvider(string value) : IValueProvider, IManifestExpressionProvider @@ -1011,20 +1011,20 @@ private sealed class TestConditionValueProvider(string value) : IValueProvider, } [Fact] - public void UnmarshalFromJson_UnmarshalsCondExprToConditionalReferenceExpression() + public void UnmarshalFromJson_UnmarshalsCondExprToReferenceExpression() { var registry = new HandleRegistry(); // Register a condition IValueProvider as a handle var condition = new TestConditionValueProvider(bool.TrueString); - var conditionHandleId = registry.Register(condition, AtsConstants.ConditionalReferenceExpressionTypeId); + var conditionHandleId = registry.Register(condition, AtsConstants.ReferenceExpressionTypeId); var (marshaller, context) = CreateMarshallerWithContext(registry); - // Build the $condExpr JSON that a client would send + // Build the unified $expr JSON with conditional mode var json = new JsonObject { - ["$condExpr"] = new JsonObject + ["$expr"] = new JsonObject { ["condition"] = new JsonObject { @@ -1047,8 +1047,8 @@ public void UnmarshalFromJson_UnmarshalsCondExprToConditionalReferenceExpression } }; - var result = marshaller.UnmarshalFromJson(json, typeof(ConditionalReferenceExpression), context); - var cre = Assert.IsType(result); + var result = marshaller.UnmarshalFromJson(json, typeof(ReferenceExpression), context); + var cre = Assert.IsType(result); Assert.NotNull(cre); } @@ -1059,13 +1059,13 @@ public async Task UnmarshalFromJson_CondExpr_TrueConditionReturnsWhenTrueValue() var registry = new HandleRegistry(); var condition = new TestConditionValueProvider(bool.TrueString); - var conditionHandleId = registry.Register(condition, AtsConstants.ConditionalReferenceExpressionTypeId); + var conditionHandleId = registry.Register(condition, AtsConstants.ReferenceExpressionTypeId); var (marshaller, context) = CreateMarshallerWithContext(registry); var json = new JsonObject { - ["$condExpr"] = new JsonObject + ["$expr"] = new JsonObject { ["condition"] = new JsonObject { @@ -1088,9 +1088,9 @@ public async Task UnmarshalFromJson_CondExpr_TrueConditionReturnsWhenTrueValue() } }; - var result = marshaller.UnmarshalFromJson(json, typeof(ConditionalReferenceExpression), context); - var cre = Assert.IsType(result); - var value = await cre.GetValueAsync(); + var result = marshaller.UnmarshalFromJson(json, typeof(ReferenceExpression), context); + var cre = Assert.IsType(result); + var value = await cre.GetValueAsync(default); Assert.Equal(",ssl=true", value); } @@ -1101,13 +1101,13 @@ public async Task UnmarshalFromJson_CondExpr_FalseConditionReturnsWhenFalseValue var registry = new HandleRegistry(); var condition = new TestConditionValueProvider(bool.FalseString); - var conditionHandleId = registry.Register(condition, AtsConstants.ConditionalReferenceExpressionTypeId); + var conditionHandleId = registry.Register(condition, AtsConstants.ReferenceExpressionTypeId); var (marshaller, context) = CreateMarshallerWithContext(registry); var json = new JsonObject { - ["$condExpr"] = new JsonObject + ["$expr"] = new JsonObject { ["condition"] = new JsonObject { @@ -1130,9 +1130,9 @@ public async Task UnmarshalFromJson_CondExpr_FalseConditionReturnsWhenFalseValue } }; - var result = marshaller.UnmarshalFromJson(json, typeof(ConditionalReferenceExpression), context); - var cre = Assert.IsType(result); - var value = await cre.GetValueAsync(); + var result = marshaller.UnmarshalFromJson(json, typeof(ReferenceExpression), context); + var cre = Assert.IsType(result); + var value = await cre.GetValueAsync(default); // An empty format string with no value providers results in null from ReferenceExpression.GetValueAsync Assert.Null(value); @@ -1146,7 +1146,7 @@ public void UnmarshalFromJson_CondExpr_ThrowsWhenConditionHandleMissing() var json = new JsonObject { - ["$condExpr"] = new JsonObject + ["$expr"] = new JsonObject { ["condition"] = new JsonObject { @@ -1170,6 +1170,6 @@ public void UnmarshalFromJson_CondExpr_ThrowsWhenConditionHandleMissing() }; Assert.Throws(() => - marshaller.UnmarshalFromJson(json, typeof(ConditionalReferenceExpression), context)); + marshaller.UnmarshalFromJson(json, typeof(ReferenceExpression), context)); } } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs index c705825d48a..b010884ef8c 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs @@ -350,9 +350,9 @@ public void Register_ConditionalReferenceExpression_CanBeRetrievedByTypeId() { var registry = new HandleRegistry(); var condition = new TestConditionProvider(bool.TrueString); - var conditional = ConditionalReferenceExpression.Create( + var conditional = ReferenceExpression.CreateConditional( condition, ReferenceExpression.Create($",ssl=true"), ReferenceExpression.Empty); - var typeId = AtsConstants.ConditionalReferenceExpressionTypeId; + var typeId = AtsConstants.ReferenceExpressionTypeId; var handleId = registry.Register(conditional, typeId); var found = registry.TryGet(handleId, out var retrieved, out var retrievedTypeId); diff --git a/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs b/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs index 2e6f5d6e91a..bd601d21796 100644 --- a/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs +++ b/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs @@ -14,9 +14,9 @@ public async Task GetValueAsync_ReturnsTrueValue_WhenConditionIsTrue() var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var expr = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + var expr = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); - var value = await expr.GetValueAsync(); + var value = await expr.GetValueAsync(default); Assert.Equal(",ssl=true", value); } @@ -27,9 +27,9 @@ public async Task GetValueAsync_ReturnsFalseValue_WhenConditionIsFalse() var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var expr = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + var expr = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); - var value = await expr.GetValueAsync(); + var value = await expr.GetValueAsync(default); Assert.Null(value); } @@ -40,10 +40,10 @@ public void ValueExpression_ReturnsManifestParameterReference() var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var expr = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + var expr = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); Assert.StartsWith("{cond-", expr.ValueExpression); - Assert.EndsWith(".value}", expr.ValueExpression); + Assert.EndsWith(".connectionString}", expr.ValueExpression); } [Fact] @@ -53,10 +53,9 @@ public async Task GetValueAsync_WithContext_PassesContextToBranch() var whenTrue = ReferenceExpression.Create($"true-value"); var whenFalse = ReferenceExpression.Create($"false-value"); - var expr = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + var expr = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); - var context = new ValueProviderContext(); - var value = await expr.GetValueAsync(context); + var value = await expr.GetValueAsync(new(), default); Assert.Equal("true-value", value); } @@ -67,7 +66,7 @@ public async Task ConditionalReferenceExpression_WorksInReferenceExpressionBuild var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var conditional = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + var conditional = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); var builder = new ReferenceExpressionBuilder(); builder.AppendLiteral("localhost:6379"); @@ -96,9 +95,9 @@ public void References_IncludesConditionAndBranchReferences() var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var expr = ConditionalReferenceExpression.Create(condition, whenTrue, whenFalse); + var expr = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); - var references = expr.References.ToList(); + var references = ((IValueWithReferences)expr).References.ToList(); Assert.Contains(endpointRef, references); } @@ -106,27 +105,27 @@ public void References_IncludesConditionAndBranchReferences() public void Constructor_ThrowsOnNullCondition() { Assert.Throws(() => - ConditionalReferenceExpression.Create(null!, ReferenceExpression.Empty, ReferenceExpression.Empty)); + ReferenceExpression.CreateConditional(null!, ReferenceExpression.Empty, ReferenceExpression.Empty)); } [Fact] public void Constructor_ThrowsOnNullWhenTrue() { Assert.Throws(() => - ConditionalReferenceExpression.Create(new TestValueProvider(bool.TrueString), null!, ReferenceExpression.Empty)); + ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), null!, ReferenceExpression.Empty)); } [Fact] public void Constructor_ThrowsOnNullWhenFalse() { Assert.Throws(() => - ConditionalReferenceExpression.Create(new TestValueProvider(bool.TrueString), ReferenceExpression.Empty, null!)); + ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), ReferenceExpression.Empty, null!)); } [Fact] public void Name_IsAutoGeneratedFromCondition() { - var expr = ConditionalReferenceExpression.Create(new TestValueProvider(bool.TrueString), ReferenceExpression.Empty, ReferenceExpression.Empty); + var expr = ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), ReferenceExpression.Empty, ReferenceExpression.Empty); Assert.StartsWith("cond-", expr.Name); } diff --git a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs index 1ff994232b2..3be925970e5 100644 --- a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs +++ b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs @@ -230,7 +230,8 @@ public void PublishingRedisResourceAsContainerResultsInConnectionStringProperty( var container = resources.GetProperty("rediscontainer"); Assert.Equal("container.v0", container.GetProperty("type").GetString()); - Assert.Equal("{rediscontainer.bindings.tcp.host}:{rediscontainer.bindings.tcp.port},password={rediscontainer-password.value}", container.GetProperty("connectionString").GetString()); + var connectionString = container.GetProperty("connectionString").GetString(); + Assert.Equal("{rediscontainer.bindings.tcp.host}:{rediscontainer.bindings.tcp.port},password={rediscontainer-password.value}{cond-rediscontainer-bindings-tcp-tlsenabled-91f82fca.connectionString}", connectionString); } [Fact] @@ -406,7 +407,7 @@ public void VerifyTestProgramFullManifest() }, "redis": { "type": "container.v0", - "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={redis-password.value}", + "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={redis-password.value}{cond-redis-bindings-tcp-tlsenabled-7a0eaf42.connectionString}", "image": "{{ComponentTestConstants.AspireTestContainerRegistry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}", "entrypoint": "/bin/sh", "args": [ @@ -489,6 +490,10 @@ public void VerifyTestProgramFullManifest() "type": "annotated.string", "value": "{postgres-password.value}", "filter": "uri" + }, + "cond-redis-bindings-tcp-tlsenabled-7a0eaf42": { + "type": "value.v0", + "connectionString": "" } } } From b43361682da053da1c8e88531e72ab164e0d8e12 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 10:56:17 -0800 Subject: [PATCH 14/45] Ensure snapshots get updated --- .../TwoPassScanningGeneratedAspire.verified.go | 4 ++-- .../TwoPassScanningGeneratedAspire.verified.java | 4 ++-- .../Snapshots/AtsGeneratedAspire.verified.py | 2 +- .../TwoPassScanningGeneratedAspire.verified.py | 4 ++-- .../Snapshots/AtsGeneratedAspire.verified.rs | 2 +- .../TwoPassScanningGeneratedAspire.verified.rs | 4 ++-- .../Snapshots/AtsGeneratedAspire.verified.ts | 3 +-- .../TwoPassScanningGeneratedAspire.verified.ts | 12 ++++-------- 8 files changed, 15 insertions(+), 20 deletions(-) diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 62759e5a142..04caa7c6960 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -1296,7 +1296,7 @@ func (s *EndpointReference) GetValueAsync(cancellationToken *CancellationToken) } // GetTlsValue gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. -func (s *EndpointReference) GetTlsValue(enabledValue *ReferenceExpression, disabledValue *ReferenceExpression) (*ConditionalReferenceExpression, error) { +func (s *EndpointReference) GetTlsValue(enabledValue *ReferenceExpression, disabledValue *ReferenceExpression) (*ReferenceExpression, error) { reqArgs := map[string]any{ "context": SerializeValue(s.Handle()), } @@ -1306,7 +1306,7 @@ func (s *EndpointReference) GetTlsValue(enabledValue *ReferenceExpression, disab if err != nil { return nil, err } - return result.(*ConditionalReferenceExpression), nil + return result.(*ReferenceExpression), nil } // EndpointReferenceExpression wraps a handle for Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression. diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index fde71ff8a6d..c06aa65cb6d 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -1177,12 +1177,12 @@ public String getValueAsync(CancellationToken cancellationToken) { } /** Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. */ - public ConditionalReferenceExpression getTlsValue(ReferenceExpression enabledValue, ReferenceExpression disabledValue) { + public ReferenceExpression getTlsValue(ReferenceExpression enabledValue, ReferenceExpression disabledValue) { Map reqArgs = new HashMap<>(); reqArgs.put("context", AspireClient.serializeValue(getHandle())); reqArgs.put("enabledValue", AspireClient.serializeValue(enabledValue)); reqArgs.put("disabledValue", AspireClient.serializeValue(disabledValue)); - return (ConditionalReferenceExpression) getClient().invokeCapability("Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue", reqArgs); + return (ReferenceExpression) getClient().invokeCapability("Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue", reqArgs); } } diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py index 40b73861611..6ead5536299 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List from transport import AspireClient, Handle, CapabilityError, register_callback, register_handle_wrapper, register_cancellation -from base import AspireDict, AspireList, ReferenceExpression, ConditionalReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value +from base import AspireDict, AspireList, ReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value # ============================================================================ # Enums diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 52444b495a9..6855a178610 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List from transport import AspireClient, Handle, CapabilityError, register_callback, register_handle_wrapper, register_cancellation -from base import AspireDict, AspireList, ReferenceExpression, ConditionalReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value +from base import AspireDict, AspireList, ReferenceExpression, ref_expr, HandleWrapperBase, ResourceBuilderBase, serialize_value # ============================================================================ # Enums @@ -749,7 +749,7 @@ def get_value_async(self, cancellation_token: CancellationToken | None = None) - args["cancellationToken"] = cancellation_token_id return self._client.invoke_capability("Aspire.Hosting.ApplicationModel/getValueAsync", args) - def get_tls_value(self, enabled_value: ReferenceExpression, disabled_value: ReferenceExpression) -> ConditionalReferenceExpression: + def get_tls_value(self, enabled_value: ReferenceExpression, disabled_value: ReferenceExpression) -> ReferenceExpression: """Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise.""" args: Dict[str, Any] = { "context": serialize_value(self._handle) } args["enabledValue"] = serialize_value(enabled_value) diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs index 28bb02d8793..9fc7729a4dd 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs @@ -12,7 +12,7 @@ use crate::transport::{ register_callback, register_cancellation, serialize_value, }; use crate::base::{ - HandleWrapperBase, ResourceBuilderBase, ReferenceExpression, ConditionalReferenceExpression, + HandleWrapperBase, ResourceBuilderBase, ReferenceExpression, AspireList, AspireDict, serialize_handle, HasHandle, }; diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 79d9ec6d106..71786bc5c05 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -12,7 +12,7 @@ use crate::transport::{ register_callback, register_cancellation, serialize_value, }; use crate::base::{ - HandleWrapperBase, ResourceBuilderBase, ReferenceExpression, ConditionalReferenceExpression, + HandleWrapperBase, ResourceBuilderBase, ReferenceExpression, AspireList, AspireDict, serialize_handle, HasHandle, }; @@ -1460,7 +1460,7 @@ impl EndpointReference { } /// Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. - pub fn get_tls_value(&self, enabled_value: ReferenceExpression, disabled_value: ReferenceExpression) -> Result> { + pub fn get_tls_value(&self, enabled_value: ReferenceExpression, disabled_value: ReferenceExpression) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("context".to_string(), self.handle.to_json()); args.insert("enabledValue".to_string(), serde_json::to_value(&enabled_value).unwrap_or(Value::Null)); diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts index 84dfc4b9f51..036593c7b91 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts @@ -17,7 +17,6 @@ import { import { ResourceBuilderBase, ReferenceExpression, - ConditionalReferenceExpression, refExpr, AspireDict, AspireList @@ -2346,7 +2345,7 @@ export async function createBuilder(options?: CreateBuilderOptions): Promise; -/** Handle to ConditionalReferenceExpression */ -type ConditionalReferenceExpressionHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ConditionalReferenceExpression'>; - /** Handle to ContainerResource */ type ContainerResourceHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource'>; @@ -761,9 +757,9 @@ export class EndpointReference { } /** Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. */ - async getTlsValue(enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { + async getTlsValue(enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { const rpcArgs: Record = { context: this._handle, enabledValue, disabledValue }; - return await this._client.invokeCapability( + return await this._client.invokeCapability( 'Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue', rpcArgs ); @@ -790,7 +786,7 @@ export class EndpointReferencePromise implements PromiseLike } /** Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. */ - getTlsValue(enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { + getTlsValue(enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { return this._promise.then(obj => obj.getTlsValue(enabledValue, disabledValue)); } @@ -10532,7 +10528,7 @@ export async function createBuilder(options?: CreateBuilderOptions): Promise Date: Sat, 7 Mar 2026 11:49:13 -0800 Subject: [PATCH 15/45] Add explicit matchValue parameter to CreateConditional - Add matchValue parameter to ReferenceExpression.CreateConditional and constructor, positioned after condition (before whenTrue/whenFalse) - Include matchValue in XxHash32 computation for deterministic naming - Update EndpointReference.GetTlsValue to pass bool.TrueString explicitly - Update ReferenceExpressionRef to deserialize matchValue from JSON-RPC - Update all 5 polyglot base files (TS, Go, Python, Java, Rust) with matchValue field, constructor param, and JSON serialization - Update test assertions with new hash values (d148d83a, c16dc063) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resources/base.go | 20 ++++++---- .../Resources/Base.java | 11 +++-- .../Resources/base.py | 6 ++- .../Resources/base.rs | 9 ++++- .../Resources/base.ts | 24 ++++++----- .../Ats/ReferenceExpressionRef.cs | 14 ++++++- .../ApplicationModel/EndpointReference.cs | 1 + .../ApplicationModel/ReferenceExpression.cs | 40 ++++++++++++------- .../ConditionalReferenceExpressionTests.cs | 20 +++++----- .../ManifestGenerationTests.cs | 6 +-- 10 files changed, 100 insertions(+), 51 deletions(-) diff --git a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go index 896530e62f1..a82e604bcb7 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go +++ b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go @@ -45,9 +45,10 @@ type ReferenceExpression struct { Args []any // Conditional mode fields - Condition any - WhenTrue *ReferenceExpression - WhenFalse *ReferenceExpression + Condition any + WhenTrue *ReferenceExpression + WhenFalse *ReferenceExpression + MatchValue string // Handle mode fields (when wrapping a server-returned handle) handle *Handle @@ -66,11 +67,15 @@ func NewReferenceExpressionFromHandle(handle *Handle, client *AspireClient) *Ref } // CreateConditionalReferenceExpression creates a conditional reference expression from its parts. -func CreateConditionalReferenceExpression(condition any, whenTrue *ReferenceExpression, whenFalse *ReferenceExpression) *ReferenceExpression { +func CreateConditionalReferenceExpression(condition any, whenTrue *ReferenceExpression, whenFalse *ReferenceExpression, matchValue string) *ReferenceExpression { + if matchValue == "" { + matchValue = "True" + } return &ReferenceExpression{ Condition: condition, WhenTrue: whenTrue, WhenFalse: whenFalse, + MatchValue: matchValue, isConditional: true, } } @@ -88,9 +93,10 @@ func (r *ReferenceExpression) ToJSON() map[string]any { if r.isConditional { return map[string]any{ "$refExpr": map[string]any{ - "condition": SerializeValue(r.Condition), - "whenTrue": r.WhenTrue.ToJSON(), - "whenFalse": r.WhenFalse.ToJSON(), + "condition": SerializeValue(r.Condition), + "whenTrue": r.WhenTrue.ToJSON(), + "whenFalse": r.WhenFalse.ToJSON(), + "matchValue": r.MatchValue, }, } } diff --git a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java index 3cbeac0f345..a1ea90ef78d 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java +++ b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java @@ -49,6 +49,7 @@ class ReferenceExpression { private final Object condition; private final ReferenceExpression whenTrue; private final ReferenceExpression whenFalse; + private final String matchValue; private final boolean isConditional; // Handle mode fields @@ -62,6 +63,7 @@ class ReferenceExpression { this.condition = null; this.whenTrue = null; this.whenFalse = null; + this.matchValue = null; this.isConditional = false; this.handle = null; this.client = null; @@ -76,14 +78,16 @@ class ReferenceExpression { this.condition = null; this.whenTrue = null; this.whenFalse = null; + this.matchValue = null; this.isConditional = false; } // Conditional mode constructor - private ReferenceExpression(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { + private ReferenceExpression(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse, String matchValue) { this.condition = condition; this.whenTrue = whenTrue; this.whenFalse = whenFalse; + this.matchValue = matchValue != null ? matchValue : "True"; this.isConditional = true; this.format = null; this.args = null; @@ -112,6 +116,7 @@ Map toJson() { condPayload.put("condition", AspireClient.serializeValue(condition)); condPayload.put("whenTrue", whenTrue.toJson()); condPayload.put("whenFalse", whenFalse.toJson()); + condPayload.put("matchValue", matchValue); var result = new java.util.HashMap(); result.put("$refExpr", condPayload); @@ -137,8 +142,8 @@ static ReferenceExpression refExpr(String format, Object... args) { /** * Creates a conditional reference expression from its parts. */ - static ReferenceExpression createConditional(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { - return new ReferenceExpression(condition, whenTrue, whenFalse); + static ReferenceExpression createConditional(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse, String matchValue) { + return new ReferenceExpression(condition, whenTrue, whenFalse, matchValue); } } diff --git a/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py b/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py index 8cdcab18a9a..af89201e7da 100644 --- a/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py +++ b/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py @@ -19,6 +19,7 @@ def __init__(self, format_string: str, value_providers: List[Any]) -> None: self._condition: Any = None self._when_true: ReferenceExpression | None = None self._when_false: ReferenceExpression | None = None + self._match_value: str | None = None self._is_conditional = False @staticmethod @@ -27,7 +28,7 @@ def create(format_string: str, *values: Any) -> "ReferenceExpression": return ReferenceExpression(format_string, value_providers) @staticmethod - def create_conditional(condition: Any, when_true: "ReferenceExpression", when_false: "ReferenceExpression") -> "ReferenceExpression": + def create_conditional(condition: Any, when_true: "ReferenceExpression", when_false: "ReferenceExpression", match_value: str = "True") -> "ReferenceExpression": """Creates a conditional reference expression from its parts.""" expr = ReferenceExpression.__new__(ReferenceExpression) expr._format_string = "" @@ -37,6 +38,7 @@ def create_conditional(condition: Any, when_true: "ReferenceExpression", when_fa expr._condition = condition expr._when_true = when_true expr._when_false = when_false + expr._match_value = match_value expr._is_conditional = True return expr @@ -51,6 +53,7 @@ def _from_handle(handle: "Handle", client: "AspireClient") -> "ReferenceExpressi expr._condition = None expr._when_true = None expr._when_false = None + expr._match_value = None expr._is_conditional = False return expr @@ -63,6 +66,7 @@ def to_json(self) -> Dict[str, Any]: "condition": serialize_value(self._condition), "whenTrue": self._when_true.to_json(), "whenFalse": self._when_false.to_json(), + "matchValue": self._match_value, } } payload: Dict[str, Any] = {"format": self._format_string} diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs index 9fff49d6e38..89fa9a0ad12 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs @@ -61,6 +61,7 @@ pub struct ReferenceExpression { condition: Option, when_true: Option>, when_false: Option>, + match_value: Option, is_conditional: bool, // Handle mode fields @@ -77,6 +78,7 @@ impl ReferenceExpression { condition: None, when_true: None, when_false: None, + match_value: None, is_conditional: false, handle: None, client: None, @@ -91,6 +93,7 @@ impl ReferenceExpression { condition: None, when_true: None, when_false: None, + match_value: None, is_conditional: false, handle: Some(handle), client: Some(client), @@ -98,13 +101,14 @@ impl ReferenceExpression { } /// Creates a conditional reference expression from its parts. - pub fn create_conditional(condition: Value, when_true: ReferenceExpression, when_false: ReferenceExpression) -> Self { + pub fn create_conditional(condition: Value, when_true: ReferenceExpression, when_false: ReferenceExpression, match_value: Option) -> Self { Self { format: None, args: None, condition: Some(condition), when_true: Some(Box::new(when_true)), when_false: Some(Box::new(when_false)), + match_value: Some(match_value.unwrap_or_else(|| "True".to_string())), is_conditional: true, handle: None, client: None, @@ -128,7 +132,8 @@ impl ReferenceExpression { "$refExpr": { "condition": serialize_value(self.condition.clone().unwrap()), "whenTrue": self.when_true.as_ref().unwrap().to_json(), - "whenFalse": self.when_false.as_ref().unwrap().to_json() + "whenFalse": self.when_false.as_ref().unwrap().to_json(), + "matchValue": self.match_value.as_ref().unwrap() } }); } diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts index 964b114b7e1..405c9e0b436 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts @@ -47,6 +47,7 @@ export class ReferenceExpression { private readonly _condition?: unknown; private readonly _whenTrue?: ReferenceExpression; private readonly _whenFalse?: ReferenceExpression; + private readonly _matchValue?: string; // Handle mode fields (when wrapping a server-returned handle) private readonly _handle?: Handle; @@ -54,11 +55,12 @@ export class ReferenceExpression { constructor(format: string, valueProviders: unknown[]); constructor(handle: Handle, client: AspireClient); - constructor(condition: unknown, whenTrue: ReferenceExpression, whenFalse: ReferenceExpression); + constructor(condition: unknown, whenTrue: ReferenceExpression, whenFalse: ReferenceExpression, matchValue?: string); constructor( handleOrFormatOrCondition: Handle | string | unknown, clientOrValueProvidersOrWhenTrue: AspireClient | unknown[] | ReferenceExpression, - whenFalse?: ReferenceExpression + whenFalse?: ReferenceExpression, + matchValue?: string ) { if (typeof handleOrFormatOrCondition === 'string') { this._format = handleOrFormatOrCondition; @@ -70,6 +72,7 @@ export class ReferenceExpression { this._condition = handleOrFormatOrCondition; this._whenTrue = clientOrValueProvidersOrWhenTrue as ReferenceExpression; this._whenFalse = whenFalse; + this._matchValue = matchValue ?? 'True'; } } @@ -106,17 +109,19 @@ export class ReferenceExpression { /** * Creates a conditional reference expression from its constituent parts. * - * @param condition - A value provider whose result is compared to "True" - * @param whenTrue - The expression to use when the condition is true - * @param whenFalse - The expression to use when the condition is false + * @param condition - A value provider whose result is compared to matchValue + * @param whenTrue - The expression to use when the condition matches + * @param whenFalse - The expression to use when the condition does not match + * @param matchValue - The value to compare the condition against (defaults to "True") * @returns A ReferenceExpression instance in conditional mode */ static createConditional( condition: unknown, whenTrue: ReferenceExpression, - whenFalse: ReferenceExpression + whenFalse: ReferenceExpression, + matchValue?: string ): ReferenceExpression { - return new ReferenceExpression(condition, whenTrue, whenFalse); + return new ReferenceExpression(condition, whenTrue, whenFalse, matchValue); } /** @@ -125,7 +130,7 @@ export class ReferenceExpression { * In conditional mode, uses the $expr format with condition + whenTrue + whenFalse. * In handle mode, delegates to the handle's serialization. */ - toJSON(): { $expr: { format: string; valueProviders?: unknown[] } | { condition: unknown; whenTrue: unknown; whenFalse: unknown } } | MarshalledHandle { + toJSON(): { $expr: { format: string; valueProviders?: unknown[] } | { condition: unknown; whenTrue: unknown; whenFalse: unknown; matchValue: string } } | MarshalledHandle { if (this._handle) { return this._handle.toJSON(); } @@ -135,7 +140,8 @@ export class ReferenceExpression { $expr: { condition: wrapIfHandle(this._condition), whenTrue: this._whenTrue!.toJSON(), - whenFalse: this._whenFalse!.toJSON() + whenFalse: this._whenFalse!.toJSON(), + matchValue: this._matchValue! } }; } diff --git a/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs b/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs index cf201745bf7..9a41daecc2f 100644 --- a/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs +++ b/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs @@ -51,6 +51,7 @@ internal sealed class ReferenceExpressionRef public JsonNode? Condition { get; init; } public JsonNode? WhenTrue { get; init; } public JsonNode? WhenFalse { get; init; } + public string? MatchValue { get; init; } /// /// Gets a value indicating whether this reference represents a conditional expression. @@ -81,11 +82,20 @@ internal sealed class ReferenceExpressionRef exprObj.TryGetPropertyValue("whenTrue", out var whenTrueNode); exprObj.TryGetPropertyValue("whenFalse", out var whenFalseNode); + string? matchValue = null; + if (exprObj.TryGetPropertyValue("matchValue", out var matchValueNode) && + matchValueNode is JsonValue matchValueJsonValue && + matchValueJsonValue.TryGetValue(out var mv)) + { + matchValue = mv; + } + return new ReferenceExpressionRef { Condition = conditionNode, WhenTrue = whenTrueNode, - WhenFalse = whenFalseNode + WhenFalse = whenFalseNode, + MatchValue = matchValue }; } @@ -181,7 +191,7 @@ private ReferenceExpression ToConditionalReferenceExpression( "whenFalse must be a reference expression ({ $expr: { ... } })"); var whenFalse = whenFalseExprRef.ToReferenceExpression(handles, capabilityId, $"{paramName}.whenFalse"); - return ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + return ReferenceExpression.CreateConditional(condition, MatchValue ?? bool.TrueString, whenTrue, whenFalse); } private ReferenceExpression ToValueReferenceExpression( diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index d5ec8d02d25..cca32a3378a 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -145,6 +145,7 @@ public ReferenceExpression GetTlsValue(ReferenceExpression enabledValue, Referen { return ReferenceExpression.CreateConditional( Property(EndpointProperty.TlsEnabled), + bool.TrueString, enabledValue, disabledValue); } diff --git a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs index db7d86a02f1..b6adab33f9b 100644 --- a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs +++ b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs @@ -42,6 +42,7 @@ public class ReferenceExpression : IManifestExpressionProvider, IValueProvider, private readonly IValueProvider? _condition; private readonly ReferenceExpression? _whenTrue; private readonly ReferenceExpression? _whenFalse; + private readonly string? _matchValue; private readonly string? _name; private ReferenceExpression(string format, IValueProvider[] valueProviders, string[] manifestExpressions, string?[] stringFormats) @@ -56,16 +57,18 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri _stringFormats = stringFormats; } - private ReferenceExpression(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) + private ReferenceExpression(IValueProvider condition, string matchValue, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { ArgumentNullException.ThrowIfNull(condition); ArgumentNullException.ThrowIfNull(whenTrue); ArgumentNullException.ThrowIfNull(whenFalse); + ArgumentException.ThrowIfNullOrEmpty(matchValue); _condition = condition; _whenTrue = whenTrue; _whenFalse = whenFalse; - _name = GenerateConditionalName(condition, whenTrue, whenFalse); + _matchValue = matchValue; + _name = GenerateConditionalName(condition, matchValue, whenTrue, whenFalse); // Set value-mode fields to safe defaults so callers never see null. Format = string.Empty; @@ -101,23 +104,29 @@ private ReferenceExpression(IValueProvider condition, ReferenceExpression whenTr public bool IsConditional => _condition is not null; /// - /// Gets the condition value provider whose result is compared to , + /// Gets the condition value provider whose result is compared to , /// or when is . /// public IValueProvider? Condition => _condition; /// - /// Gets the expression to evaluate when evaluates to , + /// Gets the expression to evaluate when evaluates to , /// or when is . /// public ReferenceExpression? WhenTrue => _whenTrue; /// - /// Gets the expression to evaluate when does not evaluate to , + /// Gets the expression to evaluate when does not evaluate to , /// or when is . /// public ReferenceExpression? WhenFalse => _whenFalse; + /// + /// Gets the value that is compared against to select the branch, + /// or when is . + /// + public string? MatchValue => _matchValue; + /// /// Gets the name of this conditional expression, used as the manifest resource name for the value.v0 entry, /// or when is . @@ -176,7 +185,7 @@ IEnumerable IValueWithReferences.References if (IsConditional) { var conditionValue = await _condition!.GetValueAsync(context, cancellationToken).ConfigureAwait(false); - var branch = string.Equals(conditionValue, bool.TrueString, StringComparison.OrdinalIgnoreCase) ? _whenTrue! : _whenFalse!; + var branch = string.Equals(conditionValue, _matchValue, StringComparison.OrdinalIgnoreCase) ? _whenTrue! : _whenFalse!; return await branch.GetValueAsync(context, cancellationToken).ConfigureAwait(false); } @@ -230,17 +239,19 @@ public static ReferenceExpression Create(in ExpressionInterpolatedStringHandler /// Creates a conditional that selects between two branch expressions /// based on the string value of a condition. /// - /// A value provider whose result is compared to + /// A value provider whose result is compared to /// to determine which branch to evaluate. - /// The expression to evaluate when the condition is . - /// The expression to evaluate when the condition is . + /// The string value that is compared against. + /// When the condition's value equals this (case-insensitive), the branch is selected. + /// The expression to evaluate when the condition matches . + /// The expression to evaluate when the condition does not match . /// A new conditional . - public static ReferenceExpression CreateConditional(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) + public static ReferenceExpression CreateConditional(IValueProvider condition, string matchValue, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { - return new ReferenceExpression(condition, whenTrue, whenFalse); + return new ReferenceExpression(condition, matchValue, whenTrue, whenFalse); } - private static string GenerateConditionalName(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) + private static string GenerateConditionalName(IValueProvider condition, string matchValue, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { string baseName; @@ -255,11 +266,11 @@ private static string GenerateConditionalName(IValueProvider condition, Referenc baseName = "cond-expr"; } - var hash = ComputeConditionalHash(condition, whenTrue, whenFalse); + var hash = ComputeConditionalHash(condition, whenTrue, whenFalse, matchValue); return $"{baseName}-{hash}"; } - private static string ComputeConditionalHash(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse) + private static string ComputeConditionalHash(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse, string matchValue) { var xxHash = new XxHash32(); @@ -267,6 +278,7 @@ private static string ComputeConditionalHash(IValueProvider condition, Reference xxHash.Append(Encoding.UTF8.GetBytes(conditionExpr)); xxHash.Append(Encoding.UTF8.GetBytes(whenTrue.ValueExpression)); xxHash.Append(Encoding.UTF8.GetBytes(whenFalse.ValueExpression)); + xxHash.Append(Encoding.UTF8.GetBytes(matchValue)); var hashBytes = xxHash.GetCurrentHash(); return Convert.ToHexString(hashBytes).ToLowerInvariant(); diff --git a/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs b/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs index bd601d21796..7a2a2f34ae3 100644 --- a/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs +++ b/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs @@ -14,7 +14,7 @@ public async Task GetValueAsync_ReturnsTrueValue_WhenConditionIsTrue() var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var expr = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + var expr = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); var value = await expr.GetValueAsync(default); Assert.Equal(",ssl=true", value); @@ -27,7 +27,7 @@ public async Task GetValueAsync_ReturnsFalseValue_WhenConditionIsFalse() var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var expr = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + var expr = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); var value = await expr.GetValueAsync(default); Assert.Null(value); @@ -40,7 +40,7 @@ public void ValueExpression_ReturnsManifestParameterReference() var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var expr = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + var expr = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); Assert.StartsWith("{cond-", expr.ValueExpression); Assert.EndsWith(".connectionString}", expr.ValueExpression); @@ -53,7 +53,7 @@ public async Task GetValueAsync_WithContext_PassesContextToBranch() var whenTrue = ReferenceExpression.Create($"true-value"); var whenFalse = ReferenceExpression.Create($"false-value"); - var expr = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + var expr = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); var value = await expr.GetValueAsync(new(), default); Assert.Equal("true-value", value); @@ -66,7 +66,7 @@ public async Task ConditionalReferenceExpression_WorksInReferenceExpressionBuild var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var conditional = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + var conditional = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); var builder = new ReferenceExpressionBuilder(); builder.AppendLiteral("localhost:6379"); @@ -95,7 +95,7 @@ public void References_IncludesConditionAndBranchReferences() var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var expr = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + var expr = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); var references = ((IValueWithReferences)expr).References.ToList(); Assert.Contains(endpointRef, references); @@ -105,27 +105,27 @@ public void References_IncludesConditionAndBranchReferences() public void Constructor_ThrowsOnNullCondition() { Assert.Throws(() => - ReferenceExpression.CreateConditional(null!, ReferenceExpression.Empty, ReferenceExpression.Empty)); + ReferenceExpression.CreateConditional(null!, bool.TrueString, ReferenceExpression.Empty, ReferenceExpression.Empty)); } [Fact] public void Constructor_ThrowsOnNullWhenTrue() { Assert.Throws(() => - ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), null!, ReferenceExpression.Empty)); + ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), bool.TrueString, null!, ReferenceExpression.Empty)); } [Fact] public void Constructor_ThrowsOnNullWhenFalse() { Assert.Throws(() => - ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), ReferenceExpression.Empty, null!)); + ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), bool.TrueString, ReferenceExpression.Empty, null!)); } [Fact] public void Name_IsAutoGeneratedFromCondition() { - var expr = ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), ReferenceExpression.Empty, ReferenceExpression.Empty); + var expr = ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), bool.TrueString, ReferenceExpression.Empty, ReferenceExpression.Empty); Assert.StartsWith("cond-", expr.Name); } diff --git a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs index 3be925970e5..102625d37fd 100644 --- a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs +++ b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs @@ -231,7 +231,7 @@ public void PublishingRedisResourceAsContainerResultsInConnectionStringProperty( var container = resources.GetProperty("rediscontainer"); Assert.Equal("container.v0", container.GetProperty("type").GetString()); var connectionString = container.GetProperty("connectionString").GetString(); - Assert.Equal("{rediscontainer.bindings.tcp.host}:{rediscontainer.bindings.tcp.port},password={rediscontainer-password.value}{cond-rediscontainer-bindings-tcp-tlsenabled-91f82fca.connectionString}", connectionString); + Assert.Equal("{rediscontainer.bindings.tcp.host}:{rediscontainer.bindings.tcp.port},password={rediscontainer-password.value}{cond-rediscontainer-bindings-tcp-tlsenabled-c16dc063.connectionString}", connectionString); } [Fact] @@ -407,7 +407,7 @@ public void VerifyTestProgramFullManifest() }, "redis": { "type": "container.v0", - "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={redis-password.value}{cond-redis-bindings-tcp-tlsenabled-7a0eaf42.connectionString}", + "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={redis-password.value}{cond-redis-bindings-tcp-tlsenabled-d148d83a.connectionString}", "image": "{{ComponentTestConstants.AspireTestContainerRegistry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}", "entrypoint": "/bin/sh", "args": [ @@ -491,7 +491,7 @@ public void VerifyTestProgramFullManifest() "value": "{postgres-password.value}", "filter": "uri" }, - "cond-redis-bindings-tcp-tlsenabled-7a0eaf42": { + "cond-redis-bindings-tcp-tlsenabled-d148d83a": { "type": "value.v0", "connectionString": "" } From 2a77de0ec23f84894172456ca2b5f705147a3138 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 12:00:57 -0800 Subject: [PATCH 16/45] Reorder matchValue parameter in polyglot language definitions Move matchValue to come after condition (before whenTrue/whenFalse) in all 5 language base files to match the C# CreateConditional signature: createConditional(condition, matchValue, whenTrue, whenFalse) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resources/base.go | 2 +- .../Resources/Base.java | 6 ++--- .../Resources/base.py | 2 +- .../Resources/base.rs | 2 +- .../Resources/base.ts | 22 +++++++++---------- .../Ats/ReferenceExpressionRef.cs | 1 + 6 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go index a82e604bcb7..d32ca582020 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go +++ b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go @@ -67,7 +67,7 @@ func NewReferenceExpressionFromHandle(handle *Handle, client *AspireClient) *Ref } // CreateConditionalReferenceExpression creates a conditional reference expression from its parts. -func CreateConditionalReferenceExpression(condition any, whenTrue *ReferenceExpression, whenFalse *ReferenceExpression, matchValue string) *ReferenceExpression { +func CreateConditionalReferenceExpression(condition any, matchValue string, whenTrue *ReferenceExpression, whenFalse *ReferenceExpression) *ReferenceExpression { if matchValue == "" { matchValue = "True" } diff --git a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java index a1ea90ef78d..bfd67286fa1 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java +++ b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java @@ -83,7 +83,7 @@ class ReferenceExpression { } // Conditional mode constructor - private ReferenceExpression(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse, String matchValue) { + private ReferenceExpression(Object condition, String matchValue, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { this.condition = condition; this.whenTrue = whenTrue; this.whenFalse = whenFalse; @@ -142,8 +142,8 @@ static ReferenceExpression refExpr(String format, Object... args) { /** * Creates a conditional reference expression from its parts. */ - static ReferenceExpression createConditional(Object condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse, String matchValue) { - return new ReferenceExpression(condition, whenTrue, whenFalse, matchValue); + static ReferenceExpression createConditional(Object condition, String matchValue, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { + return new ReferenceExpression(condition, matchValue, whenTrue, whenFalse); } } diff --git a/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py b/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py index af89201e7da..d118a8aa58c 100644 --- a/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py +++ b/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py @@ -28,7 +28,7 @@ def create(format_string: str, *values: Any) -> "ReferenceExpression": return ReferenceExpression(format_string, value_providers) @staticmethod - def create_conditional(condition: Any, when_true: "ReferenceExpression", when_false: "ReferenceExpression", match_value: str = "True") -> "ReferenceExpression": + def create_conditional(condition: Any, match_value: str, when_true: "ReferenceExpression", when_false: "ReferenceExpression") -> "ReferenceExpression": """Creates a conditional reference expression from its parts.""" expr = ReferenceExpression.__new__(ReferenceExpression) expr._format_string = "" diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs index 89fa9a0ad12..d9ba96b3cd3 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs @@ -101,7 +101,7 @@ impl ReferenceExpression { } /// Creates a conditional reference expression from its parts. - pub fn create_conditional(condition: Value, when_true: ReferenceExpression, when_false: ReferenceExpression, match_value: Option) -> Self { + pub fn create_conditional(condition: Value, match_value: Option, when_true: ReferenceExpression, when_false: ReferenceExpression) -> Self { Self { format: None, args: None, diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts index 405c9e0b436..bfe098a22d7 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts @@ -55,24 +55,24 @@ export class ReferenceExpression { constructor(format: string, valueProviders: unknown[]); constructor(handle: Handle, client: AspireClient); - constructor(condition: unknown, whenTrue: ReferenceExpression, whenFalse: ReferenceExpression, matchValue?: string); + constructor(condition: unknown, matchValue: string, whenTrue: ReferenceExpression, whenFalse: ReferenceExpression); constructor( handleOrFormatOrCondition: Handle | string | unknown, - clientOrValueProvidersOrWhenTrue: AspireClient | unknown[] | ReferenceExpression, - whenFalse?: ReferenceExpression, - matchValue?: string + clientOrValueProvidersOrMatchValue: AspireClient | unknown[] | string, + whenTrueOrWhenFalse?: ReferenceExpression, + whenFalse?: ReferenceExpression ) { if (typeof handleOrFormatOrCondition === 'string') { this._format = handleOrFormatOrCondition; - this._valueProviders = clientOrValueProvidersOrWhenTrue as unknown[]; + this._valueProviders = clientOrValueProvidersOrMatchValue as unknown[]; } else if (handleOrFormatOrCondition instanceof Handle) { this._handle = handleOrFormatOrCondition; - this._client = clientOrValueProvidersOrWhenTrue as AspireClient; + this._client = clientOrValueProvidersOrMatchValue as AspireClient; } else { this._condition = handleOrFormatOrCondition; - this._whenTrue = clientOrValueProvidersOrWhenTrue as ReferenceExpression; + this._matchValue = (clientOrValueProvidersOrMatchValue as string) ?? 'True'; + this._whenTrue = whenTrueOrWhenFalse; this._whenFalse = whenFalse; - this._matchValue = matchValue ?? 'True'; } } @@ -117,11 +117,11 @@ export class ReferenceExpression { */ static createConditional( condition: unknown, + matchValue: string, whenTrue: ReferenceExpression, - whenFalse: ReferenceExpression, - matchValue?: string + whenFalse: ReferenceExpression ): ReferenceExpression { - return new ReferenceExpression(condition, whenTrue, whenFalse, matchValue); + return new ReferenceExpression(condition, matchValue, whenTrue, whenFalse); } /** diff --git a/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs b/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs index 9a41daecc2f..4a8e91cfb6a 100644 --- a/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs +++ b/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs @@ -31,6 +31,7 @@ namespace Aspire.Hosting.RemoteHost.Ats; /// { /// "$expr": { /// "condition": { "$handle": "Aspire.Hosting.ApplicationModel/EndpointReferenceExpression:1" }, +/// "matchValue": "true", /// "whenTrue": { "$expr": { "format": ",ssl=true" } }, /// "whenFalse": { "$expr": { "format": "" } } /// } From c58d7804ec8633db4688e63f6a67c92297f4d2f3 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 12:12:28 -0800 Subject: [PATCH 17/45] Fix CreateConditional callers in RemoteHost tests Update HandleRegistryTests and AtsMarshallerTests to use the new parameter order: CreateConditional(condition, matchValue, whenTrue, whenFalse) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs | 8 ++++---- .../HandleRegistryTests.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs index cf9f5e7a10d..b61b02fd12b 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs @@ -931,7 +931,7 @@ public void MarshalToJson_MarshalsConditionalReferenceExpressionAsHandle() var condition = new TestConditionValueProvider(bool.TrueString); var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var conditional = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + var conditional = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); var result = marshaller.MarshalToJson(conditional); @@ -950,7 +950,7 @@ public void MarshalToJson_ConditionalReferenceExpression_RoundTripsViaHandle() var condition = new TestConditionValueProvider(bool.TrueString); var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var conditional = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + var conditional = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); var json = marshaller.MarshalToJson(conditional); Assert.NotNull(json); @@ -970,7 +970,7 @@ public async Task MarshalToJson_ConditionalReferenceExpression_PreservesValueAft var condition = new TestConditionValueProvider(bool.TrueString); var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var conditional = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + var conditional = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); var json = marshaller.MarshalToJson(conditional); var handleId = json!["$handle"]!.GetValue(); @@ -989,7 +989,7 @@ public async Task MarshalToJson_ConditionalReferenceExpression_FalseConditionRou var condition = new TestConditionValueProvider(bool.FalseString); var whenTrue = ReferenceExpression.Create($",ssl=true"); var whenFalse = ReferenceExpression.Empty; - var conditional = ReferenceExpression.CreateConditional(condition, whenTrue, whenFalse); + var conditional = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); var json = marshaller.MarshalToJson(conditional); var handleId = json!["$handle"]!.GetValue(); diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs index b010884ef8c..04c4582e4e5 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs @@ -351,7 +351,7 @@ public void Register_ConditionalReferenceExpression_CanBeRetrievedByTypeId() var registry = new HandleRegistry(); var condition = new TestConditionProvider(bool.TrueString); var conditional = ReferenceExpression.CreateConditional( - condition, ReferenceExpression.Create($",ssl=true"), ReferenceExpression.Empty); + condition, bool.TrueString, ReferenceExpression.Create($",ssl=true"), ReferenceExpression.Empty); var typeId = AtsConstants.ReferenceExpressionTypeId; var handleId = registry.Register(conditional, typeId); From 6e13b903c9b56002d6e170e996928df5d90d4774 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 12:57:49 -0800 Subject: [PATCH 18/45] Handle conditional ReferenceExpression in Azure publishing paths - AzurePublishingContext.EvalConditionalExpr: generates Bicep ternary via ConditionalExpression when encountering IsConditional expressions - BaseContainerAppContext.ProcessValue: adds IsConditional branch that recursively processes condition/branches and builds Bicep conditional - EndpointMapping: adds TlsEnabled field populated from endpoint annotation - GetEndpointValue: adds EndpointProperty.TlsEnabled case - New snapshot test: RedisWithConditionalConnectionString validates the conditional expression flows through to Bicep output as a ternary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BaseContainerAppContext.cs | 26 ++++- .../ContainerAppContext.cs | 4 +- .../AzurePublishingContext.cs | 15 +++ .../AzureContainerAppsTests.cs | 30 ++++++ ...ConditionalConnectionString.verified.bicep | 94 +++++++++++++++++++ ...hConditionalConnectionString.verified.json | 12 +++ 6 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.json diff --git a/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs b/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs index 1ff4adbf187..94f41cd8a55 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs @@ -25,7 +25,7 @@ internal abstract class BaseContainerAppContext(IResource resource, ContainerApp /// public string NormalizedContainerAppName => resource.Name.ToLowerInvariant(); - protected record struct EndpointMapping(string Scheme, string Host, int Port, int? TargetPort, bool IsHttpIngress, bool External); + protected record struct EndpointMapping(string Scheme, string Host, int Port, int? TargetPort, bool IsHttpIngress, bool External, bool TlsEnabled); protected readonly Dictionary _endpointMapping = []; // Resolved environment variables and command line args @@ -186,7 +186,7 @@ private void ProcessVolumes() private BicepValue GetEndpointValue(EndpointMapping mapping, EndpointProperty property) { - var (scheme, host, port, targetPort, isHttpIngress, external) = mapping; + var (scheme, host, port, targetPort, isHttpIngress, external, tlsEnabled) = mapping; BicepValue GetHostValue(string? prefix = null, string? suffix = null) { @@ -208,6 +208,7 @@ BicepValue GetHostValue(string? prefix = null, string? suffix = null) EndpointProperty.HostAndPort => GetHostValue(suffix: $":{port}"), EndpointProperty.TargetPort => targetPort is null ? AllocateContainerPortParameter() : $"{targetPort}", EndpointProperty.Scheme => scheme, + EndpointProperty.TlsEnabled => tlsEnabled ? bool.TrueString : bool.FalseString, _ => throw new NotSupportedException(), }; } @@ -286,6 +287,27 @@ BicepValue GetHostValue(string? prefix = null, string? suffix = null) if (value is ReferenceExpression expr) { + // Handle conditional expressions by generating a Bicep ternary + if (expr.IsConditional) + { + var (conditionVal, _) = ProcessValue(expr.Condition!, secretType, parent: expr); + var (whenTrueVal, trueSecret) = ProcessValue(expr.WhenTrue!, secretType, parent: expr); + var (whenFalseVal, falseSecret) = ProcessValue(expr.WhenFalse!, secretType, parent: expr); + + var conditionExpr = ResolveValue(conditionVal).Compile(); + var matchExpr = new StringLiteralExpression(expr.MatchValue!); + var conditional = new ConditionalExpression( + new BinaryExpression(conditionExpr, BinaryBicepOperator.Equal, matchExpr), + ResolveValue(whenTrueVal).Compile(), + ResolveValue(whenFalseVal).Compile()); + + var finalSecret = trueSecret != SecretType.None || falseSecret != SecretType.None + ? SecretType.Normal + : SecretType.None; + + return (new BicepValue(conditional), finalSecret); + } + // Special case simple expressions if (expr.Format == "{0}" && expr.ValueProviders.Count == 1) { diff --git a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs index 8beee80b739..3ba6954b84b 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs @@ -233,7 +233,7 @@ static bool Compatible(string[] transports) => var scheme = preserveHttp ? endpoint.UriScheme : "https"; var port = scheme is "http" ? 80 : 443; - _endpointMapping[endpoint.Name] = new(scheme, NormalizedContainerAppName, port, targetPort, true, httpIngress.External); + _endpointMapping[endpoint.Name] = new(scheme, NormalizedContainerAppName, port, targetPort, true, httpIngress.External, endpoint.TlsEnabled); } // Record HTTP endpoints being upgraded (logged once at environment level) @@ -265,7 +265,7 @@ static bool Compatible(string[] transports) => foreach (var resolved in g.ResolvedEndpoints) { var endpoint = resolved.Endpoint; - _endpointMapping[endpoint.Name] = new(endpoint.UriScheme, NormalizedContainerAppName, resolved.ExposedPort.Value ?? g.Port.Value, g.Port.Value, false, g.External); + _endpointMapping[endpoint.Name] = new(endpoint.UriScheme, NormalizedContainerAppName, resolved.ExposedPort.Value ?? g.Port.Value, g.Port.Value, false, g.External, endpoint.TlsEnabled); } } } diff --git a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs index 9acbc4c7374..6120509c83d 100644 --- a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs +++ b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs @@ -200,12 +200,27 @@ FormattableString EvalExpr(ReferenceExpression expr) return FormattableStringFactory.Create(expr.Format, args); } + BicepValue EvalConditionalExpr(ReferenceExpression expr) + { + var conditionVal = ResolveValue(Eval(expr.Condition!)); + var whenTrueVal = ResolveValue(Eval(expr.WhenTrue!)); + var whenFalseVal = ResolveValue(Eval(expr.WhenFalse!)); + + var conditional = new ConditionalExpression( + new BinaryExpression(conditionVal.Compile(), BinaryBicepOperator.Equal, new StringLiteralExpression(expr.MatchValue!)), + whenTrueVal.Compile(), + whenFalseVal.Compile()); + + return new BicepValue(conditional); + } + object Eval(object? value) => value switch { BicepOutputReference b => GetOutputs(moduleMap[b.Resource], b.Name), ParameterResource p => ParameterLookup[p], ConnectionStringReference r => Eval(r.Resource.ConnectionStringExpression), IResourceWithConnectionString cs => Eval(cs.ConnectionStringExpression), + ReferenceExpression { IsConditional: true } re => EvalConditionalExpr(re), ReferenceExpression re => EvalExpr(re), string s => s, _ => "" diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index c6476688bfa..8b77ad0b6bd 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -2367,4 +2367,34 @@ public async Task MultipleComputeEnvironmentsOnlyProcessTargetedResources() Assert.Null(webappServiceResource.GetDeploymentTargetAnnotation(aca.Resource)); Assert.Null(containerAcaResource.GetDeploymentTargetAnnotation(appService.Resource)); } + + [Fact] + public async Task RedisWithConditionalConnectionString() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureContainerAppEnvironment("env"); + + var redis = builder.AddRedis("cache"); + + builder.AddProject("api", launchProfileName: null) + .WithReference(redis); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var proj = Assert.Single(model.GetProjectResources()); + proj.TryGetLastAnnotation(out var target); + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await GetManifestWithBicep(resource); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.bicep new file mode 100644 index 00000000000..c8e1ad67150 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.bicep @@ -0,0 +1,94 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +param api_containerimage string + +@secure() +param cache_password_value string + +resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { + name: 'api' + location: location + properties: { + configuration: { + secrets: [ + { + name: 'connectionstrings--cache' + value: 'cache:6379,password=${cache_password_value}${('False' == 'True') ? ',ssl=true' : ''}' + } + { + name: 'cache-password' + value: cache_password_value + } + { + name: 'cache-uri' + value: 'redis://:${uriComponent(cache_password_value)}@cache:6379' + } + ] + activeRevisionsMode: 'Single' + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + runtime: { + dotnet: { + autoConfigureDataProtection: true + } + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: api_containerimage + name: 'api' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ConnectionStrings__cache' + secretRef: 'connectionstrings--cache' + } + { + name: 'CACHE_HOST' + value: 'cache' + } + { + name: 'CACHE_PORT' + value: '6379' + } + { + name: 'CACHE_PASSWORD' + secretRef: 'cache-password' + } + { + name: 'CACHE_URI' + secretRef: 'cache-uri' + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.json new file mode 100644 index 00000000000..7c4abf9d183 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.json @@ -0,0 +1,12 @@ +{ + "type": "azure.bicep.v0", + "path": "api-containerapp.module.bicep", + "params": { + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "api_containerimage": "{api.containerImage}", + "cache_password_value": "{cache-password.value}" + } +} \ No newline at end of file From 96eaa09a40d046ca9e99cbdb9244c77902ae8cba Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 13:04:16 -0800 Subject: [PATCH 19/45] Resolve static conditionals at publish time, emit ternary only for parameters When the condition resolves to a static string (e.g., TlsEnabled on a container endpoint), evaluate the conditional at publish time and emit only the winning branch. When the condition resolves to a Bicep parameter or output, emit a proper Bicep ternary expression. This produces cleaner Bicep: 'cache:6379,password=${pw}' instead of 'cache:6379,password=${pw}${("False" == "True") ? ",ssl=true" : ""}' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BaseContainerAppContext.cs | 24 +++++++++++++++---- .../AzurePublishingContext.cs | 17 ++++++++++--- ...ConditionalConnectionString.verified.bicep | 2 +- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs b/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs index 94f41cd8a55..ba6da44f5a5 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs @@ -287,17 +287,33 @@ BicepValue GetHostValue(string? prefix = null, string? suffix = null) if (value is ReferenceExpression expr) { - // Handle conditional expressions by generating a Bicep ternary + // Handle conditional expressions if (expr.IsConditional) { var (conditionVal, _) = ProcessValue(expr.Condition!, secretType, parent: expr); + + // If the condition resolves to a static string, evaluate at publish time + string? staticCondition = conditionVal is string str ? str : null; + if (staticCondition is null && conditionVal is BicepValue bv + && bv.Compile() is StringLiteralExpression sle) + { + staticCondition = sle.Value; + } + + if (staticCondition is not null) + { + var branch = string.Equals(staticCondition, expr.MatchValue, StringComparison.OrdinalIgnoreCase) + ? expr.WhenTrue! + : expr.WhenFalse!; + return ProcessValue(branch, secretType, parent: parent); + } + + // Condition is a Bicep parameter/output — emit a ternary expression var (whenTrueVal, trueSecret) = ProcessValue(expr.WhenTrue!, secretType, parent: expr); var (whenFalseVal, falseSecret) = ProcessValue(expr.WhenFalse!, secretType, parent: expr); - var conditionExpr = ResolveValue(conditionVal).Compile(); - var matchExpr = new StringLiteralExpression(expr.MatchValue!); var conditional = new ConditionalExpression( - new BinaryExpression(conditionExpr, BinaryBicepOperator.Equal, matchExpr), + new BinaryExpression(ResolveValue(conditionVal).Compile(), BinaryBicepOperator.Equal, new StringLiteralExpression(expr.MatchValue!)), ResolveValue(whenTrueVal).Compile(), ResolveValue(whenFalseVal).Compile()); diff --git a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs index 6120509c83d..76b409f8f68 100644 --- a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs +++ b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs @@ -200,14 +200,25 @@ FormattableString EvalExpr(ReferenceExpression expr) return FormattableStringFactory.Create(expr.Format, args); } - BicepValue EvalConditionalExpr(ReferenceExpression expr) + object EvalConditionalExpr(ReferenceExpression expr) { - var conditionVal = ResolveValue(Eval(expr.Condition!)); + var conditionVal = Eval(expr.Condition!); + + // If the condition resolves to a static string, evaluate at publish time + if (conditionVal is string staticCondition) + { + var branch = string.Equals(staticCondition, expr.MatchValue, StringComparison.OrdinalIgnoreCase) + ? expr.WhenTrue! + : expr.WhenFalse!; + return Eval(branch); + } + + // Condition is a Bicep parameter/output — emit a ternary expression var whenTrueVal = ResolveValue(Eval(expr.WhenTrue!)); var whenFalseVal = ResolveValue(Eval(expr.WhenFalse!)); var conditional = new ConditionalExpression( - new BinaryExpression(conditionVal.Compile(), BinaryBicepOperator.Equal, new StringLiteralExpression(expr.MatchValue!)), + new BinaryExpression(ResolveValue(conditionVal).Compile(), BinaryBicepOperator.Equal, new StringLiteralExpression(expr.MatchValue!)), whenTrueVal.Compile(), whenFalseVal.Compile()); diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.bicep index c8e1ad67150..eb14f74e798 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithConditionalConnectionString.verified.bicep @@ -22,7 +22,7 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { secrets: [ { name: 'connectionstrings--cache' - value: 'cache:6379,password=${cache_password_value}${('False' == 'True') ? ',ssl=true' : ''}' + value: 'cache:6379,password=${cache_password_value}' } { name: 'cache-password' From 415b3d3444b9e13ab6c747f8ab85f76715035ee9 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 13:06:32 -0800 Subject: [PATCH 20/45] Add test cases for all conditional expression branches - RedisWithConditionalConnectionString: static false (TLS disabled) resolves to connection string without ssl suffix - RedisWithTlsEnabledConditionalConnectionString: static true resolves to connection string with ,ssl=true suffix - ConditionalExpressionWithParameterCondition: parameter-based condition emits a proper Bicep ternary expression Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureContainerAppsTests.cs | 71 ++++++++++++++ ...ssionWithParameterCondition.verified.bicep | 63 +++++++++++++ ...essionWithParameterCondition.verified.json | 12 +++ ...ConditionalConnectionString.verified.bicep | 94 +++++++++++++++++++ ...dConditionalConnectionString.verified.json | 12 +++ 5 files changed, 252 insertions(+) create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.json create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithTlsEnabledConditionalConnectionString.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithTlsEnabledConditionalConnectionString.verified.json diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 8b77ad0b6bd..7140cf9b22c 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -2397,4 +2397,75 @@ public async Task RedisWithConditionalConnectionString() await Verify(manifest.ToString(), "json") .AppendContentAsFile(bicep, "bicep"); } + + [Fact] + public async Task RedisWithTlsEnabledConditionalConnectionString() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureContainerAppEnvironment("env"); + + var redis = builder.AddRedis("cache"); + redis.WithEndpoint("tcp", e => e.TlsEnabled = true); + + builder.AddProject("api", launchProfileName: null) + .WithReference(redis); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var proj = Assert.Single(model.GetProjectResources()); + proj.TryGetLastAnnotation(out var target); + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await GetManifestWithBicep(resource); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); + } + + [Fact] + public async Task ConditionalExpressionWithParameterCondition() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureContainerAppEnvironment("env"); + + var featureFlag = builder.AddParameter("enable-feature"); + + var project = builder.AddProject("api", launchProfileName: null); + + project.WithEnvironment(context => + { + var conditional = ReferenceExpression.CreateConditional( + featureFlag.Resource, + bool.TrueString, + ReferenceExpression.Create($"enabled"), + ReferenceExpression.Create($"disabled")); + + context.EnvironmentVariables["FEATURE_MODE"] = conditional; + }); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var proj = Assert.Single(model.GetProjectResources()); + proj.TryGetLastAnnotation(out var target); + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await GetManifestWithBicep(resource); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.bicep new file mode 100644 index 00000000000..361cf07d6d5 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.bicep @@ -0,0 +1,63 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +param api_containerimage string + +param enable_feature_value string + +resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + runtime: { + dotnet: { + autoConfigureDataProtection: true + } + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: api_containerimage + name: 'api' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'FEATURE_MODE' + value: (enable_feature_value == 'True') ? 'enabled' : 'disabled' + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.json new file mode 100644 index 00000000000..dac685deadb --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.json @@ -0,0 +1,12 @@ +{ + "type": "azure.bicep.v0", + "path": "api-containerapp.module.bicep", + "params": { + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "api_containerimage": "{api.containerImage}", + "enable_feature_value": "{enable-feature.value}" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithTlsEnabledConditionalConnectionString.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithTlsEnabledConditionalConnectionString.verified.bicep new file mode 100644 index 00000000000..92e9b099639 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithTlsEnabledConditionalConnectionString.verified.bicep @@ -0,0 +1,94 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +param api_containerimage string + +@secure() +param cache_password_value string + +resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { + name: 'api' + location: location + properties: { + configuration: { + secrets: [ + { + name: 'connectionstrings--cache' + value: 'cache:6379,password=${cache_password_value},ssl=true' + } + { + name: 'cache-password' + value: cache_password_value + } + { + name: 'cache-uri' + value: 'redis://:${uriComponent(cache_password_value)}@cache:6379' + } + ] + activeRevisionsMode: 'Single' + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + runtime: { + dotnet: { + autoConfigureDataProtection: true + } + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: api_containerimage + name: 'api' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ConnectionStrings__cache' + secretRef: 'connectionstrings--cache' + } + { + name: 'CACHE_HOST' + value: 'cache' + } + { + name: 'CACHE_PORT' + value: '6379' + } + { + name: 'CACHE_PASSWORD' + secretRef: 'cache-password' + } + { + name: 'CACHE_URI' + secretRef: 'cache-uri' + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithTlsEnabledConditionalConnectionString.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithTlsEnabledConditionalConnectionString.verified.json new file mode 100644 index 00000000000..7c4abf9d183 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.RedisWithTlsEnabledConditionalConnectionString.verified.json @@ -0,0 +1,12 @@ +{ + "type": "azure.bicep.v0", + "path": "api-containerapp.module.bicep", + "params": { + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "api_containerimage": "{api.containerImage}", + "cache_password_value": "{cache-password.value}" + } +} \ No newline at end of file From 8bada821340e43b54714268b5fcba54ad724ef53 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 13:24:47 -0800 Subject: [PATCH 21/45] Add tests for nested parameters and nested conditionals in Bicep - ConditionalBranchWithParameterReference: verifies parameter references inside conditional branches are properly interpolated into Bicep (e.g., 'prefix-${connection_prefix_value}-enabled') - NestedConditionalExpressions: verifies nested conditionals produce correct nested ternary expressions in Bicep output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureContainerAppsTests.cs | 88 +++++++++++++++++++ ...ranchWithParameterReference.verified.bicep | 65 ++++++++++++++ ...BranchWithParameterReference.verified.json | 13 +++ ...estedConditionalExpressions.verified.bicep | 65 ++++++++++++++ ...NestedConditionalExpressions.verified.json | 13 +++ 5 files changed, 244 insertions(+) create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.json create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.json diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 7140cf9b22c..acd4e01168d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -2468,4 +2468,92 @@ public async Task ConditionalExpressionWithParameterCondition() await Verify(manifest.ToString(), "json") .AppendContentAsFile(bicep, "bicep"); } + + [Fact] + public async Task ConditionalBranchWithParameterReference() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureContainerAppEnvironment("env"); + + var featureFlag = builder.AddParameter("enable-feature"); + var connectionPrefix = builder.AddParameter("connection-prefix"); + + var project = builder.AddProject("api", launchProfileName: null); + + project.WithEnvironment(context => + { + var conditional = ReferenceExpression.CreateConditional( + featureFlag.Resource, + bool.TrueString, + ReferenceExpression.Create($"prefix-{connectionPrefix.Resource}-enabled"), + ReferenceExpression.Create($"disabled")); + + context.EnvironmentVariables["FEATURE_CONNECTION"] = conditional; + }); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var proj = Assert.Single(model.GetProjectResources()); + proj.TryGetLastAnnotation(out var target); + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await GetManifestWithBicep(resource); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); + } + + [Fact] + public async Task NestedConditionalExpressions() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureContainerAppEnvironment("env"); + + var outerFlag = builder.AddParameter("outer-flag"); + var innerFlag = builder.AddParameter("inner-flag"); + + var project = builder.AddProject("api", launchProfileName: null); + + project.WithEnvironment(context => + { + var innerConditional = ReferenceExpression.CreateConditional( + innerFlag.Resource, + bool.TrueString, + ReferenceExpression.Create($"inner-true"), + ReferenceExpression.Create($"inner-false")); + + var outerConditional = ReferenceExpression.CreateConditional( + outerFlag.Resource, + bool.TrueString, + innerConditional, + ReferenceExpression.Create($"outer-false")); + + context.EnvironmentVariables["NESTED_FEATURE"] = outerConditional; + }); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var proj = Assert.Single(model.GetProjectResources()); + proj.TryGetLastAnnotation(out var target); + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await GetManifestWithBicep(resource); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.bicep new file mode 100644 index 00000000000..a37bb9959f8 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.bicep @@ -0,0 +1,65 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +param api_containerimage string + +param enable_feature_value string + +param connection_prefix_value string + +resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + runtime: { + dotnet: { + autoConfigureDataProtection: true + } + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: api_containerimage + name: 'api' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'FEATURE_CONNECTION' + value: (enable_feature_value == 'True') ? 'prefix-${connection_prefix_value}-enabled' : 'disabled' + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.json new file mode 100644 index 00000000000..6e463e1cd4d --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.json @@ -0,0 +1,13 @@ +{ + "type": "azure.bicep.v0", + "path": "api-containerapp.module.bicep", + "params": { + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "api_containerimage": "{api.containerImage}", + "enable_feature_value": "{enable-feature.value}", + "connection_prefix_value": "{connection-prefix.value}" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.bicep new file mode 100644 index 00000000000..b80b9a53880 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.bicep @@ -0,0 +1,65 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +param api_containerimage string + +param outer_flag_value string + +param inner_flag_value string + +resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + runtime: { + dotnet: { + autoConfigureDataProtection: true + } + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: api_containerimage + name: 'api' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'NESTED_FEATURE' + value: (outer_flag_value == 'True') ? (inner_flag_value == 'True') ? 'inner-true' : 'inner-false' : 'outer-false' + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.json new file mode 100644 index 00000000000..10d6f4b07c8 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.json @@ -0,0 +1,13 @@ +{ + "type": "azure.bicep.v0", + "path": "api-containerapp.module.bicep", + "params": { + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "api_containerimage": "{api.containerImage}", + "outer_flag_value": "{outer-flag.value}", + "inner_flag_value": "{inner-flag.value}" + } +} \ No newline at end of file From 556d29bea0fcf5cb00c14559c160f14118507e6e Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 13:27:08 -0800 Subject: [PATCH 22/45] Fix Go Handle.ToJSON return type for map compatibility Handle.ToJSON() returned map[string]string but ReferenceExpression.ToJSON() returns map[string]any. Go does not allow implicit conversion between these map types, causing a compile error in the handle-mode path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.CodeGeneration.Go/Resources/transport.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.CodeGeneration.Go/Resources/transport.go b/src/Aspire.Hosting.CodeGeneration.Go/Resources/transport.go index 79e8c8c5d27..800f8e77afb 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/Resources/transport.go +++ b/src/Aspire.Hosting.CodeGeneration.Go/Resources/transport.go @@ -54,8 +54,8 @@ type Handle struct { } // ToJSON returns the handle as a JSON-serializable map. -func (h *Handle) ToJSON() map[string]string { - return map[string]string{ +func (h *Handle) ToJSON() map[string]any { + return map[string]any{ "$handle": h.HandleID, "$type": h.TypeID, } From 2f3177b2b8f1d8473fcc302e0072dcaec5f80d41 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 13:33:05 -0800 Subject: [PATCH 23/45] Implement Serialize for Rust ReferenceExpression ReferenceExpression contains Arc which prevents derive(Serialize). Add a custom Serialize impl that delegates to to_json(), matching how the type is already serialized throughout the codebase. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs index d9ba96b3cd3..5e39e7d65d9 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs @@ -152,6 +152,12 @@ impl HasHandle for ReferenceExpression { } } +impl Serialize for ReferenceExpression { + fn serialize(&self, serializer: S) -> Result { + self.to_json().serialize(serializer) + } +} + /// Convenience function to create a reference expression. pub fn ref_expr(format: impl Into, args: Vec) -> ReferenceExpression { ReferenceExpression::new(format, args) From b45f0c6c3759cabf1786b411afa8a8e94fce9be0 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 13:51:20 -0800 Subject: [PATCH 24/45] Add Deserialize impl for Rust ReferenceExpression The generated code uses serde_json::from_value() to deserialize ReferenceExpression values from server responses. Added a custom Deserialize impl that reconstructs value-mode, conditional-mode, and handle-mode expressions from their JSON representations. Verified with cargo check and cargo run against a test harness exercising all serialization/deserialization patterns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resources/base.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs index 5e39e7d65d9..6bba6911577 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs @@ -158,6 +158,51 @@ impl Serialize for ReferenceExpression { } } +impl<'de> Deserialize<'de> for ReferenceExpression { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + if let Some(ref_expr) = value.get("$refExpr") { + if let Some(condition) = ref_expr.get("condition") { + let when_true: ReferenceExpression = serde_json::from_value( + ref_expr.get("whenTrue").cloned().unwrap_or(Value::Null) + ).map_err(serde::de::Error::custom)?; + let when_false: ReferenceExpression = serde_json::from_value( + ref_expr.get("whenFalse").cloned().unwrap_or(Value::Null) + ).map_err(serde::de::Error::custom)?; + let match_value = ref_expr.get("matchValue") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + return Ok(ReferenceExpression::create_conditional( + condition.clone(), + match_value, + when_true, + when_false, + )); + } + let format = ref_expr.get("format") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let args = ref_expr.get("args") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + return Ok(ReferenceExpression::new(format, args)); + } + if value.get("$handle").is_some() { + let handle: Handle = serde_json::from_value(value) + .map_err(serde::de::Error::custom)?; + return Ok(Self { + format: None, args: None, + condition: None, when_true: None, when_false: None, + match_value: None, is_conditional: false, + handle: Some(handle), client: None, + }); + } + Err(serde::de::Error::custom("expected $refExpr or $handle")) + } +} + /// Convenience function to create a reference expression. pub fn ref_expr(format: impl Into, args: Vec) -> ReferenceExpression { ReferenceExpression::new(format, args) From e831995bc277ce999b3e1e647a562fa39bff64ba Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 14:06:02 -0800 Subject: [PATCH 25/45] Remove handle mode from ReferenceExpression in Go, Rust, Python, Java Handle mode was incorrectly added to ReferenceExpression in these languages. The original upstream code only had value mode (format + args). TypeScript already had handle mode upstream and is left unchanged. Removing handle mode from Rust restores derive(Serialize, Deserialize) which eliminates the need for custom serde impls. Go no longer needs map[string]any return type change on Handle.ToJSON(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resources/base.go | 34 +---- .../Resources/Base.java | 31 +---- .../Resources/base.py | 23 ---- .../Resources/base.rs | 124 +++--------------- 4 files changed, 23 insertions(+), 189 deletions(-) diff --git a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go index d32ca582020..a453554fe3a 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go +++ b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go @@ -37,22 +37,16 @@ func NewResourceBuilderBase(handle *Handle, client *AspireClient) ResourceBuilde } // ReferenceExpression represents a reference expression. -// Supports value mode (Format + Args), conditional mode (Condition + WhenTrue + WhenFalse), -// and handle mode (wrapping a server-returned handle). +// Supports value mode (Format + Args) and conditional mode (Condition + WhenTrue + WhenFalse). type ReferenceExpression struct { - // Value mode fields Format string Args []any // Conditional mode fields - Condition any - WhenTrue *ReferenceExpression - WhenFalse *ReferenceExpression - MatchValue string - - // Handle mode fields (when wrapping a server-returned handle) - handle *Handle - client *AspireClient + Condition any + WhenTrue *ReferenceExpression + WhenFalse *ReferenceExpression + MatchValue string isConditional bool } @@ -61,11 +55,6 @@ func NewReferenceExpression(format string, args ...any) *ReferenceExpression { return &ReferenceExpression{Format: format, Args: args} } -// NewReferenceExpressionFromHandle creates a ReferenceExpression wrapping a server-returned handle. -func NewReferenceExpressionFromHandle(handle *Handle, client *AspireClient) *ReferenceExpression { - return &ReferenceExpression{handle: handle, client: client} -} - // CreateConditionalReferenceExpression creates a conditional reference expression from its parts. func CreateConditionalReferenceExpression(condition any, matchValue string, whenTrue *ReferenceExpression, whenFalse *ReferenceExpression) *ReferenceExpression { if matchValue == "" { @@ -87,9 +76,6 @@ func RefExpr(format string, args ...any) *ReferenceExpression { // ToJSON returns the reference expression as a JSON-serializable map. func (r *ReferenceExpression) ToJSON() map[string]any { - if r.handle != nil { - return r.handle.ToJSON() - } if r.isConditional { return map[string]any{ "$refExpr": map[string]any{ @@ -108,16 +94,6 @@ func (r *ReferenceExpression) ToJSON() map[string]any { } } -// Handle returns the underlying handle, if in handle mode. -func (r *ReferenceExpression) Handle() *Handle { - return r.handle -} - -// Client returns the AspireClient, if in handle mode. -func (r *ReferenceExpression) Client() *AspireClient { - return r.client -} - // AspireList is a handle-backed list with lazy handle resolution. type AspireList[T any] struct { HandleWrapperBase diff --git a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java index bfd67286fa1..0d4a8d5c014 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java +++ b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java @@ -37,8 +37,7 @@ class ResourceBuilderBase extends HandleWrapperBase { /** * ReferenceExpression represents a reference expression. - * Supports value mode (format + args), conditional mode (condition + whenTrue + whenFalse), - * and handle mode (wrapping a server-returned handle). + * Supports value mode (format + args) and conditional mode (condition + whenTrue + whenFalse). */ class ReferenceExpression { // Value mode fields @@ -52,10 +51,6 @@ class ReferenceExpression { private final String matchValue; private final boolean isConditional; - // Handle mode fields - private final Handle handle; - private final AspireClient client; - // Value mode constructor ReferenceExpression(String format, Object... args) { this.format = format; @@ -65,21 +60,6 @@ class ReferenceExpression { this.whenFalse = null; this.matchValue = null; this.isConditional = false; - this.handle = null; - this.client = null; - } - - // Handle mode constructor - ReferenceExpression(Handle handle, AspireClient client) { - this.handle = handle; - this.client = client; - this.format = null; - this.args = null; - this.condition = null; - this.whenTrue = null; - this.whenFalse = null; - this.matchValue = null; - this.isConditional = false; } // Conditional mode constructor @@ -91,8 +71,6 @@ private ReferenceExpression(Object condition, String matchValue, ReferenceExpres this.isConditional = true; this.format = null; this.args = null; - this.handle = null; - this.client = null; } String getFormat() { @@ -103,14 +81,7 @@ Object[] getArgs() { return args; } - Handle getHandle() { - return handle; - } - Map toJson() { - if (handle != null) { - return handle.toJson(); - } if (isConditional) { var condPayload = new java.util.HashMap(); condPayload.put("condition", AspireClient.serializeValue(condition)); diff --git a/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py b/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py index d118a8aa58c..da1bf879dfc 100644 --- a/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py +++ b/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py @@ -14,8 +14,6 @@ class ReferenceExpression: def __init__(self, format_string: str, value_providers: List[Any]) -> None: self._format_string = format_string self._value_providers = value_providers - self._handle: Handle | None = None - self._client: AspireClient | None = None self._condition: Any = None self._when_true: ReferenceExpression | None = None self._when_false: ReferenceExpression | None = None @@ -33,8 +31,6 @@ def create_conditional(condition: Any, match_value: str, when_true: "ReferenceEx expr = ReferenceExpression.__new__(ReferenceExpression) expr._format_string = "" expr._value_providers = [] - expr._handle = None - expr._client = None expr._condition = condition expr._when_true = when_true expr._when_false = when_false @@ -42,24 +38,7 @@ def create_conditional(condition: Any, match_value: str, when_true: "ReferenceEx expr._is_conditional = True return expr - @staticmethod - def _from_handle(handle: "Handle", client: "AspireClient") -> "ReferenceExpression": - """Creates a ReferenceExpression wrapping a server-returned handle.""" - expr = ReferenceExpression.__new__(ReferenceExpression) - expr._format_string = "" - expr._value_providers = [] - expr._handle = handle - expr._client = client - expr._condition = None - expr._when_true = None - expr._when_false = None - expr._match_value = None - expr._is_conditional = False - return expr - def to_json(self) -> Dict[str, Any]: - if self._handle is not None: - return self._handle.to_json() if self._is_conditional: return { "$expr": { @@ -75,8 +54,6 @@ def to_json(self) -> Dict[str, Any]: return {"$expr": payload} def __str__(self) -> str: - if self._handle is not None: - return "ReferenceExpression(handle)" if self._is_conditional: return "ReferenceExpression(conditional)" return f"ReferenceExpression({self._format_string})" diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs index 6bba6911577..cd6f11c2f74 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs @@ -50,83 +50,50 @@ impl ResourceBuilderBase { } /// A reference expression for dynamic values. -/// Supports value mode (format + args), conditional mode (condition + whenTrue + whenFalse), -/// and handle mode (wrapping a server-returned handle). +/// Supports value mode (format + args) and conditional mode (condition + whenTrue + whenFalse). +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReferenceExpression { - // Value mode fields - pub format: Option, - pub args: Option>, - - // Conditional mode fields + pub format: String, + pub args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] condition: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] when_true: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] when_false: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] match_value: Option, + #[serde(default)] is_conditional: bool, - - // Handle mode fields - handle: Option, - client: Option>, } impl ReferenceExpression { - /// Creates a new value-mode reference expression. pub fn new(format: impl Into, args: Vec) -> Self { Self { - format: Some(format.into()), - args: Some(args), + format: format.into(), + args, condition: None, when_true: None, when_false: None, match_value: None, is_conditional: false, - handle: None, - client: None, - } - } - - /// Creates a new handle-mode reference expression from a server-returned handle. - pub fn from_handle(handle: Handle, client: Arc) -> Self { - Self { - format: None, - args: None, - condition: None, - when_true: None, - when_false: None, - match_value: None, - is_conditional: false, - handle: Some(handle), - client: Some(client), } } /// Creates a conditional reference expression from its parts. - pub fn create_conditional(condition: Value, match_value: Option, when_true: ReferenceExpression, when_false: ReferenceExpression) -> Self { + pub fn create_conditional(condition: Value, match_value: impl Into, when_true: ReferenceExpression, when_false: ReferenceExpression) -> Self { Self { - format: None, - args: None, + format: String::new(), + args: Vec::new(), condition: Some(condition), when_true: Some(Box::new(when_true)), when_false: Some(Box::new(when_false)), - match_value: Some(match_value.unwrap_or_else(|| "True".to_string())), + match_value: Some(match_value.into()), is_conditional: true, - handle: None, - client: None, } } - pub fn handle(&self) -> Option<&Handle> { - self.handle.as_ref() - } - - pub fn client(&self) -> Option<&Arc> { - self.client.as_ref() - } - pub fn to_json(&self) -> Value { - if let Some(ref handle) = self.handle { - return handle.to_json(); - } if self.is_conditional { return json!({ "$refExpr": { @@ -139,70 +106,13 @@ impl ReferenceExpression { } json!({ "$refExpr": { - "format": self.format.as_ref().unwrap(), - "args": self.args.as_ref().unwrap() + "format": self.format, + "args": self.args } }) } } -impl HasHandle for ReferenceExpression { - fn handle(&self) -> &Handle { - self.handle.as_ref().expect("ReferenceExpression is not in handle mode") - } -} - -impl Serialize for ReferenceExpression { - fn serialize(&self, serializer: S) -> Result { - self.to_json().serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for ReferenceExpression { - fn deserialize>(deserializer: D) -> Result { - let value = Value::deserialize(deserializer)?; - if let Some(ref_expr) = value.get("$refExpr") { - if let Some(condition) = ref_expr.get("condition") { - let when_true: ReferenceExpression = serde_json::from_value( - ref_expr.get("whenTrue").cloned().unwrap_or(Value::Null) - ).map_err(serde::de::Error::custom)?; - let when_false: ReferenceExpression = serde_json::from_value( - ref_expr.get("whenFalse").cloned().unwrap_or(Value::Null) - ).map_err(serde::de::Error::custom)?; - let match_value = ref_expr.get("matchValue") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - return Ok(ReferenceExpression::create_conditional( - condition.clone(), - match_value, - when_true, - when_false, - )); - } - let format = ref_expr.get("format") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let args = ref_expr.get("args") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - return Ok(ReferenceExpression::new(format, args)); - } - if value.get("$handle").is_some() { - let handle: Handle = serde_json::from_value(value) - .map_err(serde::de::Error::custom)?; - return Ok(Self { - format: None, args: None, - condition: None, when_true: None, when_false: None, - match_value: None, is_conditional: false, - handle: Some(handle), client: None, - }); - } - Err(serde::de::Error::custom("expected $refExpr or $handle")) - } -} - /// Convenience function to create a reference expression. pub fn ref_expr(format: impl Into, args: Vec) -> ReferenceExpression { ReferenceExpression::new(format, args) From 081599da67b7364649af1677846237e95efaf8b3 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 14:14:32 -0800 Subject: [PATCH 26/45] Rename Go factory to NewConditionalReferenceExpression Follow Go convention of New prefix for constructors, matching NewReferenceExpression and other factories in base.go. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go index a453554fe3a..41a8d92aed4 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go +++ b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go @@ -55,8 +55,8 @@ func NewReferenceExpression(format string, args ...any) *ReferenceExpression { return &ReferenceExpression{Format: format, Args: args} } -// CreateConditionalReferenceExpression creates a conditional reference expression from its parts. -func CreateConditionalReferenceExpression(condition any, matchValue string, whenTrue *ReferenceExpression, whenFalse *ReferenceExpression) *ReferenceExpression { +// NewConditionalReferenceExpression creates a conditional reference expression from its parts. +func NewConditionalReferenceExpression(condition any, matchValue string, whenTrue *ReferenceExpression, whenFalse *ReferenceExpression) *ReferenceExpression { if matchValue == "" { matchValue = "True" } From 03655e5aedca5c7018ccf8767a7729c908bdce1c Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 14:20:32 -0800 Subject: [PATCH 27/45] Wrap format and args in Option in Rust ReferenceExpression Conditional mode doesn't use format/args, so Option properly expresses the two modes as a discriminated union rather than using empty defaults. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resources/base.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs index cd6f11c2f74..6ac76c206c6 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs @@ -53,8 +53,10 @@ impl ResourceBuilderBase { /// Supports value mode (format + args) and conditional mode (condition + whenTrue + whenFalse). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReferenceExpression { - pub format: String, - pub args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] condition: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -70,8 +72,8 @@ pub struct ReferenceExpression { impl ReferenceExpression { pub fn new(format: impl Into, args: Vec) -> Self { Self { - format: format.into(), - args, + format: Some(format.into()), + args: Some(args), condition: None, when_true: None, when_false: None, @@ -83,8 +85,8 @@ impl ReferenceExpression { /// Creates a conditional reference expression from its parts. pub fn create_conditional(condition: Value, match_value: impl Into, when_true: ReferenceExpression, when_false: ReferenceExpression) -> Self { Self { - format: String::new(), - args: Vec::new(), + format: None, + args: None, condition: Some(condition), when_true: Some(Box::new(when_true)), when_false: Some(Box::new(when_false)), @@ -106,8 +108,8 @@ impl ReferenceExpression { } json!({ "$refExpr": { - "format": self.format, - "args": self.args + "format": self.format.as_deref().unwrap_or_default(), + "args": self.args.as_deref().unwrap_or_default() } }) } From 1188db2dff0603f919da222fde2c5fc64c93cee9 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 14:44:31 -0800 Subject: [PATCH 28/45] Revert ReferenceExpressionTypeId skip blocks from Python and TypeScript codegen These were added as part of handle mode but were not present on upstream/release/13.2. Removing them restores the original codegen behavior for these languages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AtsPythonCodeGenerator.cs | 10 ---------- .../AtsTypeScriptCodeGenerator.cs | 10 ---------- .../Snapshots/AtsGeneratedAspire.verified.py | 7 +++++++ .../TwoPassScanningGeneratedAspire.verified.py | 7 +++++++ 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs index d001a9ee14c..7d995766203 100644 --- a/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs @@ -203,11 +203,6 @@ private void GenerateHandleTypes( foreach (var handleType in handleTypes.OrderBy(t => t.ClassName, StringComparer.Ordinal)) { - // Skip types defined in base.py (ReferenceExpression) - if (handleType.TypeId == AtsConstants.ReferenceExpressionTypeId) - { - continue; - } var baseClass = handleType.IsResourceBuilder ? "ResourceBuilderBase" : "HandleWrapperBase"; WriteLine($"class {handleType.ClassName}({baseClass}):"); WriteLine(" def __init__(self, handle: Handle, client: AspireClient):"); @@ -362,11 +357,6 @@ private void GenerateHandleWrapperRegistrations( foreach (var handleType in handleTypes) { - // Skip types defined in base.py - if (handleType.TypeId == AtsConstants.ReferenceExpressionTypeId) - { - continue; - } WriteLine($"register_handle_wrapper(\"{handleType.TypeId}\", lambda handle, client: {handleType.ClassName}(handle, client))"); } diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs index 8546030bfc9..877bb64782f 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs @@ -456,13 +456,8 @@ private string GenerateAspireSdk(AtsContext context) GenerateOptionsInterfaces(); // Generate type classes (context types and wrapper types) - // Skip types defined in base.ts (ReferenceExpression) foreach (var typeClass in typeClasses) { - if (typeClass.TypeId == AtsConstants.ReferenceExpressionTypeId) - { - continue; - } GenerateTypeClass(typeClass); } @@ -1495,13 +1490,8 @@ private void GenerateHandleWrapperRegistrations(List typeClasses, WriteLine("// Register wrapper factories for typed handle wrapping in callbacks"); // Register type classes (context types like EnvironmentCallbackContext) - // Skip types defined in base.ts (their wrapper registration is handled differently) foreach (var typeClass in typeClasses) { - if (typeClass.TypeId == AtsConstants.ReferenceExpressionTypeId) - { - continue; - } var className = _wrapperClassNames.GetValueOrDefault(typeClass.TypeId) ?? DeriveClassName(typeClass.TypeId); var handleType = GetHandleTypeName(typeClass.TypeId); WriteLine($"registerHandleWrapper('{typeClass.TypeId}', (handle, client) => new {className}(handle as {handleType}, client));"); diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py index 6ead5536299..d84dd4677a6 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py @@ -119,6 +119,12 @@ def __init__(self, handle: Handle, client: AspireClient): pass +class ReferenceExpression(HandleWrapperBase): + def __init__(self, handle: Handle, client: AspireClient): + super().__init__(handle, client) + + pass + class TestCallbackContext(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -703,6 +709,7 @@ def with_vault_direct(self, option: str) -> ITestVaultResource: register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestVaultResource", lambda handle, client: TestVaultResource(handle, client)) register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.ITestVaultResource", lambda handle, client: ITestVaultResource(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", lambda handle, client: IDistributedApplicationBuilder(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression", lambda handle, client: ReferenceExpression(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", lambda handle, client: IResourceWithEnvironment(handle, client)) register_handle_wrapper("Aspire.Hosting/List", lambda handle, client: AspireList(handle, client)) register_handle_wrapper("Aspire.Hosting/Dict", lambda handle, client: AspireDict(handle, client)) diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 6855a178610..6311cff6bdf 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1935,6 +1935,12 @@ def with_cancellable_operation(self, operation: Callable[[CancellationToken], No return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withCancellableOperation", args) +class ReferenceExpression(HandleWrapperBase): + def __init__(self, handle: Handle, client: AspireClient): + super().__init__(handle, client) + + pass + class ResourceLoggerService(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -3524,6 +3530,7 @@ def __init__(self, handle: Handle, client: AspireClient): register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", lambda handle, client: IDistributedApplicationBuilder(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplication", lambda handle, client: DistributedApplication(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference", lambda handle, client: EndpointReference(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression", lambda handle, client: ReferenceExpression(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", lambda handle, client: IResource(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", lambda handle, client: IResourceWithEnvironment(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", lambda handle, client: IResourceWithEndpoints(handle, client)) From 3d867a68d87c5bc933947c6bde9c55aafc39f961 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 14:48:36 -0800 Subject: [PATCH 29/45] Revert transport.go Handle.ToJSON return type to map[string]string The map[string]any change was only needed for the handle mode branch in ReferenceExpression.ToJSON which has been reverted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.CodeGeneration.Go/Resources/transport.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.CodeGeneration.Go/Resources/transport.go b/src/Aspire.Hosting.CodeGeneration.Go/Resources/transport.go index 800f8e77afb..79e8c8c5d27 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/Resources/transport.go +++ b/src/Aspire.Hosting.CodeGeneration.Go/Resources/transport.go @@ -54,8 +54,8 @@ type Handle struct { } // ToJSON returns the handle as a JSON-serializable map. -func (h *Handle) ToJSON() map[string]any { - return map[string]any{ +func (h *Handle) ToJSON() map[string]string { + return map[string]string{ "$handle": h.HandleID, "$type": h.TypeID, } From 1cc9f31cb9210349a042a22e1cc67e103a35f301 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 14:56:54 -0800 Subject: [PATCH 30/45] Remove low-value ConditionalReferenceExpression tests Remove redundant GetValueAsync_WithContext_PassesContextToBranch (duplicate of true-branch test) and three Constructor_ThrowsOnNull tests (boilerplate ArgumentNullException guards). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ConditionalReferenceExpressionTests.cs | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs b/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs index 7a2a2f34ae3..050617de3d7 100644 --- a/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs +++ b/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs @@ -46,19 +46,6 @@ public void ValueExpression_ReturnsManifestParameterReference() Assert.EndsWith(".connectionString}", expr.ValueExpression); } - [Fact] - public async Task GetValueAsync_WithContext_PassesContextToBranch() - { - var condition = new TestValueProvider(bool.TrueString); - var whenTrue = ReferenceExpression.Create($"true-value"); - var whenFalse = ReferenceExpression.Create($"false-value"); - - var expr = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); - - var value = await expr.GetValueAsync(new(), default); - Assert.Equal("true-value", value); - } - [Fact] public async Task ConditionalReferenceExpression_WorksInReferenceExpressionBuilder() { @@ -101,27 +88,6 @@ public void References_IncludesConditionAndBranchReferences() Assert.Contains(endpointRef, references); } - [Fact] - public void Constructor_ThrowsOnNullCondition() - { - Assert.Throws(() => - ReferenceExpression.CreateConditional(null!, bool.TrueString, ReferenceExpression.Empty, ReferenceExpression.Empty)); - } - - [Fact] - public void Constructor_ThrowsOnNullWhenTrue() - { - Assert.Throws(() => - ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), bool.TrueString, null!, ReferenceExpression.Empty)); - } - - [Fact] - public void Constructor_ThrowsOnNullWhenFalse() - { - Assert.Throws(() => - ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), bool.TrueString, ReferenceExpression.Empty, null!)); - } - [Fact] public void Name_IsAutoGeneratedFromCondition() { From 3ad5a250468ca507f5b4e50c17a5083d25d1aa93 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 15:22:05 -0800 Subject: [PATCH 31/45] Fix TypeScript base.ts: import wrapIfHandle for conditional mode The conditional toJSON path calls wrapIfHandle but it was only re-exported, not imported for local use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts index bfe098a22d7..521fcf592d2 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts @@ -1,5 +1,5 @@ // aspire.ts - Core Aspire types: base classes, ReferenceExpression -import { Handle, AspireClient, MarshalledHandle } from './transport.js'; +import { Handle, AspireClient, MarshalledHandle, wrapIfHandle } from './transport.js'; // Re-export transport types for convenience export { Handle, AspireClient, CapabilityError, registerCallback, unregisterCallback, registerCancellation, unregisterCancellation } from './transport.js'; From bf5a74006365542b0e97d734fcee1ec1e2021683 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 15:39:54 -0800 Subject: [PATCH 32/45] Fix incorrect use of wrapIfHandle in ReferenceExpression serialization Replace wrapIfHandle (a deserialization helper) with proper serialization logic in ReferenceExpression.toJSON() for the condition field. Remove the now-unnecessary import. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resources/base.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts index 521fcf592d2..9a3427e7e72 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts @@ -1,5 +1,5 @@ // aspire.ts - Core Aspire types: base classes, ReferenceExpression -import { Handle, AspireClient, MarshalledHandle, wrapIfHandle } from './transport.js'; +import { Handle, AspireClient, MarshalledHandle } from './transport.js'; // Re-export transport types for convenience export { Handle, AspireClient, CapabilityError, registerCallback, unregisterCallback, registerCancellation, unregisterCancellation } from './transport.js'; @@ -138,7 +138,7 @@ export class ReferenceExpression { if (this.isConditional) { return { $expr: { - condition: wrapIfHandle(this._condition), + condition: this._condition instanceof Handle ? this._condition.toJSON() : this._condition, whenTrue: this._whenTrue!.toJSON(), whenFalse: this._whenFalse!.toJSON(), matchValue: this._matchValue! From abbd8680743daef21e4bdb8b57859030f022def5 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 16:30:28 -0800 Subject: [PATCH 33/45] Disable HTTPS certificate for Redis in EndToEnd test AppHost The EndToEnd tests are incompatible with TLS-enabled Redis. Opt out of the developer certificate for the Redis resource in TestProgram. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/testproject/TestProject.AppHost/TestProgram.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/testproject/TestProject.AppHost/TestProgram.cs b/tests/testproject/TestProject.AppHost/TestProgram.cs index 65ec8d7a2f4..12216a2fb34 100644 --- a/tests/testproject/TestProject.AppHost/TestProgram.cs +++ b/tests/testproject/TestProject.AppHost/TestProgram.cs @@ -1,6 +1,8 @@ // 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 ASPIRECERTIFICATES001 // WithoutHttpsCertificate is experimental + using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; @@ -83,7 +85,8 @@ private TestProgram( if (!resourcesToSkip.HasFlag(TestResourceNames.redis)) { var redis = AppBuilder.AddRedis($"{testPrefix}redis") - .WithImageRegistry(AspireTestContainerRegistry); + .WithImageRegistry(AspireTestContainerRegistry) + .WithoutHttpsCertificate(); IntegrationServiceABuilder = IntegrationServiceABuilder.WithReference(redis); } if (!resourcesToSkip.HasFlag(TestResourceNames.postgres) || !resourcesToSkip.HasFlag(TestResourceNames.efnpgsql)) From 75b7b8c0309e5b5a79f34ba5756f449b95d7ef5b Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 17:30:31 -0800 Subject: [PATCH 34/45] Add conditional ReferenceExpression support to App Service, Docker Compose, and Kubernetes publish contexts - Azure App Service: Handle conditional expressions with Bicep ternary fallback for parameter-based conditions - Docker Compose: Convert ProcessValue to async, resolve conditions statically at generation time via GetValueAsync - Kubernetes: Resolve conditions via GetValueAsync with ValueProviderContext at generation time - Add tests with both static and parameter-based conditions for all three contexts with Verify snapshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureAppServiceWebsiteContext.cs | 37 ++++++ .../DockerComposePublishingContext.cs | 2 +- .../DockerComposeServiceResource.cs | 10 +- .../DockerComposeServiceResourceExtensions.cs | 19 ++- .../KubernetesResource.cs | 14 ++ .../AzureAppServiceTests.cs | 42 ++++++ ...ssionWithParameterCondition.verified.bicep | 124 ++++++++++++++++++ ...essionWithParameterCondition.verified.json | 16 +++ .../DockerComposePublisherTests.cs | 86 ++++++++++++ ...nditionalReferenceExpression.verified.yaml | 21 +++ ...essionWithParameterCondition.verified.yaml | 20 +++ .../KubernetesPublisherTests.cs | 124 ++++++++++++++++++ ...tionalReferenceExpression#00.verified.yaml | 11 ++ ...tionalReferenceExpression#01.verified.yaml | 6 + ...tionalReferenceExpression#02.verified.yaml | 36 +++++ ...tionalReferenceExpression#03.verified.yaml | 12 ++ ...ionWithParameterCondition#00.verified.yaml | 11 ++ ...ionWithParameterCondition#01.verified.yaml | 5 + ...ionWithParameterCondition#02.verified.yaml | 36 +++++ ...ionWithParameterCondition#03.verified.yaml | 11 ++ 20 files changed, 634 insertions(+), 9 deletions(-) create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.json create mode 100644 tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_HandlesConditionalReferenceExpression.verified.yaml create mode 100644 tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#00.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#01.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#02.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#03.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#00.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#01.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#02.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs index 889a8b9fd83..505ead5c9f4 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs @@ -196,6 +196,43 @@ private void ProcessEndpoints() if (value is ReferenceExpression expr) { + // Handle conditional expressions + if (expr.IsConditional) + { + var (conditionVal, _) = ProcessValue(expr.Condition!, secretType, parent: expr, isSlot); + + // If the condition resolves to a static string, evaluate at publish time + string? staticCondition = conditionVal is string str ? str : null; + if (staticCondition is null && conditionVal is BicepValue bv + && bv.Compile() is StringLiteralExpression sle) + { + staticCondition = sle.Value; + } + + if (staticCondition is not null) + { + var branch = string.Equals(staticCondition, expr.MatchValue, StringComparison.OrdinalIgnoreCase) + ? expr.WhenTrue! + : expr.WhenFalse!; + return ProcessValue(branch, secretType, parent: parent, isSlot); + } + + // Condition is a Bicep parameter/output — emit a ternary expression + var (whenTrueVal, trueSecret) = ProcessValue(expr.WhenTrue!, secretType, parent: expr, isSlot); + var (whenFalseVal, falseSecret) = ProcessValue(expr.WhenFalse!, secretType, parent: expr, isSlot); + + var conditional = new ConditionalExpression( + new BinaryExpression(ResolveValue(conditionVal).Compile(), BinaryBicepOperator.Equal, new StringLiteralExpression(expr.MatchValue!)), + ResolveValue(whenTrueVal).Compile(), + ResolveValue(whenFalseVal).Compile()); + + var finalSecret = trueSecret != SecretType.None || falseSecret != SecretType.None + ? SecretType.Normal + : SecretType.None; + + return (new BicepValue(conditional), finalSecret); + } + if (expr.Format == "{0}" && expr.ValueProviders.Count == 1) { var val = ProcessValue(expr.ValueProviders[0], secretType, parent: parent, isSlot); diff --git a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs index 5fe5dbbd75d..b39d051a74d 100644 --- a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs +++ b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs @@ -100,7 +100,7 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod File.Copy(dockerfileBuildAnnotation.DockerfilePath, resourceDockerfilePath, overwrite: true); } - var composeService = serviceResource.BuildComposeService(); + var composeService = await serviceResource.BuildComposeServiceAsync().ConfigureAwait(false); HandleComposeFileVolumes(serviceResource, composeFile); diff --git a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs index 2373f0908d0..bed9a2858ce 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs @@ -110,7 +110,7 @@ internal record struct EndpointMapping( /// public DockerComposeEnvironmentResource Parent => _composeEnvironmentResource; - internal Service BuildComposeService() + internal async Task BuildComposeServiceAsync() { var composeService = new Service { @@ -125,7 +125,7 @@ internal Service BuildComposeService() SetContainerName(composeService); SetEntryPoint(composeService); SetPullPolicy(composeService); - AddEnvironmentVariablesAndCommandLineArgs(composeService); + await AddEnvironmentVariablesAndCommandLineArgsAsync(composeService).ConfigureAwait(false); AddPorts(composeService); AddVolumes(composeService); SetDependsOn(composeService); @@ -215,13 +215,13 @@ private static void SetContainerImage(string? containerImageName, Service compos } } - private void AddEnvironmentVariablesAndCommandLineArgs(Service composeService) + private async Task AddEnvironmentVariablesAndCommandLineArgsAsync(Service composeService) { var env = new Dictionary(); foreach (var kv in EnvironmentVariables) { - var value = this.ProcessValue(kv.Value); + var value = await this.ProcessValueAsync(kv.Value).ConfigureAwait(false); env[kv.Key] = value?.ToString() ?? string.Empty; } @@ -238,7 +238,7 @@ private void AddEnvironmentVariablesAndCommandLineArgs(Service composeService) foreach (var arg in Args) { - var value = this.ProcessValue(arg); + var value = await this.ProcessValueAsync(arg).ConfigureAwait(false); if (value is not string str) { diff --git a/src/Aspire.Hosting.Docker/DockerComposeServiceResourceExtensions.cs b/src/Aspire.Hosting.Docker/DockerComposeServiceResourceExtensions.cs index e0d9bef389a..910c117b7e6 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeServiceResourceExtensions.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeServiceResourceExtensions.cs @@ -8,7 +8,7 @@ internal static class DockerComposeServiceResourceExtensions { - internal static object ProcessValue(this DockerComposeServiceResource resource, object value) + internal static async Task ProcessValueAsync(this DockerComposeServiceResource resource, object value) { while (true) { @@ -58,9 +58,22 @@ internal static object ProcessValue(this DockerComposeServiceResource resource, if (value is ReferenceExpression expr) { + // Handle conditional expressions by resolving the condition to its + // actual value. Docker Compose YAML cannot represent conditionals, so + // the branch must be selected at generation time. + if (expr.IsConditional) + { + var conditionStr = await expr.Condition!.GetValueAsync(default).ConfigureAwait(false); + + var branch = string.Equals(conditionStr, expr.MatchValue, StringComparison.OrdinalIgnoreCase) + ? expr.WhenTrue! + : expr.WhenFalse!; + return await resource.ProcessValueAsync(branch).ConfigureAwait(false); + } + if (expr is { Format: "{0}", ValueProviders.Count: 1 }) { - return resource.ProcessValue(expr.ValueProviders[0]).ToString() ?? string.Empty; + return (await resource.ProcessValueAsync(expr.ValueProviders[0]).ConfigureAwait(false)).ToString() ?? string.Empty; } var args = new object[expr.ValueProviders.Count]; @@ -68,7 +81,7 @@ internal static object ProcessValue(this DockerComposeServiceResource resource, foreach (var vp in expr.ValueProviders) { - var val = resource.ProcessValue(vp); + var val = await resource.ProcessValueAsync(vp).ConfigureAwait(false); args[index++] = val ?? throw new InvalidOperationException("Value is null"); } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index c7615693728..c72725de2fc 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -429,6 +429,20 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex if (value is ReferenceExpression expr) { + // Handle conditional expressions by resolving the condition to its + // actual value. Kubernetes YAML cannot represent conditionals, so + // the branch must be selected at generation time. + if (expr.IsConditional) + { + var conditionContext = new ValueProviderContext { ExecutionContext = executionContext }; + var conditionStr = await expr.Condition!.GetValueAsync(conditionContext, default).ConfigureAwait(false); + + var branch = string.Equals(conditionStr, expr.MatchValue, StringComparison.OrdinalIgnoreCase) + ? expr.WhenTrue! + : expr.WhenFalse!; + return await ProcessValueAsync(context, executionContext, branch, embedded).ConfigureAwait(false); + } + if (expr is { Format: "{0}", ValueProviders.Count: 1 }) { return (await ProcessValueAsync(context, executionContext, expr.ValueProviders[0], true).ConfigureAwait(false)).ToString() ?? string.Empty; diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs index 67041919ce8..3c50a85bccc 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs @@ -944,6 +944,48 @@ await Verify(manifest.ToString(), "json") .AppendContentAsFile(bicep, "bicep"); } + [Fact] + public async Task ConditionalExpressionWithParameterCondition() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureAppServiceEnvironment("env"); + + var featureFlag = builder.AddParameter("enable-feature"); + + var project = builder.AddProject("api", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints(); + + project.WithEnvironment(context => + { + var conditional = ReferenceExpression.CreateConditional( + featureFlag.Resource, + bool.TrueString, + ReferenceExpression.Create($"enabled"), + ReferenceExpression.Create($"disabled")); + + context.EnvironmentVariables["FEATURE_MODE"] = conditional; + }); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + var proj = Assert.Single(model.GetProjectResources()); + + proj.TryGetLastAnnotation(out var target); + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await GetManifestWithBicep(resource); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); + } + private static Task<(JsonNode ManifestNode, string BicepText)> GetManifestWithBicep(IResource resource) => AzureManifestUtils.GetManifestWithBicep(resource, skipPreparer: true); diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.bicep new file mode 100644 index 00000000000..0ed13a0b450 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.bicep @@ -0,0 +1,124 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_planid string + +param env_outputs_azure_container_registry_managed_identity_id string + +param env_outputs_azure_container_registry_managed_identity_client_id string + +param api_containerimage string + +param api_containerport string + +param enable_feature_value string + +param env_outputs_azure_app_service_dashboard_uri string + +param env_outputs_azure_website_contributor_managed_identity_id string + +param env_outputs_azure_website_contributor_managed_identity_principal_id string + +resource mainContainer 'Microsoft.Web/sites/sitecontainers@2025-03-01' = { + name: 'main' + properties: { + authType: 'UserAssigned' + image: api_containerimage + isMain: true + targetPort: api_containerport + userManagedIdentityClientId: env_outputs_azure_container_registry_managed_identity_client_id + } + parent: webapp +} + +resource webapp 'Microsoft.Web/sites@2025-03-01' = { + name: take('${toLower('api')}-${uniqueString(resourceGroup().id)}', 60) + location: location + properties: { + serverFarmId: env_outputs_planid + siteConfig: { + numberOfWorkers: 30 + linuxFxVersion: 'SITECONTAINERS' + acrUseManagedIdentityCreds: true + acrUserManagedIdentityID: env_outputs_azure_container_registry_managed_identity_client_id + appSettings: [ + { + name: 'WEBSITES_PORT' + value: api_containerport + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'HTTP_PORTS' + value: api_containerport + } + { + name: 'FEATURE_MODE' + value: (enable_feature_value == 'True') ? 'enabled' : 'disabled' + } + { + name: 'ASPIRE_ENVIRONMENT_NAME' + value: 'env' + } + { + name: 'OTEL_SERVICE_NAME' + value: 'api' + } + { + name: 'OTEL_EXPORTER_OTLP_PROTOCOL' + value: 'grpc' + } + { + name: 'OTEL_EXPORTER_OTLP_ENDPOINT' + value: 'http://localhost:6001' + } + { + name: 'WEBSITE_ENABLE_ASPIRE_OTEL_SIDECAR' + value: 'true' + } + { + name: 'OTEL_COLLECTOR_URL' + value: env_outputs_azure_app_service_dashboard_uri + } + { + name: 'OTEL_CLIENT_ID' + value: env_outputs_azure_container_registry_managed_identity_client_id + } + ] + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} + +resource api_website_ra 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(webapp.id, env_outputs_azure_website_contributor_managed_identity_id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'de139f84-1756-47ae-9be6-808fbbe84772')) + properties: { + principalId: env_outputs_azure_website_contributor_managed_identity_principal_id + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'de139f84-1756-47ae-9be6-808fbbe84772') + principalType: 'ServicePrincipal' + } + scope: webapp +} + +resource slotConfigNames 'Microsoft.Web/sites/config@2025-03-01' = { + name: 'slotConfigNames' + properties: { + appSettingNames: [ + 'OTEL_SERVICE_NAME' + ] + } + parent: webapp +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.json new file mode 100644 index 00000000000..11aa3a48d0f --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.json @@ -0,0 +1,16 @@ +{ + "type": "azure.bicep.v0", + "path": "api-website.module.bicep", + "params": { + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_planid": "{env.outputs.planId}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "env_outputs_azure_container_registry_managed_identity_client_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID}", + "api_containerimage": "{api.containerImage}", + "api_containerport": "{api.containerPort}", + "enable_feature_value": "{enable-feature.value}", + "env_outputs_azure_app_service_dashboard_uri": "{env.outputs.AZURE_APP_SERVICE_DASHBOARD_URI}", + "env_outputs_azure_website_contributor_managed_identity_id": "{env.outputs.AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID}", + "env_outputs_azure_website_contributor_managed_identity_principal_id": "{env.outputs.AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_PRINCIPAL_ID}" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index df16145ff8d..71a63d8c96a 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -923,6 +923,92 @@ await Verify(File.ReadAllText(composePath), "yaml") .AppendContentAsFile(File.ReadAllText(envPath), "env"); } + [Fact] + public async Task PublishAsync_HandlesConditionalReferenceExpression() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + + builder.Services.AddSingleton(); + + builder.AddDockerComposeEnvironment("docker-compose"); + + var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0") + .WithEnvironment(context => + { + // Simulate a conditional expression like TLS-enabled connection strings produce. + // The condition evaluates statically at publish time. + var conditional = ReferenceExpression.CreateConditional( + new TestConditionProvider(bool.TrueString), + bool.TrueString, + ReferenceExpression.Create($",ssl=true"), + ReferenceExpression.Empty); + + context.EnvironmentVariables["TLS_SUFFIX"] = conditional; + + var conditionalFalse = ReferenceExpression.CreateConditional( + new TestConditionProvider(bool.FalseString), + bool.TrueString, + ReferenceExpression.Create($",ssl=true"), + ReferenceExpression.Create($",ssl=false")); + + context.EnvironmentVariables["TLS_SUFFIX_FALSE"] = conditionalFalse; + }); + + var app = builder.Build(); + app.Run(); + + var composePath = Path.Combine(tempDir.Path, "docker-compose.yaml"); + Assert.True(File.Exists(composePath)); + + await Verify(File.ReadAllText(composePath), "yaml"); + } + + [Fact] + public async Task PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + + builder.Services.AddSingleton(); + + builder.AddDockerComposeEnvironment("docker-compose"); + + // Use a real ParameterResource as the condition with a known default value. + var enableTls = builder.AddParameter("enable-tls", "True", publishValueAsDefault: true); + + var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0") + .WithEnvironment(context => + { + var conditional = ReferenceExpression.CreateConditional( + enableTls.Resource, + bool.TrueString, + ReferenceExpression.Create($",ssl=true"), + ReferenceExpression.Create($",ssl=false")); + + context.EnvironmentVariables["TLS_SUFFIX"] = conditional; + }); + + var app = builder.Build(); + app.Run(); + + var composePath = Path.Combine(tempDir.Path, "docker-compose.yaml"); + Assert.True(File.Exists(composePath)); + + await Verify(File.ReadAllText(composePath), "yaml"); + } + + private sealed class TestConditionProvider(string value) : IValueProvider, IManifestExpressionProvider + { + public string ValueExpression => "test-condition"; + + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + => new(value); + + public ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default) + => new(value); + } + private sealed class MockImageBuilder : IResourceContainerImageManager { public bool BuildImageCalled { get; private set; } diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_HandlesConditionalReferenceExpression.verified.yaml b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_HandlesConditionalReferenceExpression.verified.yaml new file mode 100644 index 00000000000..8205be4416e --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_HandlesConditionalReferenceExpression.verified.yaml @@ -0,0 +1,21 @@ +services: + docker-compose-dashboard: + image: "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest" + ports: + - "18888" + expose: + - "18889" + - "18890" + networks: + - "aspire" + restart: "always" + myapp: + image: "mcr.microsoft.com/dotnet/aspnet:8.0" + environment: + TLS_SUFFIX: ",ssl=true" + TLS_SUFFIX_FALSE: ",ssl=false" + networks: + - "aspire" +networks: + aspire: + driver: "bridge" diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition.verified.yaml b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition.verified.yaml new file mode 100644 index 00000000000..ed3d93934c4 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition.verified.yaml @@ -0,0 +1,20 @@ +services: + docker-compose-dashboard: + image: "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest" + ports: + - "18888" + expose: + - "18889" + - "18890" + networks: + - "aspire" + restart: "always" + myapp: + image: "mcr.microsoft.com/dotnet/aspnet:8.0" + environment: + TLS_SUFFIX: ",ssl=true" + networks: + - "aspire" +networks: + aspire: + driver: "bridge" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 654b8cf29b4..324e63d94df 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -449,6 +449,130 @@ public async Task KubernetesMapsPortsForBaitAndSwitchResources() await settingsTask; } + [Fact] + public async Task PublishAsync_HandlesConditionalReferenceExpression() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + + builder.AddKubernetesEnvironment("env"); + + var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0") + .WithEnvironment(context => + { + var conditional = ReferenceExpression.CreateConditional( + new TestConditionProvider(bool.TrueString), + bool.TrueString, + ReferenceExpression.Create($",ssl=true"), + ReferenceExpression.Empty); + + context.EnvironmentVariables["TLS_SUFFIX"] = conditional; + + var conditionalFalse = ReferenceExpression.CreateConditional( + new TestConditionProvider(bool.FalseString), + bool.TrueString, + ReferenceExpression.Create($",ssl=true"), + ReferenceExpression.Create($",ssl=false")); + + context.EnvironmentVariables["TLS_SUFFIX_FALSE"] = conditionalFalse; + }); + + var app = builder.Build(); + app.Run(); + + var expectedFiles = new[] + { + "Chart.yaml", + "values.yaml", + "templates/myapp/deployment.yaml", + "templates/myapp/config.yaml", + }; + + SettingsTask settingsTask = default!; + + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(tempDir.Path, expectedFile); + var fileExtension = Path.GetExtension(filePath)[1..]; + + if (settingsTask is null) + { + settingsTask = Verify(File.ReadAllText(filePath), fileExtension); + } + else + { + settingsTask = settingsTask.AppendContentAsFile(File.ReadAllText(filePath), fileExtension); + } + } + + await settingsTask; + } + + [Fact] + public async Task PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + + builder.AddKubernetesEnvironment("env"); + + // Use a real ParameterResource as the condition with a known default value. + var enableTls = builder.AddParameter("enable-tls", "True", publishValueAsDefault: true); + + var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0") + .WithEnvironment(context => + { + var conditional = ReferenceExpression.CreateConditional( + enableTls.Resource, + bool.TrueString, + ReferenceExpression.Create($",ssl=true"), + ReferenceExpression.Create($",ssl=false")); + + context.EnvironmentVariables["TLS_SUFFIX"] = conditional; + }); + + var app = builder.Build(); + app.Run(); + + var expectedFiles = new[] + { + "Chart.yaml", + "values.yaml", + "templates/myapp/deployment.yaml", + "templates/myapp/config.yaml", + }; + + SettingsTask settingsTask = default!; + + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(tempDir.Path, expectedFile); + var fileExtension = Path.GetExtension(filePath)[1..]; + + if (settingsTask is null) + { + settingsTask = Verify(File.ReadAllText(filePath), fileExtension); + } + else + { + settingsTask = settingsTask.AppendContentAsFile(File.ReadAllText(filePath), fileExtension); + } + } + + await settingsTask; + } + + private sealed class TestConditionProvider(string value) : IValueProvider, IManifestExpressionProvider + { + public string ValueExpression => "test-condition"; + + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + => new(value); + + public ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default) + => new(value); + } + private sealed class TestProject : IProjectMetadata { public string ProjectPath => "another-path"; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#00.verified.yaml new file mode 100644 index 00000000000..d05c0dbf228 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#00.verified.yaml @@ -0,0 +1,11 @@ +apiVersion: "v2" +name: "aspire-hosting-tests" +version: "0.1.0" +kubeVersion: ">= 1.18.0-0" +description: "Aspire Helm Chart" +type: "application" +keywords: + - "aspire" + - "kubernetes" +appVersion: "0.1.0" +deprecated: false diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#01.verified.yaml new file mode 100644 index 00000000000..f4430ffea9f --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#01.verified.yaml @@ -0,0 +1,6 @@ +parameters: {} +secrets: {} +config: + myapp: + TLS_SUFFIX: ",ssl=true" + TLS_SUFFIX_FALSE: ",ssl=false" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#02.verified.yaml new file mode 100644 index 00000000000..37de00c4947 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#02.verified.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + name: "myapp-deployment" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "mcr.microsoft.com/dotnet/aspnet:8.0" + name: "myapp" + envFrom: + - configMapRef: + name: "myapp-config" + imagePullPolicy: "IfNotPresent" + selector: + matchLabels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#03.verified.yaml new file mode 100644 index 00000000000..89a6179e7e0 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpression#03.verified.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "myapp-config" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + TLS_SUFFIX: "{{ .Values.config.myapp.TLS_SUFFIX }}" + TLS_SUFFIX_FALSE: "{{ .Values.config.myapp.TLS_SUFFIX_FALSE }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#00.verified.yaml new file mode 100644 index 00000000000..d05c0dbf228 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#00.verified.yaml @@ -0,0 +1,11 @@ +apiVersion: "v2" +name: "aspire-hosting-tests" +version: "0.1.0" +kubeVersion: ">= 1.18.0-0" +description: "Aspire Helm Chart" +type: "application" +keywords: + - "aspire" + - "kubernetes" +appVersion: "0.1.0" +deprecated: false diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#01.verified.yaml new file mode 100644 index 00000000000..b477f8b137c --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#01.verified.yaml @@ -0,0 +1,5 @@ +parameters: {} +secrets: {} +config: + myapp: + TLS_SUFFIX: ",ssl=true" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#02.verified.yaml new file mode 100644 index 00000000000..37de00c4947 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#02.verified.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + name: "myapp-deployment" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "mcr.microsoft.com/dotnet/aspnet:8.0" + name: "myapp" + envFrom: + - configMapRef: + name: "myapp-config" + imagePullPolicy: "IfNotPresent" + selector: + matchLabels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml new file mode 100644 index 00000000000..eae6102e6fd --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "myapp-config" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + TLS_SUFFIX: "{{ .Values.config.myapp.TLS_SUFFIX }}" From 09d773b0d29d44708f11521ee82c8597c82bb349 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 17:50:37 -0800 Subject: [PATCH 35/45] Use Helm ternary for parameter-based conditionals in Kubernetes publisher When a conditional ReferenceExpression's condition is a ParameterResource, emit a Helm ternary expression (e.g., {{ ternary "val1" "val2" (eq .Values.parameters.x "True") | quote }}) instead of resolving statically. This defers evaluation to helm install/upgrade time, allowing users to override the condition parameter in values.yaml. - Add BuildHelmTernary helper in KubernetesResource - Add HelmFlowControlPattern regex for ternary expression detection - Update ShouldDoubleQuoteString to render ternary as plain YAML - Non-parameter conditions still resolve statically at generation time Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions/HelmExtensions.cs | 20 ++++++- .../KubernetesResource.cs | 57 ++++++++++++++++++- ...ionWithParameterCondition#01.verified.yaml | 8 +-- ...ionWithParameterCondition#03.verified.yaml | 2 +- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs index f14c5c6475e..e4e4be36858 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs @@ -91,11 +91,25 @@ public static bool ContainsHelmValuesSecretExpression(this string value) public static (bool, ScalarStyle?) ShouldDoubleQuoteString(string value) { - var shouldApply = ScalarExpressionPattern().IsMatch(value) is false - || EndWithNonStringTypePattern().IsMatch(value) is false; - return (shouldApply, shouldApply is false ? ScalarStyle.ForcePlain : null); + if (!ScalarExpressionPattern().IsMatch(value)) + { + return (true, null); + } + + // Scalar Helm expressions that contain type conversions (| int, | float64, etc.) + // or flow control (ternary) must be rendered as plain (unquoted) YAML so that Helm + // can process them as template expressions without YAML escaping interference. + if (EndWithNonStringTypePattern().IsMatch(value) || HelmFlowControlPattern().IsMatch(value)) + { + return (false, ScalarStyle.ForcePlain); + } + + return (true, null); } + [GeneratedRegex(@"^\{\{\s*ternary\b")] + private static partial Regex HelmFlowControlPattern(); + [GeneratedRegex(@"\{\{[^}]*\|\s*(int|int64|float64)\s*\}\}")] private static partial Regex EndWithNonStringTypePattern(); diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index c72725de2fc..412818c39c4 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -429,11 +429,16 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex if (value is ReferenceExpression expr) { - // Handle conditional expressions by resolving the condition to its - // actual value. Kubernetes YAML cannot represent conditionals, so - // the branch must be selected at generation time. if (expr.IsConditional) { + // When the condition is a parameter, use Helm ternary to defer + // evaluation to helm install/upgrade time. + if (expr.Condition is ParameterResource conditionParam) + { + return await BuildHelmTernary(context, executionContext, expr, conditionParam, embedded).ConfigureAwait(false); + } + + // For non-parameter conditions, resolve statically at generation time. var conditionContext = new ValueProviderContext { ExecutionContext = executionContext }; var conditionStr = await expr.Condition!.GetValueAsync(conditionContext, default).ConfigureAwait(false); @@ -472,6 +477,52 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex } } + private async Task BuildHelmTernary(KubernetesEnvironmentContext context, DistributedApplicationExecutionContext executionContext, ReferenceExpression expr, ParameterResource conditionParam, bool embedded) + { + // Process both branches to see if they resolve to plain strings. + var whenTrueResult = await ProcessValueAsync(context, executionContext, expr.WhenTrue!, embedded).ConfigureAwait(false); + var whenFalseResult = await ProcessValueAsync(context, executionContext, expr.WhenFalse!, embedded).ConfigureAwait(false); + + // If either branch produced a HelmValue (e.g., references another parameter), + // fall back to static resolution since nested Helm expressions aren't supported. + if (whenTrueResult is HelmValue || whenFalseResult is HelmValue) + { + var conditionContext = new ValueProviderContext { ExecutionContext = executionContext }; + var conditionStr = await expr.Condition!.GetValueAsync(conditionContext, default).ConfigureAwait(false); + + var branch = string.Equals(conditionStr, expr.MatchValue, StringComparison.OrdinalIgnoreCase) + ? expr.WhenTrue! + : expr.WhenFalse!; + return await ProcessValueAsync(context, executionContext, branch, embedded).ConfigureAwait(false); + } + + // Allocate the condition parameter into values.yaml under the parameters section. + var formattedName = conditionParam.Name.ToHelmValuesSectionName(); + var paramExpression = formattedName.ToHelmParameterExpression(TargetResource.Name); + + if (!Parameters.ContainsKey(formattedName)) + { + Parameters[formattedName] = conditionParam.Default is null || conditionParam.Secret + ? new HelmValue(paramExpression, (string?)null) + : new HelmValue(paramExpression, conditionParam); + } + + // Extract the values path (e.g., .Values.parameters.myapp.enable_tls) from {{ expression }}. + var conditionPath = HelmExtensions.ScalarExpressionPattern().Match(paramExpression).Value.Trim(); + + var whenTrueStr = EscapeGoTemplateString(whenTrueResult.ToString() ?? string.Empty); + var whenFalseStr = EscapeGoTemplateString(whenFalseResult.ToString() ?? string.Empty); + var matchStr = EscapeGoTemplateString(expr.MatchValue ?? string.Empty); + + var helmExpression = $"ternary \"{whenTrueStr}\" \"{whenFalseStr}\" (eq {conditionPath} \"{matchStr}\") {HelmExtensions.PipelineDelimiter} quote" + .ToHelmExpression(); + + return HelmValue.Literal(helmExpression); + + static string EscapeGoTemplateString(string value) + => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + private static string GetEndpointValue(EndpointMapping mapping, EndpointProperty property, bool embedded = false) { var (scheme, _, host, port, _, _) = mapping; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#01.verified.yaml index b477f8b137c..0734dae1ada 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#01.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#01.verified.yaml @@ -1,5 +1,5 @@ -parameters: {} -secrets: {} -config: +parameters: myapp: - TLS_SUFFIX: ",ssl=true" + enable_tls: "True" +secrets: {} +config: {} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml index eae6102e6fd..58089925047 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml @@ -8,4 +8,4 @@ metadata: app.kubernetes.io/component: "myapp" app.kubernetes.io/instance: "{{ .Release.Name }}" data: - TLS_SUFFIX: "{{ .Values.config.myapp.TLS_SUFFIX }}" + TLS_SUFFIX: {{ ternary ",ssl=true" ",ssl=false" (eq .Values.parameters.myapp.enable_tls "True") | quote }} From 7a0097521050d0998a0247b38536612888b21683 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 18:22:55 -0800 Subject: [PATCH 36/45] Fix BuildHelmTernary fallback detection and add HelmExtensions tests The is HelmValue type check failed because ProcessValueAsync's Format=={0} optimization converts HelmValues to strings via ToString(). Changed to detect Helm expressions in string content using ContainsHelmExpression() instead. Added HelmExtensionsTests with Theory tests for ShouldDoubleQuoteString and HelmFlowControlPattern regex. Added fallback integration test verifying that conditionals with parameter branches fall back to static resolution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../KubernetesResource.cs | 21 ++++--- .../HelmExtensionsTests.cs | 43 ++++++++++++++ .../KubernetesPublisherTests.cs | 57 +++++++++++++++++++ ...llsBackToStaticResolution#00.verified.yaml | 11 ++++ ...llsBackToStaticResolution#01.verified.yaml | 3 + ...llsBackToStaticResolution#02.verified.yaml | 36 ++++++++++++ ...llsBackToStaticResolution#03.verified.yaml | 11 ++++ 7 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#00.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#01.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#02.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#03.verified.yaml diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index 412818c39c4..f8c8f28e918 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -479,13 +479,18 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex private async Task BuildHelmTernary(KubernetesEnvironmentContext context, DistributedApplicationExecutionContext executionContext, ReferenceExpression expr, ParameterResource conditionParam, bool embedded) { - // Process both branches to see if they resolve to plain strings. + // Process both branches to get their rendered values. var whenTrueResult = await ProcessValueAsync(context, executionContext, expr.WhenTrue!, embedded).ConfigureAwait(false); var whenFalseResult = await ProcessValueAsync(context, executionContext, expr.WhenFalse!, embedded).ConfigureAwait(false); - // If either branch produced a HelmValue (e.g., references another parameter), - // fall back to static resolution since nested Helm expressions aren't supported. - if (whenTrueResult is HelmValue || whenFalseResult is HelmValue) + var whenTrueStr = whenTrueResult.ToString() ?? string.Empty; + var whenFalseStr = whenFalseResult.ToString() ?? string.Empty; + + // If either branch resolved to a Helm expression (e.g., a parameter reference like + // {{ .Values.config.x }}), fall back to static resolution since nesting Helm + // expressions inside a ternary produces invalid Go templates. + if (whenTrueStr.ContainsHelmExpression() || whenFalseStr.ContainsHelmExpression() + || whenTrueResult is HelmValue || whenFalseResult is HelmValue) { var conditionContext = new ValueProviderContext { ExecutionContext = executionContext }; var conditionStr = await expr.Condition!.GetValueAsync(conditionContext, default).ConfigureAwait(false); @@ -510,11 +515,11 @@ private async Task BuildHelmTernary(KubernetesEnvironmentContext context // Extract the values path (e.g., .Values.parameters.myapp.enable_tls) from {{ expression }}. var conditionPath = HelmExtensions.ScalarExpressionPattern().Match(paramExpression).Value.Trim(); - var whenTrueStr = EscapeGoTemplateString(whenTrueResult.ToString() ?? string.Empty); - var whenFalseStr = EscapeGoTemplateString(whenFalseResult.ToString() ?? string.Empty); - var matchStr = EscapeGoTemplateString(expr.MatchValue ?? string.Empty); + var escapedTrue = EscapeGoTemplateString(whenTrueStr); + var escapedFalse = EscapeGoTemplateString(whenFalseStr); + var escapedMatch = EscapeGoTemplateString(expr.MatchValue ?? string.Empty); - var helmExpression = $"ternary \"{whenTrueStr}\" \"{whenFalseStr}\" (eq {conditionPath} \"{matchStr}\") {HelmExtensions.PipelineDelimiter} quote" + var helmExpression = $"ternary \"{escapedTrue}\" \"{escapedFalse}\" (eq {conditionPath} \"{escapedMatch}\") {HelmExtensions.PipelineDelimiter} quote" .ToHelmExpression(); return HelmValue.Literal(helmExpression); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs new file mode 100644 index 00000000000..7eb7c32b40e --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs @@ -0,0 +1,43 @@ +// 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.Kubernetes.Extensions; +using YamlDotNet.Core; + +namespace Aspire.Hosting.Kubernetes.Tests; + +public class HelmExtensionsTests +{ + [Theory] + [InlineData("plain string", true, null)] + [InlineData("{{ .Values.config.myapp.key }}", true, null)] + [InlineData("{{ .Values.config.myapp.port | int }}", false, ScalarStyle.ForcePlain)] + [InlineData("{{ .Values.config.myapp.count | int64 }}", false, ScalarStyle.ForcePlain)] + [InlineData("{{ .Values.config.myapp.rate | float64 }}", false, ScalarStyle.ForcePlain)] + [InlineData("{{ ternary \"a\" \"b\" (eq .Values.parameters.myapp.flag \"True\") | quote }}", false, ScalarStyle.ForcePlain)] + [InlineData("{{ ternary \",ssl=true\" \",ssl=false\" (eq .Values.parameters.myapp.enable_tls \"True\") | quote }}", false, ScalarStyle.ForcePlain)] + public void ShouldDoubleQuoteString_ReturnsExpectedResult(string value, bool expectedShouldApply, ScalarStyle? expectedStyle) + { + var (shouldApply, style) = HelmExtensions.ShouldDoubleQuoteString(value); + + Assert.Equal(expectedShouldApply, shouldApply); + Assert.Equal(expectedStyle, style); + } + + [Theory] + [InlineData("{{ ternary \"a\" \"b\" true }}")] + [InlineData("{{ ternary \"val1\" \"val2\" (eq .Values.x \"y\") | quote }}")] + public void HelmFlowControlPattern_MatchesTernaryExpressions(string value) + { + Assert.Matches(@"^\{\{\s*ternary\b", value); + } + + [Theory] + [InlineData("{{ .Values.config.myapp.key }}")] + [InlineData("plain text")] + [InlineData("{{ if eq .Values.x \"y\" }}a{{ else }}b{{ end }}")] + public void HelmFlowControlPattern_DoesNotMatchNonTernaryExpressions(string value) + { + Assert.DoesNotMatch(@"^\{\{\s*ternary\b", value); + } +} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 324e63d94df..3c0d9940ac4 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -562,6 +562,63 @@ public async Task PublishAsync_HandlesConditionalReferenceExpressionWithParamete await settingsTask; } + [Fact] + public async Task PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + + builder.AddKubernetesEnvironment("env"); + + // The condition is a ParameterResource, but one branch also references a parameter. + // This forces the fallback to static resolution because nested Helm expressions + // (a ternary whose branch value is itself a {{ .Values.x }} reference) aren't supported. + var enableTls = builder.AddParameter("enable-tls", "True", publishValueAsDefault: true); + var tlsSuffix = builder.AddParameter("tls-suffix", ",ssl=true", publishValueAsDefault: true); + + var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0") + .WithEnvironment(context => + { + var conditional = ReferenceExpression.CreateConditional( + enableTls.Resource, + bool.TrueString, + ReferenceExpression.Create($"{tlsSuffix.Resource}"), + ReferenceExpression.Create($",ssl=false")); + + context.EnvironmentVariables["TLS_SUFFIX"] = conditional; + }); + + var app = builder.Build(); + app.Run(); + + var expectedFiles = new[] + { + "Chart.yaml", + "values.yaml", + "templates/myapp/deployment.yaml", + "templates/myapp/config.yaml", + }; + + SettingsTask settingsTask = default!; + + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(tempDir.Path, expectedFile); + var fileExtension = Path.GetExtension(filePath)[1..]; + + if (settingsTask is null) + { + settingsTask = Verify(File.ReadAllText(filePath), fileExtension); + } + else + { + settingsTask = settingsTask.AppendContentAsFile(File.ReadAllText(filePath), fileExtension); + } + } + + await settingsTask; + } + private sealed class TestConditionProvider(string value) : IValueProvider, IManifestExpressionProvider { public string ValueExpression => "test-condition"; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#00.verified.yaml new file mode 100644 index 00000000000..d05c0dbf228 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#00.verified.yaml @@ -0,0 +1,11 @@ +apiVersion: "v2" +name: "aspire-hosting-tests" +version: "0.1.0" +kubeVersion: ">= 1.18.0-0" +description: "Aspire Helm Chart" +type: "application" +keywords: + - "aspire" + - "kubernetes" +appVersion: "0.1.0" +deprecated: false diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#01.verified.yaml new file mode 100644 index 00000000000..6e65f5d657a --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#01.verified.yaml @@ -0,0 +1,3 @@ +parameters: {} +secrets: {} +config: {} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#02.verified.yaml new file mode 100644 index 00000000000..37de00c4947 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#02.verified.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + name: "myapp-deployment" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "mcr.microsoft.com/dotnet/aspnet:8.0" + name: "myapp" + envFrom: + - configMapRef: + name: "myapp-config" + imagePullPolicy: "IfNotPresent" + selector: + matchLabels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#03.verified.yaml new file mode 100644 index 00000000000..f365d947af1 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#03.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "myapp-config" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + TLS_SUFFIX: "{{ .Values.config.myapp.tls_suffix }}" From 0052dbca8508d4dc8d2149f4b7601613b8ca6e7b Mon Sep 17 00:00:00 2001 From: David Negstad Date: Sat, 7 Mar 2026 18:55:15 -0800 Subject: [PATCH 37/45] Use Helm if/else for conditionals with expression branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace static fallback with Go template {{ if eq ... }}...{{ else }}...{{ end }} syntax when conditional branches contain Helm expressions. This preserves deploy-time dynamism for parameter-based conditions even when branches reference other parameters. Key changes: - BuildHelmTernary → BuildHelmConditional with ternary for literals, if/else for expression branches, static fallback for complex cases - TryFormatBranch: scalar expressions get | quote, literals wrapped as {{ "literal" | quote }} to avoid bare quotes in YAML plain scalars - AllocateBranchParameters: stores branch parameter values in AdditionalConfigValues to populate values.yaml without case-insensitive key collisions in ToConfigMap's processedKeys - HelmFlowControlPattern regex now matches {{ if in addition to {{ ternary - ShouldDoubleQuoteString checks flow control before ScalarExpressionPattern Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions/HelmExtensions.cs | 19 ++- .../KubernetesPublishingContext.cs | 11 +- .../KubernetesResource.cs | 122 +++++++++++++++--- .../HelmExtensionsTests.cs | 11 +- .../KubernetesPublisherTests.cs | 8 +- ...llsBackToStaticResolution#01.verified.yaml | 3 - ...rBranch_UsesIfElseSyntax#00.verified.yaml} | 0 ...erBranch_UsesIfElseSyntax#01.verified.yaml | 7 + ...rBranch_UsesIfElseSyntax#02.verified.yaml} | 0 ...rBranch_UsesIfElseSyntax#03.verified.yaml} | 2 +- 10 files changed, 144 insertions(+), 39 deletions(-) delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#01.verified.yaml rename tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/{KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#00.verified.yaml => KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#00.verified.yaml} (100%) create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#01.verified.yaml rename tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/{KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#02.verified.yaml => KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#02.verified.yaml} (100%) rename tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/{KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#03.verified.yaml => KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#03.verified.yaml} (59%) diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs index e4e4be36858..e0096c8bce1 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs @@ -91,15 +91,24 @@ public static bool ContainsHelmValuesSecretExpression(this string value) public static (bool, ScalarStyle?) ShouldDoubleQuoteString(string value) { + // Flow control expressions (ternary, if/else) must be rendered as plain YAML + // so Helm can process them as template expressions without YAML escaping. + // This check runs first because if/else blocks contain multiple {{ }} pairs + // and won't match ScalarExpressionPattern. + if (HelmFlowControlPattern().IsMatch(value)) + { + return (false, ScalarStyle.ForcePlain); + } + if (!ScalarExpressionPattern().IsMatch(value)) { return (true, null); } // Scalar Helm expressions that contain type conversions (| int, | float64, etc.) - // or flow control (ternary) must be rendered as plain (unquoted) YAML so that Helm - // can process them as template expressions without YAML escaping interference. - if (EndWithNonStringTypePattern().IsMatch(value) || HelmFlowControlPattern().IsMatch(value)) + // must be rendered as plain (unquoted) YAML so that Helm can process them as + // template expressions without YAML escaping interference. + if (EndWithNonStringTypePattern().IsMatch(value)) { return (false, ScalarStyle.ForcePlain); } @@ -107,8 +116,8 @@ public static (bool, ScalarStyle?) ShouldDoubleQuoteString(string value) return (true, null); } - [GeneratedRegex(@"^\{\{\s*ternary\b")] - private static partial Regex HelmFlowControlPattern(); + [GeneratedRegex(@"^\{\{\s*(ternary|if)\b")] + internal static partial Regex HelmFlowControlPattern(); [GeneratedRegex(@"\{\{[^}]*\|\s*(int|int64|float64)\s*\}\}")] private static partial Regex EndWithNonStringTypePattern(); diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs index 962cd403d87..03b14ebbd6a 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs @@ -108,7 +108,16 @@ private async Task WriteKubernetesOutputAsync(DistributedApplicationModel model, private async Task AppendResourceContextToHelmValuesAsync(IResource resource, KubernetesResource resourceContext) { await AddValuesToHelmSectionAsync(resource, resourceContext.Parameters, HelmExtensions.ParametersKey).ConfigureAwait(false); - await AddValuesToHelmSectionAsync(resource, resourceContext.EnvironmentVariables, HelmExtensions.ConfigKey).ConfigureAwait(false); + + // Merge AdditionalConfigValues (e.g., branch parameters from if/else conditionals) + // into a combined dictionary for the config section of values.yaml. + var configItems = new Dictionary(resourceContext.EnvironmentVariables); + foreach (var kvp in resourceContext.AdditionalConfigValues) + { + configItems.TryAdd(kvp.Key, kvp.Value); + } + + await AddValuesToHelmSectionAsync(resource, configItems, HelmExtensions.ConfigKey).ConfigureAwait(false); await AddValuesToHelmSectionAsync(resource, resourceContext.Secrets, HelmExtensions.SecretsKey).ConfigureAwait(false); } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index f8c8f28e918..551e83df9dd 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -22,6 +22,7 @@ internal record EndpointMapping(string Scheme, string Protocol, string Host, Hel internal Dictionary EnvironmentVariables { get; } = []; internal Dictionary Secrets { get; } = []; internal Dictionary Parameters { get; } = []; + internal Dictionary AdditionalConfigValues { get; } = []; internal Dictionary Labels { get; private set; } = []; internal List Commands { get; } = []; internal List Volumes { get; } = []; @@ -431,11 +432,11 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex { if (expr.IsConditional) { - // When the condition is a parameter, use Helm ternary to defer + // When the condition is a parameter, use Helm flow control to defer // evaluation to helm install/upgrade time. if (expr.Condition is ParameterResource conditionParam) { - return await BuildHelmTernary(context, executionContext, expr, conditionParam, embedded).ConfigureAwait(false); + return await BuildHelmConditional(context, executionContext, expr, conditionParam, embedded).ConfigureAwait(false); } // For non-parameter conditions, resolve statically at generation time. @@ -477,7 +478,7 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex } } - private async Task BuildHelmTernary(KubernetesEnvironmentContext context, DistributedApplicationExecutionContext executionContext, ReferenceExpression expr, ParameterResource conditionParam, bool embedded) + private async Task BuildHelmConditional(KubernetesEnvironmentContext context, DistributedApplicationExecutionContext executionContext, ReferenceExpression expr, ParameterResource conditionParam, bool embedded) { // Process both branches to get their rendered values. var whenTrueResult = await ProcessValueAsync(context, executionContext, expr.WhenTrue!, embedded).ConfigureAwait(false); @@ -486,20 +487,8 @@ private async Task BuildHelmTernary(KubernetesEnvironmentContext context var whenTrueStr = whenTrueResult.ToString() ?? string.Empty; var whenFalseStr = whenFalseResult.ToString() ?? string.Empty; - // If either branch resolved to a Helm expression (e.g., a parameter reference like - // {{ .Values.config.x }}), fall back to static resolution since nesting Helm - // expressions inside a ternary produces invalid Go templates. - if (whenTrueStr.ContainsHelmExpression() || whenFalseStr.ContainsHelmExpression() - || whenTrueResult is HelmValue || whenFalseResult is HelmValue) - { - var conditionContext = new ValueProviderContext { ExecutionContext = executionContext }; - var conditionStr = await expr.Condition!.GetValueAsync(conditionContext, default).ConfigureAwait(false); - - var branch = string.Equals(conditionStr, expr.MatchValue, StringComparison.OrdinalIgnoreCase) - ? expr.WhenTrue! - : expr.WhenFalse!; - return await ProcessValueAsync(context, executionContext, branch, embedded).ConfigureAwait(false); - } + var trueHasExpression = whenTrueStr.ContainsHelmExpression() || whenTrueResult is HelmValue; + var falseHasExpression = whenFalseStr.ContainsHelmExpression() || whenFalseResult is HelmValue; // Allocate the condition parameter into values.yaml under the parameters section. var formattedName = conditionParam.Name.ToHelmValuesSectionName(); @@ -514,20 +503,113 @@ private async Task BuildHelmTernary(KubernetesEnvironmentContext context // Extract the values path (e.g., .Values.parameters.myapp.enable_tls) from {{ expression }}. var conditionPath = HelmExtensions.ScalarExpressionPattern().Match(paramExpression).Value.Trim(); + var escapedMatch = EscapeGoTemplateString(expr.MatchValue ?? string.Empty); + + if (trueHasExpression || falseHasExpression) + { + // At least one branch contains a Helm expression (e.g., a parameter reference). + // Use {{ if eq ... }}...{{ else }}...{{ end }} syntax since ternary arguments + // can't contain nested Helm expressions. + if (TryFormatBranch(whenTrueStr, out var trueBranch) && TryFormatBranch(whenFalseStr, out var falseBranch)) + { + // Ensure parameter values referenced in branches are populated in values.yaml. + // ProcessValueAsync's "{0}" optimization converts HelmValues to strings via + // ToString(), so the ParameterSource is lost. Re-allocate here so the values + // flow through AddValuesToHelmSectionAsync. + AllocateBranchParameters(expr.WhenTrue!); + AllocateBranchParameters(expr.WhenFalse!); + + var ifElseExpression = $"{{{{ if eq {conditionPath} \"{escapedMatch}\" }}}}{trueBranch}{{{{ else }}}}{falseBranch}{{{{ end }}}}"; + return HelmValue.Literal(ifElseExpression); + } + + // A branch contains a complex non-scalar expression that can't be formatted + // for if/else — resolve the condition statically as a last resort. + var conditionContext = new ValueProviderContext { ExecutionContext = executionContext }; + var conditionStr = await expr.Condition!.GetValueAsync(conditionContext, default).ConfigureAwait(false); + + var branch = string.Equals(conditionStr, expr.MatchValue, StringComparison.OrdinalIgnoreCase) + ? expr.WhenTrue! + : expr.WhenFalse!; + return await ProcessValueAsync(context, executionContext, branch, embedded).ConfigureAwait(false); + } + // Both branches are plain strings — use the simpler ternary syntax. var escapedTrue = EscapeGoTemplateString(whenTrueStr); var escapedFalse = EscapeGoTemplateString(whenFalseStr); - var escapedMatch = EscapeGoTemplateString(expr.MatchValue ?? string.Empty); - var helmExpression = $"ternary \"{escapedTrue}\" \"{escapedFalse}\" (eq {conditionPath} \"{escapedMatch}\") {HelmExtensions.PipelineDelimiter} quote" + var ternaryExpression = $"ternary \"{escapedTrue}\" \"{escapedFalse}\" (eq {conditionPath} \"{escapedMatch}\") {HelmExtensions.PipelineDelimiter} quote" .ToHelmExpression(); - return HelmValue.Literal(helmExpression); + return HelmValue.Literal(ternaryExpression); static string EscapeGoTemplateString(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); } + /// + /// Formats a branch value for use inside a Go template if/else block. + /// Scalar Helm expressions get | quote appended; literals are wrapped in double quotes. + /// Returns if the branch contains a complex non-scalar expression + /// that cannot be safely formatted. + /// + private static bool TryFormatBranch(string branchValue, out string formatted) + { + // If the branch is a scalar Helm expression (single {{ expr }}), + // add | quote to the pipeline for proper string quoting. + var scalarMatch = HelmExtensions.ScalarExpressionPattern().Match(branchValue); + if (scalarMatch.Success) + { + var innerExpr = scalarMatch.Value.TrimEnd(); + formatted = $"{{{{ {innerExpr} | quote }}}}"; + return true; + } + + // A branch with embedded (non-scalar) Helm expressions can't be safely + // formatted for if/else — signal the caller to fall back. + if (branchValue.ContainsHelmExpression()) + { + formatted = string.Empty; + return false; + } + + // Wrap literal values as Go template string expressions with | quote so the + // entire if/else block contains only {{ }} segments — no bare double quotes + // that would violate YAML plain scalar rules. + var escaped = branchValue.Replace("\\", "\\\\").Replace("\"", "\\\""); + formatted = $"{{{{ \"{escaped}\" | quote }}}}"; + return true; + } + + /// + /// Ensures that any instances referenced in a branch's + /// value providers are allocated in the appropriate dictionary (EnvironmentVariables or + /// Secrets) so their values flow to values.yaml via AddValuesToHelmSectionAsync. + /// + private void AllocateBranchParameters(ReferenceExpression branch) + { + foreach (var vp in branch.ValueProviders) + { + if (vp is ParameterResource branchParam) + { + var helmValue = AllocateParameter(branchParam, TargetResource); + var key = branchParam.Name.ToHelmValuesSectionName(); + + // Store in AdditionalConfigValues rather than EnvironmentVariables to avoid + // case-insensitive key collisions in ToConfigMap's processedKeys. These values + // flow to the config section of values.yaml but do not appear as env vars. + if (helmValue.ExpressionContainsHelmSecretExpression) + { + Secrets.TryAdd(key, helmValue); + } + else + { + AdditionalConfigValues.TryAdd(key, helmValue); + } + } + } + } + private static string GetEndpointValue(EndpointMapping mapping, EndpointProperty property, bool embedded = false) { var (scheme, _, host, port, _, _) = mapping; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs index 7eb7c32b40e..1934f78f9d8 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs @@ -16,6 +16,7 @@ public class HelmExtensionsTests [InlineData("{{ .Values.config.myapp.rate | float64 }}", false, ScalarStyle.ForcePlain)] [InlineData("{{ ternary \"a\" \"b\" (eq .Values.parameters.myapp.flag \"True\") | quote }}", false, ScalarStyle.ForcePlain)] [InlineData("{{ ternary \",ssl=true\" \",ssl=false\" (eq .Values.parameters.myapp.enable_tls \"True\") | quote }}", false, ScalarStyle.ForcePlain)] + [InlineData("{{ if eq .Values.parameters.myapp.enable_tls \"True\" }}{{ .Values.config.myapp.tls_suffix | quote }}{{ else }}{{ \",ssl=false\" | quote }}{{ end }}", false, ScalarStyle.ForcePlain)] public void ShouldDoubleQuoteString_ReturnsExpectedResult(string value, bool expectedShouldApply, ScalarStyle? expectedStyle) { var (shouldApply, style) = HelmExtensions.ShouldDoubleQuoteString(value); @@ -27,17 +28,17 @@ public void ShouldDoubleQuoteString_ReturnsExpectedResult(string value, bool exp [Theory] [InlineData("{{ ternary \"a\" \"b\" true }}")] [InlineData("{{ ternary \"val1\" \"val2\" (eq .Values.x \"y\") | quote }}")] - public void HelmFlowControlPattern_MatchesTernaryExpressions(string value) + [InlineData("{{ if eq .Values.parameters.myapp.flag \"True\" }}{{ .Values.config.myapp.suffix | quote }}{{ else }}{{ \"fallback\" | quote }}{{ end }}")] + public void HelmFlowControlPattern_MatchesFlowControlExpressions(string value) { - Assert.Matches(@"^\{\{\s*ternary\b", value); + Assert.Matches(HelmExtensions.HelmFlowControlPattern(), value); } [Theory] [InlineData("{{ .Values.config.myapp.key }}")] [InlineData("plain text")] - [InlineData("{{ if eq .Values.x \"y\" }}a{{ else }}b{{ end }}")] - public void HelmFlowControlPattern_DoesNotMatchNonTernaryExpressions(string value) + public void HelmFlowControlPattern_DoesNotMatchNonFlowControlExpressions(string value) { - Assert.DoesNotMatch(@"^\{\{\s*ternary\b", value); + Assert.DoesNotMatch(HelmExtensions.HelmFlowControlPattern(), value); } } diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 3c0d9940ac4..92d7f73d92e 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -563,16 +563,16 @@ public async Task PublishAsync_HandlesConditionalReferenceExpressionWithParamete } [Fact] - public async Task PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution() + public async Task PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax() { using var tempDir = new TestTempDirectory(); var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); builder.AddKubernetesEnvironment("env"); - // The condition is a ParameterResource, but one branch also references a parameter. - // This forces the fallback to static resolution because nested Helm expressions - // (a ternary whose branch value is itself a {{ .Values.x }} reference) aren't supported. + // The condition is a ParameterResource, and one branch also references a parameter. + // This uses {{ if eq ... }}...{{ else }}...{{ end }} syntax since ternary arguments + // can't contain nested Helm expressions. var enableTls = builder.AddParameter("enable-tls", "True", publishValueAsDefault: true); var tlsSuffix = builder.AddParameter("tls-suffix", ",ssl=true", publishValueAsDefault: true); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#01.verified.yaml deleted file mode 100644 index 6e65f5d657a..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#01.verified.yaml +++ /dev/null @@ -1,3 +0,0 @@ -parameters: {} -secrets: {} -config: {} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#00.verified.yaml similarity index 100% rename from tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#00.verified.yaml rename to tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#00.verified.yaml diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#01.verified.yaml new file mode 100644 index 00000000000..da0b7d92c13 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#01.verified.yaml @@ -0,0 +1,7 @@ +parameters: + myapp: + enable_tls: "True" +secrets: {} +config: + myapp: + tls_suffix: ",ssl=true" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#02.verified.yaml similarity index 100% rename from tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#02.verified.yaml rename to tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#02.verified.yaml diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#03.verified.yaml similarity index 59% rename from tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#03.verified.yaml rename to tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#03.verified.yaml index f365d947af1..3fd821c5bba 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_FallsBackToStaticResolution#03.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#03.verified.yaml @@ -8,4 +8,4 @@ metadata: app.kubernetes.io/component: "myapp" app.kubernetes.io/instance: "{{ .Release.Name }}" data: - TLS_SUFFIX: "{{ .Values.config.myapp.tls_suffix }}" + TLS_SUFFIX: {{ if eq .Values.parameters.myapp.enable_tls "True" }}{{ .Values.config.myapp.tls_suffix | quote }}{{ else }}{{ ",ssl=false" | quote }}{{ end }} From 434aac0b16bd5fe76e31a2b50292bbf2655dc4a8 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 9 Mar 2026 10:26:59 -0700 Subject: [PATCH 38/45] Populate ValueProviders for conditional ReferenceExpressions Conditional expressions now expose the union of both branches' ValueProviders, enabling publish contexts to discover all referenced parameters and resources without inspecting WhenTrue/WhenFalse directly. Guard RegisterFormattedParameters against the parallel array mismatch (ValueProviders is now non-empty but ManifestExpressions/StringFormats remain empty for conditionals) by recursing into each branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs | 6 ++++-- src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs index 2edc7b064c8..6c89ed1ac93 100644 --- a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs +++ b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs @@ -70,9 +70,11 @@ private ReferenceExpression(IValueProvider condition, string matchValue, Referen _matchValue = matchValue; _name = GenerateConditionalName(condition, matchValue, whenTrue, whenFalse); - // Set value-mode fields to safe defaults so callers never see null. + // Expose the union of both branches' value providers so that callers + // iterating ValueProviders (e.g., publish contexts) can discover all + // parameters and resources referenced by the conditional. Format = string.Empty; - ValueProviders = []; + ValueProviders = whenTrue.ValueProviders.Concat(whenFalse.ValueProviders).ToArray(); _manifestExpressions = []; _stringFormats = []; } diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index 501318326ed..f829f101c34 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -729,6 +729,13 @@ private string GetManifestExpression(ReferenceExpression referenceExpression) private void RegisterFormattedParameters(ReferenceExpression referenceExpression) { + if (referenceExpression.IsConditional) + { + RegisterFormattedParameters(referenceExpression.WhenTrue!); + RegisterFormattedParameters(referenceExpression.WhenFalse!); + return; + } + var providers = referenceExpression.ValueProviders; var formats = referenceExpression.StringFormats; From d4edea51a79e35f8b506235279da86c71f5cadd8 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 9 Mar 2026 10:51:32 -0700 Subject: [PATCH 39/45] Remove Redis connection string caching and fix manifest conditional registration The hash-based conditional name (XxHash32 of condition + branches) is now stable, so caching the built ReferenceExpression in RedisResource is unnecessary. Fix RegisterConditionalExpressions to register the expression itself when it is conditional, not just nested conditionals in ValueProviders. With ValueProviders now containing the union of both branches' providers (parameters, endpoints, etc.), the top-level conditional was no longer being discovered through the nested provider scan. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Redis/RedisResource.cs | 10 +--------- .../Publishing/ManifestPublishingContext.cs | 12 +++++++++--- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Hosting.Redis/RedisResource.cs b/src/Aspire.Hosting.Redis/RedisResource.cs index 8b373e2e715..6bbfd01410e 100644 --- a/src/Aspire.Hosting.Redis/RedisResource.cs +++ b/src/Aspire.Hosting.Redis/RedisResource.cs @@ -81,15 +81,8 @@ public RedisResource(string name, ParameterResource password) : this(name) /// internal List Args { get; set; } = new(); - private ReferenceExpression? _cachedConnectionString; - private ReferenceExpression BuildConnectionString() { - if (_cachedConnectionString is not null) - { - return _cachedConnectionString; - } - var builder = new ReferenceExpressionBuilder(); builder.Append($"{PrimaryEndpoint.Property(EndpointProperty.HostAndPort)}"); @@ -102,8 +95,7 @@ private ReferenceExpression BuildConnectionString() enabledValue: ReferenceExpression.Create($",ssl=true"), disabledValue: ReferenceExpression.Empty)}"); - _cachedConnectionString = builder.Build(); - return _cachedConnectionString; + return builder.Build(); } /// diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index f829f101c34..62254188cf2 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -754,12 +754,18 @@ private void RegisterFormattedParameters(ReferenceExpression referenceExpression private void RegisterConditionalExpressions(ReferenceExpression referenceExpression) { + if (referenceExpression is { IsConditional: true, Name: string name }) + { + _conditionalExpressions.TryAdd(name, referenceExpression); + _manifestResourceNames.Add(name); + } + foreach (var provider in referenceExpression.ValueProviders) { - if (provider is ReferenceExpression { IsConditional: true, Name: string name } conditional) + if (provider is ReferenceExpression { IsConditional: true, Name: string nestedName } conditional) { - _conditionalExpressions.TryAdd(name, conditional); - _manifestResourceNames.Add(name); + _conditionalExpressions.TryAdd(nestedName, conditional); + _manifestResourceNames.Add(nestedName); } } } From 0acd987e3d7b981119228581a6c7c6cd040bfe85 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 9 Mar 2026 11:32:42 -0700 Subject: [PATCH 40/45] Update Go and Rust expression key from $refExpr to $expr Align with the TypeScript base.ts which already uses the current $expr key for reference expression serialization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go | 4 ++-- src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go index 41a8d92aed4..1eee691a142 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go +++ b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go @@ -78,7 +78,7 @@ func RefExpr(format string, args ...any) *ReferenceExpression { func (r *ReferenceExpression) ToJSON() map[string]any { if r.isConditional { return map[string]any{ - "$refExpr": map[string]any{ + "$expr": map[string]any{ "condition": SerializeValue(r.Condition), "whenTrue": r.WhenTrue.ToJSON(), "whenFalse": r.WhenFalse.ToJSON(), @@ -87,7 +87,7 @@ func (r *ReferenceExpression) ToJSON() map[string]any { } } return map[string]any{ - "$refExpr": map[string]any{ + "$expr": map[string]any{ "format": r.Format, "args": r.Args, }, diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs index 6ac76c206c6..88dfb7c9822 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs @@ -98,7 +98,7 @@ impl ReferenceExpression { pub fn to_json(&self) -> Value { if self.is_conditional { return json!({ - "$refExpr": { + "$expr": { "condition": serialize_value(self.condition.clone().unwrap()), "whenTrue": self.when_true.as_ref().unwrap().to_json(), "whenFalse": self.when_false.as_ref().unwrap().to_json(), @@ -107,7 +107,7 @@ impl ReferenceExpression { }); } json!({ - "$refExpr": { + "$expr": { "format": self.format.as_deref().unwrap_or_default(), "args": self.args.as_deref().unwrap_or_default() } From 51c4b97abd155a68c2117b3baddcf262b2152524 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 9 Mar 2026 11:35:23 -0700 Subject: [PATCH 41/45] Add tests for conditional ReferenceExpression ValueProviders and References Validate that ValueProviders returns the union of both branches' providers, References includes condition + all branch references, nested conditionals propagate providers through the chain, and duplicate conditionals produce identical hash-based names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ReferenceExpressionTests.cs | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/tests/Aspire.Hosting.Tests/ReferenceExpressionTests.cs b/tests/Aspire.Hosting.Tests/ReferenceExpressionTests.cs index 07273e883cf..3cc90d39e7b 100644 --- a/tests/Aspire.Hosting.Tests/ReferenceExpressionTests.cs +++ b/tests/Aspire.Hosting.Tests/ReferenceExpressionTests.cs @@ -122,4 +122,148 @@ private sealed class Value : IValueProvider, IManifestExpressionProvider return new("Hello World"); } } + + private sealed class TestCondition(string value) : IValueProvider, IManifestExpressionProvider, IValueWithReferences + { + public string ValueExpression => "{test-condition.value}"; + + public IEnumerable References => [this]; + + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) => new(value); + } + + [Fact] + public void ConditionalExpression_ValueProviders_ReturnsUnionOfBothBranches() + { + var param1 = new Value(); + var param2 = new Value(); + var param3 = new Value(); + var condition = new TestCondition("True"); + + var whenTrue = ReferenceExpression.Create($"prefix-{param1}-{param2}"); + var whenFalse = ReferenceExpression.Create($"fallback-{param3}"); + + var conditional = ReferenceExpression.CreateConditional(condition, "True", whenTrue, whenFalse); + + Assert.True(conditional.IsConditional); + Assert.Equal(3, conditional.ValueProviders.Count); + Assert.Same(param1, conditional.ValueProviders[0]); + Assert.Same(param2, conditional.ValueProviders[1]); + Assert.Same(param3, conditional.ValueProviders[2]); + } + + [Fact] + public void ConditionalExpression_ValueProviders_EmptyWhenBranchesHaveNoProviders() + { + var condition = new TestCondition("True"); + + var whenTrue = ReferenceExpression.Create($"literal-a"); + var whenFalse = ReferenceExpression.Create($"literal-b"); + + var conditional = ReferenceExpression.CreateConditional(condition, "True", whenTrue, whenFalse); + + Assert.True(conditional.IsConditional); + Assert.Empty(conditional.ValueProviders); + } + + [Fact] + public void ConditionalExpression_References_IncludesConditionAndBothBranches() + { + var param1 = new Value(); + var param2 = new Value(); + var condition = new TestCondition("True"); + + var whenTrue = ReferenceExpression.Create($"{param1}"); + var whenFalse = ReferenceExpression.Create($"{param2}"); + + var conditional = ReferenceExpression.CreateConditional(condition, "True", whenTrue, whenFalse); + + var references = ((IValueWithReferences)conditional).References.ToList(); + + // References should include the condition's references, then each branch's references + Assert.Contains(condition, references); + Assert.Contains(param1, references); + Assert.Contains(param2, references); + } + + [Fact] + public void NestedConditionalExpression_ValueProviders_IncludesAllNestedProviders() + { + var outerCondition = new TestCondition("True"); + var innerCondition = new TestCondition("Yes"); + var param1 = new Value(); + var param2 = new Value(); + var param3 = new Value(); + + // Inner conditional: if innerCondition == "Yes" then param1 else param2 + var innerConditional = ReferenceExpression.CreateConditional( + innerCondition, "Yes", + ReferenceExpression.Create($"{param1}"), + ReferenceExpression.Create($"{param2}")); + + // Outer conditional: if outerCondition == "True" then innerConditional else param3 + var outerConditional = ReferenceExpression.CreateConditional( + outerCondition, "True", + innerConditional, + ReferenceExpression.Create($"{param3}")); + + // Outer ValueProviders should be the union of: + // innerConditional.ValueProviders (param1, param2) + whenFalse.ValueProviders (param3) + Assert.Equal(3, outerConditional.ValueProviders.Count); + Assert.Same(param1, outerConditional.ValueProviders[0]); + Assert.Same(param2, outerConditional.ValueProviders[1]); + Assert.Same(param3, outerConditional.ValueProviders[2]); + } + + [Fact] + public void NestedConditionalExpression_References_IncludesAllNestedReferences() + { + var outerCondition = new TestCondition("True"); + var innerCondition = new TestCondition("Yes"); + var param1 = new Value(); + var param2 = new Value(); + var param3 = new Value(); + + var innerConditional = ReferenceExpression.CreateConditional( + innerCondition, "Yes", + ReferenceExpression.Create($"{param1}"), + ReferenceExpression.Create($"{param2}")); + + var outerConditional = ReferenceExpression.CreateConditional( + outerCondition, "True", + innerConditional, + ReferenceExpression.Create($"{param3}")); + + var references = ((IValueWithReferences)outerConditional).References.ToList(); + + // Outer condition's references + Assert.Contains(outerCondition, references); + // Inner conditional's references (condition + both branches) + Assert.Contains(innerCondition, references); + Assert.Contains(param1, references); + Assert.Contains(param2, references); + // Outer false branch + Assert.Contains(param3, references); + } + + [Fact] + public void DuplicateConditionalExpressions_HaveSameName() + { + var condition = new TestCondition("True"); + var param1 = new Value(); + var param2 = new Value(); + + var conditional1 = ReferenceExpression.CreateConditional( + condition, "True", + ReferenceExpression.Create($"{param1}"), + ReferenceExpression.Create($"{param2}")); + + var conditional2 = ReferenceExpression.CreateConditional( + condition, "True", + ReferenceExpression.Create($"{param1}"), + ReferenceExpression.Create($"{param2}")); + + // Two identical conditionals should produce the same hash-based name + Assert.Equal(conditional1.ValueExpression, conditional2.ValueExpression); + } } From 3b85a7289bac1045c3298be81c9fb2b1b0549376 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 9 Mar 2026 11:57:07 -0700 Subject: [PATCH 42/45] Add conditional ReferenceExpression tests to ResourceDependencyTests Add 4 test cases for resource dependency tracking with conditional reference expressions: basic branch dependencies, endpoint references, nested conditionals, and deduplication. Fix bug in ReferenceExpression.References where the condition object itself was not yielded - only its sub-references via IValueWithReferences. Since ParameterResource does not implement IValueWithReferences, the condition resource was silently dropped from dependency tracking. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApplicationModel/ReferenceExpression.cs | 4 + .../ResourceDependencyTests.cs | 114 ++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs index 6c89ed1ac93..d879167ce38 100644 --- a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs +++ b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs @@ -141,6 +141,10 @@ IEnumerable IValueWithReferences.References { if (IsConditional) { + // Yield the condition itself so dependency tracking discovers it as an IResource, + // then yield its sub-references if it implements IValueWithReferences. + yield return _condition!; + if (_condition is IValueWithReferences conditionRefs) { foreach (var reference in conditionRefs.References) diff --git a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs index 20df2382eb1..1fb854a69a8 100644 --- a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs @@ -592,4 +592,118 @@ public async Task DefaultOverloadUsesTransitiveClosure() Assert.Contains(b.Resource, dependencies); Assert.Contains(c.Resource, dependencies); } + + [Fact] + public async Task ConditionalReferenceExpressionIncludesBothBranchDependencies() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var enableTls = builder.AddParameter("enable-tls"); + var tlsSuffix = builder.AddParameter("tls-suffix"); + + var conditional = ReferenceExpression.CreateConditional( + enableTls.Resource, + bool.TrueString, + ReferenceExpression.Create($"{tlsSuffix}"), + ReferenceExpression.Create($",ssl=false")); + + var container = builder.AddContainer("container", "alpine") + .WithEnvironment("TLS_SUFFIX", conditional); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await container.Resource.GetResourceDependenciesAsync(executionContext); + + Assert.Contains(enableTls.Resource, dependencies); + Assert.Contains(tlsSuffix.Resource, dependencies); + } + + [Fact] + public async Task ConditionalReferenceExpressionWithEndpointReferencesIncludesAll() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var flag = builder.AddParameter("use-primary"); + var primary = builder.AddContainer("primary", "alpine") + .WithHttpEndpoint(5000, 5000, "http"); + var secondary = builder.AddContainer("secondary", "alpine") + .WithHttpEndpoint(5001, 5001, "http"); + + var conditional = ReferenceExpression.CreateConditional( + flag.Resource, + bool.TrueString, + ReferenceExpression.Create($"{primary.GetEndpoint("http")}"), + ReferenceExpression.Create($"{secondary.GetEndpoint("http")}")); + + var container = builder.AddContainer("frontend", "alpine") + .WithEnvironment("BACKEND_URL", conditional); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await container.Resource.GetResourceDependenciesAsync(executionContext); + + Assert.Contains(flag.Resource, dependencies); + Assert.Contains(primary.Resource, dependencies); + Assert.Contains(secondary.Resource, dependencies); + } + + [Fact] + public async Task NestedConditionalReferenceExpressionIncludesAllTransitiveDependencies() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var outerFlag = builder.AddParameter("outer-flag"); + var innerFlag = builder.AddParameter("inner-flag"); + var paramA = builder.AddParameter("param-a"); + var paramB = builder.AddParameter("param-b"); + var paramC = builder.AddParameter("param-c"); + + var innerConditional = ReferenceExpression.CreateConditional( + innerFlag.Resource, + bool.TrueString, + ReferenceExpression.Create($"{paramA}"), + ReferenceExpression.Create($"{paramB}")); + + var outerConditional = ReferenceExpression.CreateConditional( + outerFlag.Resource, + bool.TrueString, + innerConditional, + ReferenceExpression.Create($"{paramC}")); + + var container = builder.AddContainer("container", "alpine") + .WithEnvironment("VALUE", outerConditional); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await container.Resource.GetResourceDependenciesAsync(executionContext); + + Assert.Contains(outerFlag.Resource, dependencies); + Assert.Contains(innerFlag.Resource, dependencies); + Assert.Contains(paramA.Resource, dependencies); + Assert.Contains(paramB.Resource, dependencies); + Assert.Contains(paramC.Resource, dependencies); + } + + [Fact] + public async Task DuplicateConditionalExpressionDependenciesAreDeduplicatedAndIncluded() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var flag = builder.AddParameter("flag"); + var param = builder.AddParameter("value"); + + var conditional = ReferenceExpression.CreateConditional( + flag.Resource, + bool.TrueString, + ReferenceExpression.Create($"{param}"), + ReferenceExpression.Create($"default")); + + var container = builder.AddContainer("container", "alpine") + .WithEnvironment("VAR1", conditional) + .WithEnvironment("VAR2", conditional); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await container.Resource.GetResourceDependenciesAsync(executionContext); + + // Both env vars reference the same resources — dependencies should be deduplicated + Assert.Contains(flag.Resource, dependencies); + Assert.Contains(param.Resource, dependencies); + } } From 8c03ad23837d238fe5520a48868ef2ff11ff948b Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 9 Mar 2026 15:11:31 -0700 Subject: [PATCH 43/45] Fix ExpressionResolver to handle conditional ReferenceExpressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExpressionResolver.EvalExpressionAsync had no handling for conditional ReferenceExpressions. Since conditional expressions have Format="" (empty string), the method returned null, and the dashboard silently dropped the environment variable (resolvedValue?.Value != null check). Add conditional branch to EvalExpressionAsync that evaluates the condition, selects the matching branch, and recurses — mirroring the logic already present in ReferenceExpression.GetValueAsync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs b/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs index c483a8a2360..c4a07bb3628 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs @@ -10,6 +10,13 @@ internal class ExpressionResolver(CancellationToken cancellationToken) { async Task EvalExpressionAsync(ReferenceExpression expr, ValueProviderContext context) { + if (expr.IsConditional) + { + var conditionResult = await ResolveInternalAsync(expr.Condition!, context).ConfigureAwait(false); + var branch = string.Equals(conditionResult.Value, expr.MatchValue, StringComparison.OrdinalIgnoreCase) ? expr.WhenTrue! : expr.WhenFalse!; + return await EvalExpressionAsync(branch, context).ConfigureAwait(false); + } + // This logic is similar to ReferenceExpression.GetValueAsync, except that we recurse on // our own resolver method var args = new object?[expr.ValueProviders.Count]; From 30cbdc323e7038831b4ddb1588419e0c1555830a Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 9 Mar 2026 18:52:31 -0700 Subject: [PATCH 44/45] Simplify Helm conditional to if/else only with case-insensitive comparison Remove ternary and static fallback paths from BuildHelmConditional. All conditionals now use {{ if eq ... }}...{{ else }}...{{ end }} syntax which natively handles mixed content (literal + Helm expressions) without needing TryFormatBranch or printf workarounds. Add | lower pipe for case-insensitive comparison, matching .NET's StringComparison.OrdinalIgnoreCase used in other execution/publish paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions/HelmExtensions.cs | 10 +-- .../KubernetesResource.cs | 89 +++---------------- .../HelmExtensionsTests.cs | 9 +- ...erBranch_UsesIfElseSyntax#03.verified.yaml | 2 +- ...ionWithParameterCondition#03.verified.yaml | 2 +- 5 files changed, 20 insertions(+), 92 deletions(-) diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs index e0096c8bce1..4d962245ff6 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs @@ -91,10 +91,10 @@ public static bool ContainsHelmValuesSecretExpression(this string value) public static (bool, ScalarStyle?) ShouldDoubleQuoteString(string value) { - // Flow control expressions (ternary, if/else) must be rendered as plain YAML - // so Helm can process them as template expressions without YAML escaping. - // This check runs first because if/else blocks contain multiple {{ }} pairs - // and won't match ScalarExpressionPattern. + // Flow control expressions (if/else) must be rendered as plain YAML so Helm + // can process them as template expressions without YAML escaping. This check + // runs first because if/else blocks contain multiple {{ }} pairs and won't + // match ScalarExpressionPattern. if (HelmFlowControlPattern().IsMatch(value)) { return (false, ScalarStyle.ForcePlain); @@ -116,7 +116,7 @@ public static (bool, ScalarStyle?) ShouldDoubleQuoteString(string value) return (true, null); } - [GeneratedRegex(@"^\{\{\s*(ternary|if)\b")] + [GeneratedRegex(@"^\{\{\s*if\b")] internal static partial Regex HelmFlowControlPattern(); [GeneratedRegex(@"\{\{[^}]*\|\s*(int|int64|float64)\s*\}\}")] diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index bd0579ed254..737ad0b60b2 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -488,9 +488,6 @@ private async Task BuildHelmConditional(KubernetesEnvironmentContext con var whenTrueStr = whenTrueResult.ToString() ?? string.Empty; var whenFalseStr = whenFalseResult.ToString() ?? string.Empty; - var trueHasExpression = whenTrueStr.ContainsHelmExpression() || whenTrueResult is HelmValue; - var falseHasExpression = whenFalseStr.ContainsHelmExpression() || whenFalseResult is HelmValue; - // Allocate the condition parameter into values.yaml under the parameters section. var formattedName = conditionParam.Name.ToHelmValuesSectionName(); var paramExpression = formattedName.ToHelmParameterExpression(TargetResource.Name); @@ -502,84 +499,18 @@ private async Task BuildHelmConditional(KubernetesEnvironmentContext con : new HelmValue(paramExpression, conditionParam); } - // Extract the values path (e.g., .Values.parameters.myapp.enable_tls) from {{ expression }}. - var conditionPath = HelmExtensions.ScalarExpressionPattern().Match(paramExpression).Value.Trim(); - var escapedMatch = EscapeGoTemplateString(expr.MatchValue ?? string.Empty); - - if (trueHasExpression || falseHasExpression) - { - // At least one branch contains a Helm expression (e.g., a parameter reference). - // Use {{ if eq ... }}...{{ else }}...{{ end }} syntax since ternary arguments - // can't contain nested Helm expressions. - if (TryFormatBranch(whenTrueStr, out var trueBranch) && TryFormatBranch(whenFalseStr, out var falseBranch)) - { - // Ensure parameter values referenced in branches are populated in values.yaml. - // ProcessValueAsync's "{0}" optimization converts HelmValues to strings via - // ToString(), so the ParameterSource is lost. Re-allocate here so the values - // flow through AddValuesToHelmSectionAsync. - AllocateBranchParameters(expr.WhenTrue!); - AllocateBranchParameters(expr.WhenFalse!); - - var ifElseExpression = $"{{{{ if eq {conditionPath} \"{escapedMatch}\" }}}}{trueBranch}{{{{ else }}}}{falseBranch}{{{{ end }}}}"; - return HelmValue.Literal(ifElseExpression); - } - - // A branch contains a complex non-scalar expression that can't be formatted - // for if/else — resolve the condition statically as a last resort. - var conditionContext = new ValueProviderContext { ExecutionContext = executionContext }; - var conditionStr = await expr.Condition!.GetValueAsync(conditionContext, default).ConfigureAwait(false); - - var branch = string.Equals(conditionStr, expr.MatchValue, StringComparison.OrdinalIgnoreCase) - ? expr.WhenTrue! - : expr.WhenFalse!; - return await ProcessValueAsync(context, executionContext, branch, embedded).ConfigureAwait(false); - } - - // Both branches are plain strings — use the simpler ternary syntax. - var escapedTrue = EscapeGoTemplateString(whenTrueStr); - var escapedFalse = EscapeGoTemplateString(whenFalseStr); - - var ternaryExpression = $"ternary \"{escapedTrue}\" \"{escapedFalse}\" (eq {conditionPath} \"{escapedMatch}\") {HelmExtensions.PipelineDelimiter} quote" - .ToHelmExpression(); + // Ensure parameter values referenced in branches are populated in values.yaml. + AllocateBranchParameters(expr.WhenTrue!); + AllocateBranchParameters(expr.WhenFalse!); - return HelmValue.Literal(ternaryExpression); - - static string EscapeGoTemplateString(string value) - => value.Replace("\\", "\\\\").Replace("\"", "\\\""); - } - - /// - /// Formats a branch value for use inside a Go template if/else block. - /// Scalar Helm expressions get | quote appended; literals are wrapped in double quotes. - /// Returns if the branch contains a complex non-scalar expression - /// that cannot be safely formatted. - /// - private static bool TryFormatBranch(string branchValue, out string formatted) - { - // If the branch is a scalar Helm expression (single {{ expr }}), - // add | quote to the pipeline for proper string quoting. - var scalarMatch = HelmExtensions.ScalarExpressionPattern().Match(branchValue); - if (scalarMatch.Success) - { - var innerExpr = scalarMatch.Value.TrimEnd(); - formatted = $"{{{{ {innerExpr} | quote }}}}"; - return true; - } - - // A branch with embedded (non-scalar) Helm expressions can't be safely - // formatted for if/else — signal the caller to fall back. - if (branchValue.ContainsHelmExpression()) - { - formatted = string.Empty; - return false; - } + // Extract the values path (e.g., .Values.parameters.myapp.enable_tls) from {{ expression }}. + // Pipe through | lower for case-insensitive comparison, matching .NET's + // StringComparison.OrdinalIgnoreCase used in other execution/publish paths. + var conditionPath = $"({HelmExtensions.ScalarExpressionPattern().Match(paramExpression).Value.Trim()} | lower)"; + var escapedMatch = (expr.MatchValue ?? string.Empty).ToLowerInvariant().Replace("\\", "\\\\").Replace("\"", "\\\""); - // Wrap literal values as Go template string expressions with | quote so the - // entire if/else block contains only {{ }} segments — no bare double quotes - // that would violate YAML plain scalar rules. - var escaped = branchValue.Replace("\\", "\\\\").Replace("\"", "\\\""); - formatted = $"{{{{ \"{escaped}\" | quote }}}}"; - return true; + var ifElseExpression = $"{{{{ if eq {conditionPath} \"{escapedMatch}\" }}}}{whenTrueStr}{{{{ else }}}}{whenFalseStr}{{{{ end }}}}"; + return HelmValue.Literal(ifElseExpression); } /// diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs index 1934f78f9d8..3c6e5c09f39 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs @@ -14,9 +14,7 @@ public class HelmExtensionsTests [InlineData("{{ .Values.config.myapp.port | int }}", false, ScalarStyle.ForcePlain)] [InlineData("{{ .Values.config.myapp.count | int64 }}", false, ScalarStyle.ForcePlain)] [InlineData("{{ .Values.config.myapp.rate | float64 }}", false, ScalarStyle.ForcePlain)] - [InlineData("{{ ternary \"a\" \"b\" (eq .Values.parameters.myapp.flag \"True\") | quote }}", false, ScalarStyle.ForcePlain)] - [InlineData("{{ ternary \",ssl=true\" \",ssl=false\" (eq .Values.parameters.myapp.enable_tls \"True\") | quote }}", false, ScalarStyle.ForcePlain)] - [InlineData("{{ if eq .Values.parameters.myapp.enable_tls \"True\" }}{{ .Values.config.myapp.tls_suffix | quote }}{{ else }}{{ \",ssl=false\" | quote }}{{ end }}", false, ScalarStyle.ForcePlain)] + [InlineData("{{ if eq (.Values.parameters.myapp.enable_tls | lower) \"true\" }},ssl=true{{ else }},ssl=false{{ end }}", false, ScalarStyle.ForcePlain)] public void ShouldDoubleQuoteString_ReturnsExpectedResult(string value, bool expectedShouldApply, ScalarStyle? expectedStyle) { var (shouldApply, style) = HelmExtensions.ShouldDoubleQuoteString(value); @@ -26,9 +24,8 @@ public void ShouldDoubleQuoteString_ReturnsExpectedResult(string value, bool exp } [Theory] - [InlineData("{{ ternary \"a\" \"b\" true }}")] - [InlineData("{{ ternary \"val1\" \"val2\" (eq .Values.x \"y\") | quote }}")] - [InlineData("{{ if eq .Values.parameters.myapp.flag \"True\" }}{{ .Values.config.myapp.suffix | quote }}{{ else }}{{ \"fallback\" | quote }}{{ end }}")] + [InlineData("{{ if eq (.Values.parameters.myapp.flag | lower) \"true\" }}valA{{ else }}valB{{ end }}")] + [InlineData("{{ if eq (.Values.parameters.myapp.enable_tls | lower) \"true\" }}{{ .Values.config.myapp.suffix }}{{ else }}fallback{{ end }}")] public void HelmFlowControlPattern_MatchesFlowControlExpressions(string value) { Assert.Matches(HelmExtensions.HelmFlowControlPattern(), value); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#03.verified.yaml index 3fd821c5bba..2823fb9bc2a 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#03.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#03.verified.yaml @@ -8,4 +8,4 @@ metadata: app.kubernetes.io/component: "myapp" app.kubernetes.io/instance: "{{ .Release.Name }}" data: - TLS_SUFFIX: {{ if eq .Values.parameters.myapp.enable_tls "True" }}{{ .Values.config.myapp.tls_suffix | quote }}{{ else }}{{ ",ssl=false" | quote }}{{ end }} + TLS_SUFFIX: {{ if eq (.Values.parameters.myapp.enable_tls | lower) "true" }}{{ .Values.config.myapp.tls_suffix }}{{ else }},ssl=false{{ end }} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml index 58089925047..303a7d529b0 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#03.verified.yaml @@ -8,4 +8,4 @@ metadata: app.kubernetes.io/component: "myapp" app.kubernetes.io/instance: "{{ .Release.Name }}" data: - TLS_SUFFIX: {{ ternary ",ssl=true" ",ssl=false" (eq .Values.parameters.myapp.enable_tls "True") | quote }} + TLS_SUFFIX: {{ if eq (.Values.parameters.myapp.enable_tls | lower) "true" }},ssl=true{{ else }},ssl=false{{ end }} From 0a0623a25428edb2f1cf7b4f7b9bdf6f276fa387 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 9 Mar 2026 19:30:48 -0700 Subject: [PATCH 45/45] Use toLower for case-insensitive Bicep conditional comparison Apply toLower() to condition values in Azure Container Apps and App Service Bicep ternary expressions, matching the OrdinalIgnoreCase semantics used in all other execution and publish paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BaseContainerAppContext.cs | 2 +- .../AzureAppServiceWebsiteContext.cs | 2 +- ...s.ConditionalExpressionWithParameterCondition.verified.bicep | 2 +- ...Tests.ConditionalBranchWithParameterReference.verified.bicep | 2 +- ...s.ConditionalExpressionWithParameterCondition.verified.bicep | 2 +- ...ntainerAppsTests.NestedConditionalExpressions.verified.bicep | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs b/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs index ba6da44f5a5..c0d14ac555f 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs @@ -313,7 +313,7 @@ BicepValue GetHostValue(string? prefix = null, string? suffix = null) var (whenFalseVal, falseSecret) = ProcessValue(expr.WhenFalse!, secretType, parent: expr); var conditional = new ConditionalExpression( - new BinaryExpression(ResolveValue(conditionVal).Compile(), BinaryBicepOperator.Equal, new StringLiteralExpression(expr.MatchValue!)), + new BinaryExpression(BicepFunction.ToLower(ResolveValue(conditionVal).Compile()).Compile(), BinaryBicepOperator.Equal, new StringLiteralExpression((expr.MatchValue ?? string.Empty).ToLowerInvariant())), ResolveValue(whenTrueVal).Compile(), ResolveValue(whenFalseVal).Compile()); diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs index 505ead5c9f4..acef6111184 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs @@ -222,7 +222,7 @@ private void ProcessEndpoints() var (whenFalseVal, falseSecret) = ProcessValue(expr.WhenFalse!, secretType, parent: expr, isSlot); var conditional = new ConditionalExpression( - new BinaryExpression(ResolveValue(conditionVal).Compile(), BinaryBicepOperator.Equal, new StringLiteralExpression(expr.MatchValue!)), + new BinaryExpression(BicepFunction.ToLower(ResolveValue(conditionVal).Compile()).Compile(), BinaryBicepOperator.Equal, new StringLiteralExpression((expr.MatchValue ?? string.Empty).ToLowerInvariant())), ResolveValue(whenTrueVal).Compile(), ResolveValue(whenFalseVal).Compile()); diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.bicep index 0ed13a0b450..88c8b48d134 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.bicep @@ -62,7 +62,7 @@ resource webapp 'Microsoft.Web/sites@2025-03-01' = { } { name: 'FEATURE_MODE' - value: (enable_feature_value == 'True') ? 'enabled' : 'disabled' + value: (toLower(enable_feature_value) == 'true') ? 'enabled' : 'disabled' } { name: 'ASPIRE_ENVIRONMENT_NAME' diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.bicep index a37bb9959f8..a2ea7208da3 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.bicep @@ -46,7 +46,7 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { } { name: 'FEATURE_CONNECTION' - value: (enable_feature_value == 'True') ? 'prefix-${connection_prefix_value}-enabled' : 'disabled' + value: (toLower(enable_feature_value) == 'true') ? 'prefix-${connection_prefix_value}-enabled' : 'disabled' } ] } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.bicep index 361cf07d6d5..e2a1cf8b5f4 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.bicep @@ -44,7 +44,7 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { } { name: 'FEATURE_MODE' - value: (enable_feature_value == 'True') ? 'enabled' : 'disabled' + value: (toLower(enable_feature_value) == 'true') ? 'enabled' : 'disabled' } ] } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.bicep index b80b9a53880..2848af2685e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.bicep @@ -46,7 +46,7 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { } { name: 'NESTED_FEATURE' - value: (outer_flag_value == 'True') ? (inner_flag_value == 'True') ? 'inner-true' : 'inner-false' : 'outer-false' + value: (toLower(outer_flag_value) == 'true') ? (toLower(inner_flag_value) == 'true') ? 'inner-true' : 'inner-false' : 'outer-false' } ] }