diff --git a/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs b/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs index 1ff4adbf187..c0d14ac555f 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,43 @@ BicepValue GetHostValue(string? prefix = null, string? suffix = null) if (value is ReferenceExpression expr) { + // 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 conditional = new ConditionalExpression( + new BinaryExpression(BicepFunction.ToLower(ResolveValue(conditionVal).Compile()).Compile(), BinaryBicepOperator.Equal, new StringLiteralExpression((expr.MatchValue ?? string.Empty).ToLowerInvariant())), + 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 6fac60a28b1..3ba6954b84b 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"); } @@ -233,7 +233,7 @@ static bool Compatible(string[] schemes) => 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[] schemes) => 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.AppService/AzureAppServiceWebsiteContext.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs index 889a8b9fd83..acef6111184 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(BicepFunction.ToLower(ResolveValue(conditionVal).Compile()).Compile(), BinaryBicepOperator.Equal, new StringLiteralExpression((expr.MatchValue ?? string.Empty).ToLowerInvariant())), + 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.Azure/AzurePublishingContext.cs b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs index 9acbc4c7374..76b409f8f68 100644 --- a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs +++ b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs @@ -200,12 +200,38 @@ FormattableString EvalExpr(ReferenceExpression expr) return FormattableStringFactory.Create(expr.Format, args); } + object EvalConditionalExpr(ReferenceExpression expr) + { + 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(ResolveValue(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/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go index 2be797c6c7f..1eee691a142 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go +++ b/src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go @@ -37,16 +37,38 @@ func NewResourceBuilderBase(handle *Handle, client *AspireClient) ResourceBuilde } // ReferenceExpression represents a reference expression. +// Supports value mode (Format + Args) and conditional mode (Condition + WhenTrue + WhenFalse). type ReferenceExpression struct { Format string Args []any + + // Conditional mode fields + Condition any + WhenTrue *ReferenceExpression + WhenFalse *ReferenceExpression + MatchValue string + 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} } +// NewConditionalReferenceExpression creates a conditional reference expression from its parts. +func NewConditionalReferenceExpression(condition any, matchValue string, whenTrue *ReferenceExpression, whenFalse *ReferenceExpression) *ReferenceExpression { + if matchValue == "" { + matchValue = "True" + } + return &ReferenceExpression{ + Condition: condition, + WhenTrue: whenTrue, + WhenFalse: whenFalse, + MatchValue: matchValue, + isConditional: true, + } +} + // RefExpr is a convenience function for creating reference expressions. func RefExpr(format string, args ...any) *ReferenceExpression { return NewReferenceExpression(format, args...) @@ -54,8 +76,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.isConditional { + return map[string]any{ + "$expr": map[string]any{ + "condition": SerializeValue(r.Condition), + "whenTrue": r.WhenTrue.ToJSON(), + "whenFalse": r.WhenFalse.ToJSON(), + "matchValue": r.MatchValue, + }, + } + } 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.Java/Resources/Base.java b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java index 98a1aa43838..0d4a8d5c014 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java +++ b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java @@ -37,14 +37,40 @@ class ResourceBuilderBase extends HandleWrapperBase { /** * ReferenceExpression represents a reference expression. + * Supports value mode (format + args) and conditional mode (condition + whenTrue + whenFalse). */ 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 String matchValue; + private final boolean isConditional; + + // Value mode constructor ReferenceExpression(String format, Object... args) { this.format = format; this.args = args; + this.condition = null; + this.whenTrue = null; + this.whenFalse = null; + this.matchValue = null; + this.isConditional = false; + } + + // Conditional mode constructor + private ReferenceExpression(Object condition, String matchValue, ReferenceExpression whenTrue, ReferenceExpression whenFalse) { + this.condition = condition; + this.whenTrue = whenTrue; + this.whenFalse = whenFalse; + this.matchValue = matchValue != null ? matchValue : "True"; + this.isConditional = true; + this.format = null; + this.args = null; } String getFormat() { @@ -56,6 +82,18 @@ Object[] getArgs() { } Map 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()); + condPayload.put("matchValue", matchValue); + + 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,6 +109,13 @@ Map toJson() { static ReferenceExpression refExpr(String format, Object... args) { return new ReferenceExpression(format, args); } + + /** + * Creates a conditional reference expression from its parts. + */ + 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 8ba4c32d25b..da1bf879dfc 100644 --- a/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py +++ b/src/Aspire.Hosting.CodeGeneration.Python/Resources/base.py @@ -8,24 +8,54 @@ 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._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 def create(format_string: str, *values: Any) -> "ReferenceExpression": value_providers = [_extract_reference_value(value) for value in values] return ReferenceExpression(format_string, value_providers) + @staticmethod + 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 = "" + expr._value_providers = [] + expr._condition = condition + expr._when_true = when_true + expr._when_false = when_false + expr._match_value = match_value + expr._is_conditional = True + return expr + def to_json(self) -> Dict[str, Any]: + if self._is_conditional: + return { + "$expr": { + "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} if self._value_providers: payload["valueProviders"] = self._value_providers return {"$expr": payload} def __str__(self) -> str: + 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 2554b29de2d..88dfb7c9822 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/Resources/base.rs @@ -50,25 +50,66 @@ impl ResourceBuilderBase { } /// A reference expression for dynamic values. +/// 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")] + 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, } 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, + match_value: None, + is_conditional: false, + } + } + + /// 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: 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.into()), + is_conditional: true, } } pub fn to_json(&self) -> Value { + if self.is_conditional { + return json!({ + "$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(), + "matchValue": self.match_value.as_ref().unwrap() + } + }); + } json!({ - "$refExpr": { - "format": self.format, - "args": self.args + "$expr": { + "format": self.format.as_deref().unwrap_or_default(), + "args": self.args.as_deref().unwrap_or_default() } }) } diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts index 7778b0f1737..9a3427e7e72 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.ts @@ -43,22 +43,46 @@ 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; + private readonly _matchValue?: string; + // 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, matchValue: string, whenTrue: ReferenceExpression, whenFalse: ReferenceExpression); + constructor( + handleOrFormatOrCondition: Handle | string | unknown, + clientOrValueProvidersOrMatchValue: AspireClient | unknown[] | string, + whenTrueOrWhenFalse?: ReferenceExpression, + whenFalse?: ReferenceExpression + ) { + if (typeof handleOrFormatOrCondition === 'string') { + this._format = handleOrFormatOrCondition; + this._valueProviders = clientOrValueProvidersOrMatchValue as unknown[]; + } else if (handleOrFormatOrCondition instanceof Handle) { + this._handle = handleOrFormatOrCondition; + this._client = clientOrValueProvidersOrMatchValue as AspireClient; } else { - this._handle = handleOrFormat; - this._client = clientOrValueProviders as AspireClient; + this._condition = handleOrFormatOrCondition; + this._matchValue = (clientOrValueProvidersOrMatchValue as string) ?? 'True'; + this._whenTrue = whenTrueOrWhenFalse; + 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 +106,46 @@ 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 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, + matchValue: string, + whenTrue: ReferenceExpression, + whenFalse: ReferenceExpression + ): ReferenceExpression { + return new ReferenceExpression(condition, matchValue, 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; matchValue: string } } | MarshalledHandle { if (this._handle) { return this._handle.toJSON(); } + if (this.isConditional) { + return { + $expr: { + condition: this._condition instanceof Handle ? this._condition.toJSON() : this._condition, + whenTrue: this._whenTrue!.toJSON(), + whenFalse: this._whenFalse!.toJSON(), + matchValue: this._matchValue! + } + }; + } + return { $expr: { format: this._format!, @@ -107,6 +161,9 @@ export class ReferenceExpression { if (this._handle) { return `ReferenceExpression(handle)`; } + if (this.isConditional) { + return `ReferenceExpression(conditional)`; + } return `ReferenceExpression(${this._format})`; } } 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 4eeda5d4ca4..c8dd226dbee 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/Extensions/HelmExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs index f14c5c6475e..4d962245ff6 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs @@ -91,11 +91,34 @@ 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); + // 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); + } + + if (!ScalarExpressionPattern().IsMatch(value)) + { + return (true, null); + } + + // Scalar Helm expressions that contain type conversions (| int, | float64, etc.) + // 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); + } + + return (true, null); } + [GeneratedRegex(@"^\{\{\s*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 83b80665328..737ad0b60b2 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -23,6 +23,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; } = []; @@ -430,6 +431,25 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex if (value is ReferenceExpression expr) { + if (expr.IsConditional) + { + // 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 BuildHelmConditional(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); + + 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; @@ -459,6 +479,69 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex } } + 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); + var whenFalseResult = await ProcessValueAsync(context, executionContext, expr.WhenFalse!, embedded).ConfigureAwait(false); + + var whenTrueStr = whenTrueResult.ToString() ?? string.Empty; + var whenFalseStr = whenFalseResult.ToString() ?? string.Empty; + + // 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); + } + + // Ensure parameter values referenced in branches are populated in values.yaml. + AllocateBranchParameters(expr.WhenTrue!); + AllocateBranchParameters(expr.WhenFalse!); + + // 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("\"", "\\\""); + + var ifElseExpression = $"{{{{ if eq {conditionPath} \"{escapedMatch}\" }}}}{whenTrueStr}{{{{ else }}}}{whenFalseStr}{{{{ end }}}}"; + return HelmValue.Literal(ifElseExpression); + } + + /// + /// 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/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 0eeac0835ce..49d2d81feb1 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: RedisResource.StandardRedisScheme) .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 = RedisResource.TlsRedisScheme; + 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 b1121f1a344..6bbfd01410e 100644 --- a/src/Aspire.Hosting.Redis/RedisResource.cs +++ b/src/Aspire.Hosting.Redis/RedisResource.cs @@ -31,6 +31,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; /// @@ -54,9 +64,17 @@ public RedisResource(string name, ParameterResource password) : this(name) public ParameterResource? PasswordParameter { get; private set; } /// - /// Determines whether Tls is enabled for the resource + /// Indicates 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 @@ -73,10 +91,9 @@ private ReferenceExpression BuildConnectionString() builder.Append($",password={PasswordParameter}"); } - if (TlsEnabled) - { - builder.Append($",ssl=true"); - } + builder.Append($"{PrimaryEndpoint.GetTlsValue( + enabledValue: ReferenceExpression.Create($",ssl=true"), + disabledValue: ReferenceExpression.Empty)}"); return builder.Build(); } @@ -128,14 +145,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.RemoteHost/Ats/AtsMarshaller.cs b/src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs index 6fb5cff2bd5..41112125fd8 100644 --- a/src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs +++ b/src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs @@ -300,8 +300,9 @@ 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) { diff --git a/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs b/src/Aspire.Hosting.RemoteHost/Ats/ReferenceExpressionRef.cs index c3e17861f43..4a8e91cfb6a 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,42 @@ namespace Aspire.Hosting.RemoteHost.Ats; /// } /// } /// +/// Conditional mode — a ternary expression selecting between two branch expressions: +/// +/// { +/// "$expr": { +/// "condition": { "$handle": "Aspire.Hosting.ApplicationModel/EndpointReferenceExpression:1" }, +/// "matchValue": "true", +/// "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; } + public string? MatchValue { 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 +77,30 @@ 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); + + 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, + MatchValue = matchValue + }; + } + + // Value mode: format + optional valueProviders if (!exprObj.TryGetPropertyValue("format", out var formatNode) || formatNode is not JsonValue formatValue || !formatValue.TryGetValue(out var format)) @@ -103,6 +139,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 +150,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, MatchValue ?? bool.TrueString, 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 +238,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/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index cccc6438924..4ed71ad3b52 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; /// @@ -168,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; } @@ -184,6 +185,22 @@ 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 => _tlsEnabled ?? string.Equals(UriScheme, "https", StringComparisons.EndpointAnnotationUriScheme); + 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 08f3fb8b104..cca32a3378a 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(); /// @@ -100,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}'.") }; @@ -116,6 +126,30 @@ public EndpointReferenceExpression Property(EndpointProperty property) return new(this, property); } + /// + /// Creates a conditional that resolves to when + /// is on this endpoint, or to + /// otherwise. + /// + /// + /// 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). Because the condition and branches are declarative, + /// polyglot code generators can translate this into native conditional constructs in any target language. + /// + /// The expression to evaluate when TLS is enabled (e.g., ",ssl=true"). + /// The expression to evaluate when TLS is not enabled. + /// 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 ReferenceExpression GetTlsValue(ReferenceExpression enabledValue, ReferenceExpression disabledValue) + { + return ReferenceExpression.CreateConditional( + Property(EndpointProperty.TlsEnabled), + bool.TrueString, + enabledValue, + disabledValue); + } + /// /// Gets the port for this endpoint. /// @@ -302,6 +336,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) @@ -370,5 +405,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/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]; diff --git a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs index a005d34ba35..d879167ce38 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,16 @@ 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? _matchValue; + private readonly string? _name; + private ReferenceExpression(string format, IValueProvider[] valueProviders, string[] manifestExpressions, string?[] stringFormats) { ArgumentNullException.ThrowIfNull(format); @@ -38,6 +57,28 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri _stringFormats = stringFormats; } + 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; + _matchValue = matchValue; + _name = GenerateConditionalName(condition, matchValue, whenTrue, whenFalse); + + // 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 = whenTrue.ValueProviders.Concat(whenFalse.ValueProviders).ToArray(); + _manifestExpressions = []; + _stringFormats = []; + } + /// /// The format string for this expression. /// @@ -58,13 +99,87 @@ 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 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 . + /// + internal string? Name => _name; + + IEnumerable IValueWithReferences.References + { + get + { + 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) + { + 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 +188,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, _matchValue, 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 +241,77 @@ 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 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, string matchValue, ReferenceExpression whenTrue, ReferenceExpression whenFalse) + { + return new ReferenceExpression(condition, matchValue, whenTrue, whenFalse); + } + + private static string GenerateConditionalName(IValueProvider condition, string matchValue, 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, matchValue); + return $"{baseName}-{hash}"; + } + + private static string ComputeConditionalHash(IValueProvider condition, ReferenceExpression whenTrue, ReferenceExpression whenFalse, string matchValue) + { + 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)); + xxHash.Append(Encoding.UTF8.GetBytes(matchValue)); + + 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/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index 64d5b3a79ed..62254188cf2 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(); @@ -85,7 +87,6 @@ internal async Task WriteModel(DistributedApplicationModel model, CancellationTo } Writer.WriteStartObject(); - Writer.WriteString("$schema", SchemaUtils.SchemaVersion); Writer.WriteStartObject("resources"); foreach (var resource in model.Resources) @@ -97,6 +98,8 @@ internal async Task WriteModel(DistributedApplicationModel model, CancellationTo WriteRemainingFormattedParameters(); + await WriteConditionalExpressionsAsync().ConfigureAwait(false); + Writer.WriteEndObject(); Writer.WriteEndObject(); @@ -665,6 +668,7 @@ public void TryAddDependentResources(object? value) if (value is ReferenceExpression referenceExpression) { RegisterFormattedParameters(referenceExpression); + RegisterConditionalExpressions(referenceExpression); } if (value is IResource resource) @@ -725,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; @@ -741,6 +752,24 @@ 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 nestedName } conditional) + { + _conditionalExpressions.TryAdd(nestedName, conditional); + _manifestResourceNames.Add(nestedName); + } + } + } + private string RegisterFormattedParameter(ParameterResource parameter, string format) { if (!_formattedParameters.TryGetValue(parameter, out var formats)) @@ -836,6 +865,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("connectionString", resolvedValue ?? string.Empty); + Writer.WriteEndObject(); + } + + _conditionalExpressions.Clear(); + } + private string? GetFormattedResourceNameForProvider(object provider, string format) { return provider switch 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/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/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 805b116aead..acd4e01168d 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] @@ -2286,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() @@ -2352,4 +2367,193 @@ 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"); + } + + [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"); + } + + [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/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.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.ConditionalExpressionWithParameterCondition.verified.bicep new file mode 100644 index 00000000000..88c8b48d134 --- /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: (toLower(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.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.ConditionalBranchWithParameterReference.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalBranchWithParameterReference.verified.bicep new file mode 100644 index 00000000000..a2ea7208da3 --- /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: (toLower(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.ConditionalExpressionWithParameterCondition.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ConditionalExpressionWithParameterCondition.verified.bicep new file mode 100644 index 00000000000..e2a1cf8b5f4 --- /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: (toLower(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.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.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.NestedConditionalExpressions.verified.bicep new file mode 100644 index 00000000000..2848af2685e --- /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: (toLower(outer_flag_value) == 'true') ? (toLower(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 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..eb14f74e798 --- /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}' + } + { + 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 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 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 706f2278280..f57ce754101 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -124,6 +124,7 @@ const ( EndpointPropertyScheme EndpointProperty = "Scheme" EndpointPropertyTargetPort EndpointProperty = "TargetPort" EndpointPropertyHostAndPort EndpointProperty = "HostAndPort" + EndpointPropertyTlsEnabled EndpointProperty = "TlsEnabled" ) // UrlDisplayLocation represents UrlDisplayLocation. @@ -5059,6 +5060,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{ @@ -5134,6 +5147,20 @@ 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(enabledValue *ReferenceExpression, disabledValue *ReferenceExpression) (*ReferenceExpression, error) { + reqArgs := map[string]any{ + "context": SerializeValue(s.Handle()), + } + 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.(*ReferenceExpression), 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 27fb16e9e7e..a6c99a6cc56 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -236,7 +236,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; @@ -3925,6 +3926,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<>(); @@ -3970,6 +3978,15 @@ 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 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 (ReferenceExpression) 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 81eea3eea6a..37991b6e630 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -89,6 +89,7 @@ class EndpointProperty(str, Enum): SCHEME = "Scheme" TARGET_PORT = "TargetPort" HOST_AND_PORT = "HostAndPort" + TLS_ENABLED = "TlsEnabled" class UrlDisplayLocation(str, Enum): SUMMARY_AND_DETAILS = "SummaryAndDetails" @@ -2730,6 +2731,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) } @@ -2763,6 +2769,13 @@ 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) -> 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) + 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): 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 105b92a734d..cb197c538d5 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -296,6 +296,8 @@ pub enum EndpointProperty { TargetPort, #[serde(rename = "HostAndPort")] HostAndPort, + #[serde(rename = "TlsEnabled")] + TlsEnabled, } impl std::fmt::Display for EndpointProperty { @@ -308,6 +310,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"), } } } @@ -4750,6 +4753,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(); @@ -4801,6 +4812,16 @@ 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, 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)); + 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 98d0607cf43..b19160ad003 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -215,6 +215,7 @@ export enum EndpointProperty { Scheme = "Scheme", TargetPort = "TargetPort", HostAndPort = "HostAndPort", + TlsEnabled = "TlsEnabled", } /** Enum type for IconVariant */ @@ -812,6 +813,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 => { @@ -873,6 +884,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(enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { + const rpcArgs: Record = { context: this._handle, enabledValue, disabledValue }; + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue', + rpcArgs + ); + } + } /** @@ -893,6 +913,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(enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise { + return this._promise.then(obj => obj.getTlsValue(enabledValue, disabledValue)); + } + } // ============================================================================ 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.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/HelmExtensionsTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs new file mode 100644 index 00000000000..3c6e5c09f39 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/HelmExtensionsTests.cs @@ -0,0 +1,41 @@ +// 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("{{ 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); + + Assert.Equal(expectedShouldApply, shouldApply); + Assert.Equal(expectedStyle, style); + } + + [Theory] + [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); + } + + [Theory] + [InlineData("{{ .Values.config.myapp.key }}")] + [InlineData("plain text")] + public void HelmFlowControlPattern_DoesNotMatchNonFlowControlExpressions(string 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 654b8cf29b4..92d7f73d92e 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -449,6 +449,187 @@ 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; + } + + [Fact] + 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, 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); + + 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"; + + 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_ConditionalWithParameterBranch_UsesIfElseSyntax#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#00.verified.yaml new file mode 100644 index 00000000000..d05c0dbf228 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#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_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_UsesIfElseSyntax#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#02.verified.yaml new file mode 100644 index 00000000000..37de00c4947 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#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_UsesIfElseSyntax#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#03.verified.yaml new file mode 100644 index 00000000000..2823fb9bc2a --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#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: {{ 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_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..0734dae1ada --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#01.verified.yaml @@ -0,0 +1,5 @@ +parameters: + myapp: + enable_tls: "True" +secrets: {} +config: {} 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..303a7d529b0 --- /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: {{ if eq (.Values.parameters.myapp.enable_tls | lower) "true" }},ssl=true{{ else }},ssl=false{{ end }} diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index 2d54dc90ef9..6cfea90b8c9 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -4,6 +4,8 @@ using System.Net.Sockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; @@ -44,7 +46,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 +74,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); @@ -94,7 +96,9 @@ public void RedisCreatesConnectionStringWithPassword() 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] @@ -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": "tcp", - "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": "tcp", - "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": "tcp", - "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": "tcp", - "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); } @@ -835,6 +813,69 @@ public async Task RedisWithCertificateHasCorrectConnectionString() await builder.Eventing.PublishAsync(beforeStartEvent); Assert.True(redis.Resource.TlsEnabled); + + // The connection string expression uses a conditional reference for TLS + var connectionStringExpression = redis.Resource.ConnectionStringExpression; + 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"); + 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 uses a conditional reference (not literal ,ssl=true) + var expressionBeforeTls = redis.Resource.ConnectionStringExpression; + AssertContainsConditionalReference(expressionBeforeTls.ValueExpression); + Assert.DoesNotContain(",ssl=true", expressionBeforeTls.ValueExpression); + + // 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 has a conditional reference + var expressionAfterTls = redis.Resource.ConnectionStringExpression; + AssertContainsConditionalReference(expressionAfterTls.ValueExpression); } [Fact] @@ -850,6 +891,11 @@ public void RedisWithoutCertificateHasCorrectConnectionString() // Simulate the BeforeStartEvent var beforeStartEvent = new BeforeStartEvent(app.Services, appModel); Assert.False(redis.Resource.TlsEnabled); + + // 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); } private static X509Certificate2 CreateTestCertificate() @@ -869,4 +915,18 @@ private static X509Certificate2 CreateTestCertificate() return certificate; } + + private static string AssertContainsConditionalReference(string 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; + } + + 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.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.RemoteHost.Tests/AtsMarshallerTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs index 1b4865ac792..b61b02fd12b 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 = ReferenceExpression.CreateConditional(condition, bool.TrueString, 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 = ReferenceExpression.CreateConditional(condition, bool.TrueString, 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 = ReferenceExpression.CreateConditional(condition, bool.TrueString, 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.StartsWith("cond-test-condition", retrievedConditional.Name); + Assert.Equal(",ssl=true", await retrievedConditional.GetValueAsync(default)); + } + + [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 = ReferenceExpression.CreateConditional(condition, bool.TrueString, 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(default)); + } + + 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_UnmarshalsCondExprToReferenceExpression() + { + var registry = new HandleRegistry(); + + // Register a condition IValueProvider as a handle + var condition = new TestConditionValueProvider(bool.TrueString); + var conditionHandleId = registry.Register(condition, AtsConstants.ReferenceExpressionTypeId); + + var (marshaller, context) = CreateMarshallerWithContext(registry); + + // Build the unified $expr JSON with conditional mode + var json = new JsonObject + { + ["$expr"] = 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(ReferenceExpression), 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.ReferenceExpressionTypeId); + + var (marshaller, context) = CreateMarshallerWithContext(registry); + + var json = new JsonObject + { + ["$expr"] = 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(ReferenceExpression), context); + var cre = Assert.IsType(result); + var value = await cre.GetValueAsync(default); + + 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.ReferenceExpressionTypeId); + + var (marshaller, context) = CreateMarshallerWithContext(registry); + + var json = new JsonObject + { + ["$expr"] = 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(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); + } + + [Fact] + public void UnmarshalFromJson_CondExpr_ThrowsWhenConditionHandleMissing() + { + var registry = new HandleRegistry(); + var (marshaller, context) = CreateMarshallerWithContext(registry); + + var json = new JsonObject + { + ["$expr"] = 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(ReferenceExpression), context)); + } } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/HandleRegistryTests.cs index bd9eb63e29d..04c4582e4e5 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 = ReferenceExpression.CreateConditional( + condition, bool.TrueString, ReferenceExpression.Create($",ssl=true"), ReferenceExpression.Empty); + var typeId = AtsConstants.ReferenceExpressionTypeId; + + 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..050617de3d7 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/ConditionalReferenceExpressionTests.cs @@ -0,0 +1,185 @@ +// 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 = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); + + var value = await expr.GetValueAsync(default); + 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 = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); + + var value = await expr.GetValueAsync(default); + 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 = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); + + Assert.StartsWith("{cond-", expr.ValueExpression); + Assert.EndsWith(".connectionString}", expr.ValueExpression); + } + + [Fact] + public async Task ConditionalReferenceExpression_WorksInReferenceExpressionBuilder() + { + var condition = new TestValueProvider(bool.FalseString); + var whenTrue = ReferenceExpression.Create($",ssl=true"); + var whenFalse = ReferenceExpression.Empty; + + var conditional = ReferenceExpression.CreateConditional(condition, bool.TrueString, 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 = ReferenceExpression.CreateConditional(condition, bool.TrueString, whenTrue, whenFalse); + + var references = ((IValueWithReferences)expr).References.ToList(); + Assert.Contains(endpointRef, references); + } + + [Fact] + public void Name_IsAutoGeneratedFromCondition() + { + var expr = ReferenceExpression.CreateConditional(new TestValueProvider(bool.TrueString), 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/ManifestGenerationTests.cs b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs index 7ded2b36aba..102625d37fd 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-c16dc063.connectionString}", connectionString); } [Fact] @@ -296,7 +297,6 @@ public void VerifyTestProgramFullManifest() var expectedManifest = $$""" { - "$schema": "{{SchemaUtils.SchemaVersion}}", "resources": { "servicea": { "type": "project.v0", @@ -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}", @@ -407,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-d148d83a.connectionString}", "image": "{{ComponentTestConstants.AspireTestContainerRegistry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}", "entrypoint": "/bin/sh", "args": [ @@ -419,7 +419,7 @@ public void VerifyTestProgramFullManifest() }, "bindings": { "tcp": { - "scheme": "tcp", + "scheme": "redis", "protocol": "tcp", "transport": "tcp", "targetPort": 6379 @@ -490,6 +490,10 @@ public void VerifyTestProgramFullManifest() "type": "annotated.string", "value": "{postgres-password.value}", "filter": "uri" + }, + "cond-redis-bindings-tcp-tlsenabled-d148d83a": { + "type": "value.v0", + "connectionString": "" } } } @@ -558,10 +562,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 +605,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 +651,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(); 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); + } } 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); + } } 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(); 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))