Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0cf7f52
Add a builder pattern for resolving resource configuration
danegsta Nov 27, 2025
80ef95c
Update services available to test
danegsta Dec 1, 2025
2211dc8
Make configuration gatherer implementations internal
danegsta Dec 1, 2025
badbfef
Apply suggestion from @Copilot
danegsta Dec 1, 2025
a1fcffc
Remove unused code
danegsta Dec 1, 2025
a140660
Update src/Aspire.Hosting/ApplicationModel/ResourceServerAuthenticati…
danegsta Dec 1, 2025
10ce666
Update src/Aspire.Hosting/ApplicationModel/ResourceCertificateTrustCo…
danegsta Dec 1, 2025
dc3a8d0
Update src/Aspire.Hosting/ApplicationModel/ResourceServerAuthenticati…
danegsta Dec 1, 2025
1e1a448
Update src/Aspire.Hosting/ApplicationModel/ResourceConfigurationGathe…
danegsta Dec 1, 2025
4e48098
Update doc comment
danegsta Dec 1, 2025
1a487e3
Add missing bracket
danegsta Dec 1, 2025
b90751d
Add test cases for new configuration gatherer implmentations
danegsta Dec 1, 2025
4d4b8c7
Fix missing paramater
danegsta Dec 1, 2025
2bce908
Rename Configuration to ExecutionConfiguration
danegsta Dec 1, 2025
ffdcc62
Merge remote-tracking branch 'upstream/main' into danegsta/referenceT…
danegsta Dec 1, 2025
9fae909
Convert resource evaluation to new bulder pattern
danegsta Dec 2, 2025
7b22da7
Fix failing tests after update
danegsta Dec 2, 2025
6c07355
Check for inner exception
danegsta Dec 2, 2025
16c2035
Switch to returning a tuple
danegsta Dec 3, 2025
c866b9b
Missed one last place to update the tuple
danegsta Dec 3, 2025
4d3c949
Add an extension method to retrieve the builder directly from a resource
danegsta Dec 3, 2025
e14e6ff
Handle empty create file results
danegsta Dec 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 25 additions & 44 deletions src/Aspire.Hosting.Maui/Utilities/MauiEnvironmentHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,30 +36,24 @@ internal static class MauiEnvironmentHelper
var environmentVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var encodedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

// Collect all environment variables from the resource
await resource.ProcessEnvironmentVariableValuesAsync(
executionContext,
(key, unprocessed, processed, ex) =>
{
if (ex is not null || string.IsNullOrEmpty(key) || processed is not string value)
{
return;
}
(var executionConfiguration, _) = await resource.ExecutionConfigurationBuilder()
.WithEnvironmentVariables()
.BuildAsync(executionContext, logger, cancellationToken)
.ConfigureAwait(false);

// Android environment variables must be uppercase to be properly read by the runtime
var normalizedKey = key.ToUpperInvariant();
var encodedValue = EncodeSemicolons(value, out var wasEncoded);
// Normalize all the environment variables for the resource
foreach (var envVar in executionConfiguration.EnvironmentVariables)
{
var normalizedKey = envVar.Key.ToUpperInvariant();
var encodedValue = EncodeSemicolons(envVar.Value, out var wasEncoded);

environmentVariables[normalizedKey] = encodedValue;
environmentVariables[normalizedKey] = encodedValue;

if (wasEncoded)
{
encodedKeys.Add(normalizedKey);
}
},
logger,
cancellationToken: cancellationToken
).ConfigureAwait(false);
if (wasEncoded)
{
encodedKeys.Add(normalizedKey);
}
}

// If no environment variables, return null
if (environmentVariables.Count == 0)
Expand Down Expand Up @@ -226,26 +220,13 @@ private static string EncodeSemicolons(string value, out bool wasEncoded)
ILogger logger,
CancellationToken cancellationToken)
{
var environmentVariables = new Dictionary<string, string>(StringComparer.Ordinal);

// Collect all environment variables from the resource
await resource.ProcessEnvironmentVariableValuesAsync(
executionContext,
(key, unprocessed, processed, ex) =>
{
if (ex is not null || string.IsNullOrEmpty(key) || processed is not string value)
{
return;
}

environmentVariables[key] = value;
},
logger,
cancellationToken: cancellationToken
).ConfigureAwait(false);
(var executionConfiguration, _) = await resource.ExecutionConfigurationBuilder()
.WithEnvironmentVariables()
.BuildAsync(executionContext, logger, cancellationToken)
.ConfigureAwait(false);

// If no environment variables, return null
if (environmentVariables.Count == 0)
if (!executionConfiguration.EnvironmentVariables.Any())
{
return null;
}
Expand All @@ -262,7 +243,7 @@ await resource.ProcessEnvironmentVariableValuesAsync(
var targetsFilePath = Path.Combine(tempDirectory, $"{sanitizedName}-{uniqueId}.targets");

// Generate the targets file content
var targetsContent = GenerateiOSTargetsFileContent(environmentVariables);
var targetsContent = GenerateiOSTargetsFileContent(executionConfiguration.EnvironmentVariables.ToDictionary());

// Write the file
await File.WriteAllTextAsync(targetsFilePath, targetsContent, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
Expand All @@ -287,17 +268,17 @@ private static string GenerateiOSTargetsFileContent(Dictionary<string, string> e
// Create an ItemGroup to add environment variables using MlaunchEnvironmentVariables
// iOS apps need environment variables passed to mlaunch as KEY=VALUE pairs
var itemGroup = new XElement("ItemGroup");

foreach (var (key, value) in environmentVariables.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
// Encode semicolons as %3B to prevent MSBuild from treating them as item separators
var encodedValue = value.Replace(";", "%3B", StringComparison.Ordinal);

// Add as MlaunchEnvironmentVariables item with Include="KEY=VALUE"
itemGroup.Add(new XElement("MlaunchEnvironmentVariables",
itemGroup.Add(new XElement("MlaunchEnvironmentVariables",
new XAttribute("Include", $"{key}={encodedValue}")));
}

projectElement.Add(itemGroup);

// Add a diagnostic message target to show what's being forwarded
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Gathers command line arguments for resources.
/// </summary>
internal class ArgumentsExecutionConfigurationGatherer : IResourceExecutionConfigurationGatherer
{
/// <inheritdoc/>
public async ValueTask GatherAsync(IResourceExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default)
{
if (resource.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var callbacks))
{
var callbackContext = new CommandLineArgsCallbackContext(context.Arguments, resource, cancellationToken)
{
Logger = resourceLogger,
ExecutionContext = executionContext
};

foreach (var callback in callbacks)
{
await callback.Callback(callbackContext).ConfigureAwait(false);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// 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

using System.Security.Cryptography.X509Certificates;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Gathers certificate trust configuration for resources that require it.
/// </summary>
internal class CertificateTrustExecutionConfigurationGatherer : IResourceExecutionConfigurationGatherer
{
private readonly Func<CertificateTrustScope, CertificateTrustExecutionConfigurationContext> _configContextFactory;

/// <summary>
/// Initializes a new instance of <see cref="CertificateTrustExecutionConfigurationGatherer"/>.
/// </summary>
/// <param name="configContextFactory">A factory for configuring certificate trust configuration properties.</param>
public CertificateTrustExecutionConfigurationGatherer(Func<CertificateTrustScope, CertificateTrustExecutionConfigurationContext> configContextFactory)
{
_configContextFactory = configContextFactory;
}

/// <inheritdoc/>
public async ValueTask GatherAsync(IResourceExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default)
{
var developerCertificateService = executionContext.ServiceProvider.GetRequiredService<IDeveloperCertificateService>();
var trustDevCert = developerCertificateService.TrustCertificate;

// Add additional certificate trust configuration metadata
var additionalData = new CertificateTrustExecutionConfigurationData();
context.AddAdditionalData(additionalData);

additionalData.Scope = CertificateTrustScope.Append;
var certificates = new X509Certificate2Collection();
if (resource.TryGetLastAnnotation<CertificateAuthorityCollectionAnnotation>(out var caAnnotation))
{
foreach (var certCollection in caAnnotation.CertificateAuthorityCollections)
{
certificates.AddRange(certCollection.Certificates);
}

trustDevCert = caAnnotation.TrustDeveloperCertificates.GetValueOrDefault(trustDevCert);
additionalData.Scope = caAnnotation.Scope.GetValueOrDefault(additionalData.Scope);
}

if (additionalData.Scope == CertificateTrustScope.None)
{
// No certificate trust configuration to apply
return;
}

if (additionalData.Scope == CertificateTrustScope.System)
{
// Read the system root certificates and add them to the collection
certificates.AddRootCertificates();
}

if (executionContext.IsRunMode && trustDevCert)
{
foreach (var cert in developerCertificateService.Certificates)
{
certificates.Add(cert);
}
}

additionalData.Certificates.AddRange(certificates);

if (!additionalData.Certificates.Any())
{
// No certificates to configure
resourceLogger.LogInformation("No custom certificate authorities to configure for '{ResourceName}'. Default certificate authority trust behavior will be used.", resource.Name);
return;
}

var configurationContext = _configContextFactory(additionalData.Scope);

// Apply default OpenSSL environment configuration for certificate trust
context.EnvironmentVariables["SSL_CERT_DIR"] = configurationContext.CertificateDirectoriesPath;

if (additionalData.Scope != CertificateTrustScope.Append)
{
context.EnvironmentVariables["SSL_CERT_FILE"] = configurationContext.CertificateBundlePath;
}

var callbackContext = new CertificateTrustConfigurationCallbackAnnotationContext
{
ExecutionContext = executionContext,
Resource = resource,
Scope = additionalData.Scope,
CertificateBundlePath = configurationContext.CertificateBundlePath,
CertificateDirectoriesPath = configurationContext.CertificateDirectoriesPath,
Arguments = context.Arguments,
EnvironmentVariables = context.EnvironmentVariables,
CancellationToken = cancellationToken,
};

if (resource.TryGetAnnotationsOfType<CertificateTrustConfigurationCallbackAnnotation>(out var callbacks))
{
foreach (var callback in callbacks)
{
await callback.Callback(callbackContext).ConfigureAwait(false);
}
}

if (additionalData.Scope == CertificateTrustScope.System)
{
resourceLogger.LogInformation("Resource '{ResourceName}' has a certificate trust scope of '{Scope}'. Automatically including system root certificates in the trusted configuration.", resource.Name, Enum.GetName(additionalData.Scope));
}

}
}

/// <summary>
/// Metadata about the resource certificate trust configuration.
/// </summary>
public class CertificateTrustExecutionConfigurationData : IResourceExecutionConfigurationData
{
/// <summary>
/// The certificate trust scope for the resource.
/// </summary>
public CertificateTrustScope Scope { get; internal set; }

/// <summary>
/// The collection of certificates to trust.
/// </summary>
public X509Certificate2Collection Certificates { get; } = new();
}

/// <summary>
/// Context for configuring certificate trust configuration properties.
/// </summary>
public class CertificateTrustExecutionConfigurationContext
{
/// <summary>
/// The path to the certificate bundle file in the resource context (e.g., container filesystem).
/// </summary>
public required ReferenceExpression CertificateBundlePath { get; init; }

/// <summary>
/// The path(s) to the certificate directories in the resource context (e.g., container filesystem).
/// </summary>
public required ReferenceExpression CertificateDirectoriesPath { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Gathers environment variables for resources.
/// </summary>
internal class EnvironmentVariablesExecutionConfigurationGatherer : IResourceExecutionConfigurationGatherer
{
/// <inheritdoc/>
public async ValueTask GatherAsync(IResourceExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default)
{
if (resource.TryGetEnvironmentVariables(out var callbacks))
{
var callbackContext = new EnvironmentCallbackContext(executionContext, resource, context.EnvironmentVariables, cancellationToken)
{
Logger = resourceLogger,
};

foreach (var callback in callbacks)
{
await callback.Callback(callbackContext).ConfigureAwait(false);
}
}
}
}
2 changes: 0 additions & 2 deletions src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ async Task<ResolvedValue> EvalExpressionAsync(ReferenceExpression expr, ValuePro
var args = new object?[expr.ValueProviders.Count];
var isSensitive = false;

expr.WasResolved = true;

for (var i = 0; i < expr.ValueProviders.Count; i++)
{
var result = await ResolveInternalAsync(expr.ValueProviders[i], context).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Configuration (arguments and environment variables) to apply to a specific resource.
/// </summary>
public interface IProcessedResourceExecutionConfiguration
{
/// <summary>
/// Gets the set of references such as <see cref="IValueProvider"/> or <see cref="IManifestExpressionProvider"/> that were used to produce this configuration.
/// </summary>
IEnumerable<object> References { get; }

/// <summary>
/// Gets the arguments for the resource with the orgiginal unprocessed values included.
/// </summary>
IEnumerable<(object Unprocessed, string Processed, bool IsSensitive)> ArgumentsWithUnprocessed { get; }

/// <summary>
/// Gets the processed arguments to apply to the resource.
/// </summary>
IEnumerable<(string Value, bool IsSensitive)> Arguments { get; }

/// <summary>
/// Gets the environment variables to apply to the resource with the original unprocessed values included.
/// </summary>
IEnumerable<KeyValuePair<string, (object Unprocessed, string Processed)>> EnvironmentVariablesWithUnprocessed { get; }

/// <summary>
/// Gets the processed environment variables to apply to the resource.
/// </summary>
IEnumerable<KeyValuePair<string, string>> EnvironmentVariables { get; }

/// <summary>
/// Gets additional configuration data associated with the resource configuration.
/// </summary>
IEnumerable<IResourceExecutionConfigurationData> AdditionalConfigurationData { get; }
}
Loading