diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs
index f7dcb60b2b4..81ad596b9e8 100644
--- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs
+++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs
@@ -3,6 +3,7 @@
#pragma warning disable AZPROVISION001 // Azure.Provisioning.Network is experimental
+using Aspire.Hosting.Azure;
using Azure.Provisioning.Network;
var builder = DistributedApplication.CreateBuilder(args);
@@ -13,17 +14,17 @@
var vnet = builder.AddAzureVirtualNetwork("vnet");
var containerAppsSubnet = vnet.AddSubnet("container-apps", "10.0.0.0/23")
- .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp)
- .DenyInbound(from: "VirtualNetwork")
- .DenyInbound(from: "Internet");
+ .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer, protocol: SecurityRuleProtocol.Tcp)
+ .DenyInbound(from: AzureServiceTags.VirtualNetwork)
+ .DenyInbound(from: AzureServiceTags.Internet);
// Create a NAT Gateway for deterministic outbound IP on the ACA subnet
var natGateway = builder.AddNatGateway("nat");
containerAppsSubnet.WithNatGateway(natGateway);
var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/27")
- .AllowInbound(port: "443", from: "VirtualNetwork", protocol: SecurityRuleProtocol.Tcp)
- .DenyInbound(from: "Internet");
+ .AllowInbound(port: "443", from: AzureServiceTags.VirtualNetwork, protocol: SecurityRuleProtocol.Tcp)
+ .DenyInbound(from: AzureServiceTags.Internet);
// Configure the Container App Environment to use the VNet
builder.AddAzureContainerAppEnvironment("env")
diff --git a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs
index 36f0a90d257..5ab0d332686 100644
--- a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs
+++ b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs
@@ -31,6 +31,11 @@ internal interface IAuxiliaryBackchannelMonitor
///
IAppHostAuxiliaryBackchannel? SelectedConnection { get; }
+ ///
+ /// Gets the AppHost path of the currently resolved connection, or null if no connection is available.
+ ///
+ string? ResolvedAppHostPath => SelectedConnection?.AppHostInfo?.AppHostPath;
+
///
/// Gets all connections that are within the scope of the specified working directory.
///
diff --git a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
index e3d3c368333..31e633058d2 100644
--- a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
+++ b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
@@ -20,7 +20,7 @@ internal sealed class McpResourceToolRefreshService : IMcpResourceToolRefreshSer
private McpServer? _server;
private Dictionary _resourceToolMap = new(StringComparer.Ordinal);
private bool _invalidated = true;
- private string? _selectedAppHostPath;
+ private string? _lastRefreshedAppHostPath;
public McpResourceToolRefreshService(
IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor,
@@ -35,7 +35,7 @@ public bool TryGetResourceToolMap(out IReadOnlyDictionary
+/// Provides well-known Azure service tags that can be used as source or destination address prefixes
+/// in network security group rules.
+///
+///
+///
+/// Service tags represent a group of IP address prefixes from a given Azure service. Microsoft manages the
+/// address prefixes encompassed by each tag and automatically updates them as addresses change.
+///
+///
+/// These tags can be used with the from and to parameters of methods such as
+/// , ,
+/// , ,
+/// or with the and properties.
+///
+///
+///
+/// Use service tags when configuring network security rules:
+///
+/// var subnet = vnet.AddSubnet("web", "10.0.1.0/24")
+/// .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer, protocol: SecurityRuleProtocol.Tcp)
+/// .DenyInbound(from: AzureServiceTags.Internet);
+///
+///
+public static class AzureServiceTags
+{
+ ///
+ /// Represents the Internet address space, including all publicly routable IP addresses.
+ ///
+ public const string Internet = nameof(Internet);
+
+ ///
+ /// Represents the address space for the virtual network, including all connected address spaces,
+ /// all connected on-premises address spaces, and peered virtual networks.
+ ///
+ public const string VirtualNetwork = nameof(VirtualNetwork);
+
+ ///
+ /// Represents the Azure infrastructure load balancer. This tag is commonly used to allow
+ /// health probe traffic from Azure.
+ ///
+ public const string AzureLoadBalancer = nameof(AzureLoadBalancer);
+
+ ///
+ /// Represents Azure Traffic Manager probe IP addresses.
+ ///
+ public const string AzureTrafficManager = nameof(AzureTrafficManager);
+
+ ///
+ /// Represents the Azure Storage service. This tag does not include specific Storage accounts;
+ /// it covers all Azure Storage IP addresses.
+ ///
+ public const string Storage = nameof(Storage);
+
+ ///
+ /// Represents Azure SQL Database, Azure Database for MySQL, Azure Database for PostgreSQL,
+ /// Azure Database for MariaDB, and Azure Synapse Analytics.
+ ///
+ public const string Sql = nameof(Sql);
+
+ ///
+ /// Represents Azure Cosmos DB service addresses.
+ ///
+ public const string AzureCosmosDB = nameof(AzureCosmosDB);
+
+ ///
+ /// Represents Azure Key Vault service addresses.
+ ///
+ public const string AzureKeyVault = nameof(AzureKeyVault);
+
+ ///
+ /// Represents Azure Event Hubs service addresses.
+ ///
+ public const string EventHub = nameof(EventHub);
+
+ ///
+ /// Represents Azure Service Bus service addresses.
+ ///
+ public const string ServiceBus = nameof(ServiceBus);
+
+ ///
+ /// Represents Azure Container Registry service addresses.
+ ///
+ public const string AzureContainerRegistry = nameof(AzureContainerRegistry);
+
+ ///
+ /// Represents Azure App Service and Azure Functions service addresses.
+ ///
+ public const string AppService = nameof(AppService);
+
+ ///
+ /// Represents Microsoft Entra ID (formerly Azure Active Directory) service addresses.
+ ///
+ public const string AzureActiveDirectory = nameof(AzureActiveDirectory);
+
+ ///
+ /// Represents Azure Monitor service addresses, including Log Analytics, Application Insights,
+ /// and Azure Monitor metrics.
+ ///
+ public const string AzureMonitor = nameof(AzureMonitor);
+
+ ///
+ /// Represents the Gateway Manager service, used for VPN Gateway and Application Gateway management traffic.
+ ///
+ public const string GatewayManager = nameof(GatewayManager);
+}
diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs
index 4e15aa7ad3f..3d5d18475c0 100644
--- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs
+++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs
@@ -361,8 +361,8 @@ public static IResourceBuilder WithNetworkSecurityGroup(
/// This example allows HTTPS traffic from the Azure Load Balancer:
///
/// var subnet = vnet.AddSubnet("web", "10.0.1.0/24")
- /// .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp)
- /// .DenyInbound(from: "Internet");
+ /// .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer, protocol: SecurityRuleProtocol.Tcp)
+ /// .DenyInbound(from: AzureServiceTags.Internet);
///
///
public static IResourceBuilder AllowInbound(
diff --git a/src/Aspire.Hosting.Azure.Network/README.md b/src/Aspire.Hosting.Azure.Network/README.md
index 90975e8bbc6..3590fb1e102 100644
--- a/src/Aspire.Hosting.Azure.Network/README.md
+++ b/src/Aspire.Hosting.Azure.Network/README.md
@@ -90,8 +90,8 @@ Add security rules to control traffic flow on subnets using shorthand methods:
```csharp
var vnet = builder.AddAzureVirtualNetwork("vnet");
var subnet = vnet.AddSubnet("web", "10.0.1.0/24")
- .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp)
- .DenyInbound(from: "Internet");
+ .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer, protocol: SecurityRuleProtocol.Tcp)
+ .DenyInbound(from: AzureServiceTags.Internet);
```
An NSG is automatically created when shorthand methods are used. Priority auto-increments (100, 200, 300...) and rule names are auto-generated.
diff --git a/src/Aspire.Hosting/Publishing/BuildImageSecretValue.cs b/src/Aspire.Hosting/Publishing/BuildImageSecretValue.cs
new file mode 100644
index 00000000000..3d32ddd9306
--- /dev/null
+++ b/src/Aspire.Hosting/Publishing/BuildImageSecretValue.cs
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Aspire.Hosting.Publishing;
+
+///
+/// Specifies the type of a build secret.
+///
+[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public enum BuildImageSecretType
+{
+ ///
+ /// The secret value is provided via an environment variable.
+ ///
+ Environment,
+
+ ///
+ /// The secret value is a file path.
+ ///
+ File
+}
+
+///
+/// Represents a resolved build secret with its value and type.
+///
+/// The resolved secret value. For secrets, this is the secret content.
+/// For secrets, this is the file path.
+/// The type of the build secret, indicating whether it is environment-based or file-based.
+[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public record BuildImageSecretValue(string? Value, BuildImageSecretType Type);
diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs
index dc313c593a7..6b0bf535b60 100644
--- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs
+++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs
@@ -37,7 +37,7 @@ protected ContainerRuntimeBase(ILogger logger)
public abstract Task CheckIfRunningAsync(CancellationToken cancellationToken);
- public abstract Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken);
+ public abstract Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken);
public virtual async Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken)
{
@@ -241,18 +241,22 @@ protected static string BuildArgumentsString(Dictionary buildAr
/// The build secrets to include.
/// Whether to require a non-null value for secrets (default: false).
/// A string containing the formatted build secrets.
- protected static string BuildSecretsString(Dictionary buildSecrets, bool requireValue = false)
+ internal static string BuildSecretsString(Dictionary buildSecrets, bool requireValue = false)
{
var result = string.Empty;
foreach (var buildSecret in buildSecrets)
{
- if (requireValue && buildSecret.Value is null)
+ if (buildSecret.Value.Type == BuildImageSecretType.File)
{
- result += $" --secret \"id={buildSecret.Key}\"";
+ result += $" --secret \"id={buildSecret.Key},type=file,src={buildSecret.Value.Value}\"";
+ }
+ else if (requireValue && buildSecret.Value.Value is null)
+ {
+ result += $" --secret \"id={buildSecret.Key},type=env\"";
}
else
{
- result += $" --secret \"id={buildSecret.Key},env={buildSecret.Key.ToUpperInvariant()}\"";
+ result += $" --secret \"id={buildSecret.Key},type=env,env={buildSecret.Key.ToUpperInvariant()}\"";
}
}
return result;
diff --git a/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs b/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs
index 7770bc0959a..82607a85a0c 100644
--- a/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs
+++ b/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREPIPELINES003
+#pragma warning disable ASPIRECONTAINERRUNTIME001
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
@@ -17,7 +18,7 @@ public DockerContainerRuntime(ILogger logger) : base(log
protected override string RuntimeExecutable => "docker";
public override string Name => "Docker";
- private async Task RunDockerBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken)
+ private async Task RunDockerBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken)
{
var imageName = !string.IsNullOrEmpty(options?.Tag)
? $"{options.ImageName}:{options.Tag}"
@@ -107,12 +108,12 @@ private async Task RunDockerBuildAsync(string contextPath, string dockerfil
InheritEnv = true,
};
- // Add build secrets as environment variables
+ // Add build secrets as environment variables (only for environment-type secrets)
foreach (var buildSecret in buildSecrets)
{
- if (buildSecret.Value is not null)
+ if (buildSecret.Value.Type == BuildImageSecretType.Environment && buildSecret.Value.Value is not null)
{
- spec.EnvironmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value;
+ spec.EnvironmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value.Value;
}
}
@@ -145,7 +146,7 @@ private async Task RunDockerBuildAsync(string contextPath, string dockerfil
}
}
- public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken)
+ public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken)
{
// Normalize the context path to handle trailing slashes and relative paths
var normalizedContextPath = Path.GetFullPath(contextPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs
index 681f1e6159d..88a4a58a772 100644
--- a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs
+++ b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs
@@ -34,7 +34,7 @@ public interface IContainerRuntime
/// Build secrets to pass to the build process.
/// The target build stage.
/// A token to cancel the operation.
- Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken);
+ Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken);
///
/// Tags a container image with a new name.
diff --git a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs
index f6d874ac07c..d93eefd21ae 100644
--- a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs
+++ b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREPIPELINES003
+#pragma warning disable ASPIRECONTAINERRUNTIME001
using Microsoft.Extensions.Logging;
@@ -15,7 +16,7 @@ public PodmanContainerRuntime(ILogger logger) : base(log
protected override string RuntimeExecutable => "podman";
public override string Name => "Podman";
- private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken)
+ private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken)
{
var imageName = !string.IsNullOrEmpty(options?.Tag)
? $"{options.ImageName}:{options.Tag}"
@@ -60,13 +61,13 @@ private async Task RunPodmanBuildAsync(string contextPath, string dockerfil
arguments += $" \"{contextPath}\"";
- // Prepare environment variables for build secrets
+ // Prepare environment variables for build secrets (only for environment-type secrets)
var environmentVariables = new Dictionary();
foreach (var buildSecret in buildSecrets)
{
- if (buildSecret.Value is not null)
+ if (buildSecret.Value.Type == BuildImageSecretType.Environment && buildSecret.Value.Value is not null)
{
- environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value;
+ environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value.Value;
}
}
@@ -79,7 +80,7 @@ private async Task RunPodmanBuildAsync(string contextPath, string dockerfil
environmentVariables).ConfigureAwait(false);
}
- public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken)
+ public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken)
{
var exitCode = await RunPodmanBuildAsync(
contextPath,
diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs
index d23f8564f33..f8c8e6c2b5e 100644
--- a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs
+++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs
@@ -440,10 +440,12 @@ private async Task BuildContainerImageFromDockerfileAsync(IResource resource, Do
}
// Resolve build secrets
- var resolvedBuildSecrets = new Dictionary();
+ var resolvedBuildSecrets = new Dictionary();
foreach (var buildSecret in dockerfileBuildAnnotation.BuildSecrets)
{
- resolvedBuildSecrets[buildSecret.Key] = await ResolveValue(buildSecret.Value, cancellationToken).ConfigureAwait(false);
+ var secretType = buildSecret.Value is FileInfo ? BuildImageSecretType.File : BuildImageSecretType.Environment;
+ var resolvedValue = await ResolveValue(buildSecret.Value, cancellationToken).ConfigureAwait(false);
+ resolvedBuildSecrets[buildSecret.Key] = new BuildImageSecretValue(resolvedValue, secretType);
}
// ensure outputPath is created if specified since docker/podman won't create it for us
diff --git a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs
index a4110121431..3bf05fa8694 100644
--- a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs
+++ b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs
@@ -496,6 +496,66 @@ public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification()
Assert.Equal(0, notificationCount);
}
+ [Fact]
+ public async Task McpServer_ListTools_CachesResourceToolMap_WhenConnectionUnchanged()
+ {
+ // Arrange - Create a mock backchannel and track how many times GetResourceSnapshotsAsync is called
+ var getResourceSnapshotsCallCount = 0;
+ var mockBackchannel = new TestAppHostAuxiliaryBackchannel
+ {
+ Hash = "test-apphost-hash",
+ IsInScope = true,
+ AppHostInfo = new AppHostInformation
+ {
+ AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
+ ProcessId = 12345
+ },
+ GetResourceSnapshotsHandler = (ct) =>
+ {
+ Interlocked.Increment(ref getResourceSnapshotsCallCount);
+ return Task.FromResult(new List
+ {
+ new ResourceSnapshot
+ {
+ Name = "db-mcp-xyz",
+ DisplayName = "db-mcp",
+ ResourceType = "Container",
+ State = "Running",
+ McpServer = new ResourceSnapshotMcpServer
+ {
+ EndpointUrl = "http://localhost:8080/mcp",
+ Tools =
+ [
+ new Tool
+ {
+ Name = "query_db",
+ Description = "Query the database"
+ }
+ ]
+ }
+ }
+ });
+ }
+ };
+
+ _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel);
+
+ // Act - Call ListTools twice
+ var tools1 = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout();
+ var tools2 = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout();
+
+ // Assert - Both calls return the resource tool
+ Assert.Contains(tools1, t => t.Name == "db_mcp_query_db");
+ Assert.Contains(tools2, t => t.Name == "db_mcp_query_db");
+
+ // The resource tool map should be cached after the first call,
+ // so GetResourceSnapshotsAsync should only be called once (during the first refresh).
+ // Before the fix, TryGetResourceToolMap always returned false due to
+ // SelectedAppHostPath vs SelectedConnection path mismatch, causing every
+ // ListTools call to trigger a full refresh.
+ Assert.Equal(1, getResourceSnapshotsCallCount);
+ }
+
[Fact]
public async Task McpServer_CallTool_UnknownTool_ReturnsError()
{
diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs
index 31c7ea06dd8..fe266efc374 100644
--- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs
+++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs
@@ -46,6 +46,12 @@ internal sealed class TestAppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackcha
///
public Func?, CancellationToken, Task>? CallResourceMcpToolHandler { get; set; }
+ ///
+ /// Gets or sets the function to call when GetResourceSnapshotsAsync is invoked.
+ /// If null, returns the ResourceSnapshots list.
+ ///
+ public Func>>? GetResourceSnapshotsHandler { get; set; }
+
public Task GetDashboardUrlsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(DashboardUrlsState);
@@ -53,6 +59,11 @@ internal sealed class TestAppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackcha
public Task> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default)
{
+ if (GetResourceSnapshotsHandler is not null)
+ {
+ return GetResourceSnapshotsHandler(cancellationToken);
+ }
+
return Task.FromResult(ResourceSnapshots);
}
diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs
index 964a030b6a3..79ba00243ac 100644
--- a/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs
@@ -247,7 +247,7 @@ public async Task MultipleNSGs_WithSameRuleName_GeneratesDistinctBicepIdentifier
Direction = SecurityRuleDirection.Inbound,
Access = SecurityRuleAccess.Allow,
Protocol = SecurityRuleProtocol.Tcp,
- SourceAddressPrefix = "VirtualNetwork",
+ SourceAddressPrefix = AzureServiceTags.VirtualNetwork,
SourcePortRange = "*",
DestinationAddressPrefix = "*",
DestinationPortRange = "443"
@@ -271,7 +271,7 @@ public void WithNetworkSecurityGroup_AfterShorthand_Throws()
var vnet = builder.AddAzureVirtualNetwork("myvnet");
var nsg = builder.AddNetworkSecurityGroup("web-nsg");
var subnet = vnet.AddSubnet("web-subnet", "10.0.1.0/24")
- .AllowInbound(port: "443", from: "AzureLoadBalancer");
+ .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer);
var exception = Assert.Throws(() => subnet.WithNetworkSecurityGroup(nsg));
diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs
index 08fafbde1a3..1747a3b1f96 100644
--- a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs
@@ -361,6 +361,66 @@ await Verify(vnetManifest.BicepText, extension: "bicep")
.AppendContentAsFile(nsgManifest.BicepText, "bicep", "nsg");
}
+ [Fact]
+ public void ServiceTags_CanBeUsedAsFromAndToParameters()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var vnet = builder.AddAzureVirtualNetwork("myvnet");
+ var subnet = vnet.AddSubnet("web", "10.0.1.0/24")
+ .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer, protocol: SecurityRuleProtocol.Tcp)
+ .DenyInbound(from: AzureServiceTags.Internet)
+ .AllowOutbound(port: "443", to: AzureServiceTags.Storage)
+ .DenyOutbound(to: AzureServiceTags.VirtualNetwork);
+
+ var rules = subnet.Resource.NetworkSecurityGroup!.SecurityRules;
+ Assert.Equal(4, rules.Count);
+
+ Assert.Equal("AzureLoadBalancer", rules[0].SourceAddressPrefix);
+ Assert.Equal("Internet", rules[1].SourceAddressPrefix);
+ Assert.Equal("Storage", rules[2].DestinationAddressPrefix);
+ Assert.Equal("VirtualNetwork", rules[3].DestinationAddressPrefix);
+ }
+
+ [Fact]
+ public void ServiceTags_CanBeUsedInSecurityRuleProperties()
+ {
+ var rule = new AzureSecurityRule
+ {
+ Name = "allow-https-from-lb",
+ Priority = 100,
+ Direction = SecurityRuleDirection.Inbound,
+ Access = SecurityRuleAccess.Allow,
+ Protocol = SecurityRuleProtocol.Tcp,
+ SourceAddressPrefix = AzureServiceTags.AzureLoadBalancer,
+ DestinationAddressPrefix = AzureServiceTags.VirtualNetwork,
+ DestinationPortRange = "443"
+ };
+
+ Assert.Equal("AzureLoadBalancer", rule.SourceAddressPrefix);
+ Assert.Equal("VirtualNetwork", rule.DestinationAddressPrefix);
+ }
+
+ [Fact]
+ public void ServiceTags_HaveExpectedValues()
+ {
+ Assert.Equal("Internet", AzureServiceTags.Internet);
+ Assert.Equal("VirtualNetwork", AzureServiceTags.VirtualNetwork);
+ Assert.Equal("AzureLoadBalancer", AzureServiceTags.AzureLoadBalancer);
+ Assert.Equal("AzureTrafficManager", AzureServiceTags.AzureTrafficManager);
+ Assert.Equal("Storage", AzureServiceTags.Storage);
+ Assert.Equal("Sql", AzureServiceTags.Sql);
+ Assert.Equal("AzureCosmosDB", AzureServiceTags.AzureCosmosDB);
+ Assert.Equal("AzureKeyVault", AzureServiceTags.AzureKeyVault);
+ Assert.Equal("EventHub", AzureServiceTags.EventHub);
+ Assert.Equal("ServiceBus", AzureServiceTags.ServiceBus);
+ Assert.Equal("AzureContainerRegistry", AzureServiceTags.AzureContainerRegistry);
+ Assert.Equal("AppService", AzureServiceTags.AppService);
+ Assert.Equal("AzureActiveDirectory", AzureServiceTags.AzureActiveDirectory);
+ Assert.Equal("AzureMonitor", AzureServiceTags.AzureMonitor);
+ Assert.Equal("GatewayManager", AzureServiceTags.GatewayManager);
+ }
+
[Fact]
public void AllFourDirectionAccessCombos_SetCorrectly()
{
diff --git a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs
index 100109755df..baec499b9ac 100644
--- a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs
+++ b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs
@@ -26,9 +26,9 @@ public sealed class FakeContainerRuntime(bool shouldFail = false, bool isRunning
public List<(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options)> BuildImageCalls { get; } = [];
public List<(string registryServer, string username, string password)> LoginToRegistryCalls { get; } = [];
public Dictionary? CapturedBuildArguments { get; private set; }
- public Dictionary? CapturedBuildSecrets { get; private set; }
+ public Dictionary? CapturedBuildSecrets { get; private set; }
public string? CapturedStage { get; private set; }
- public Func, Dictionary, string?, CancellationToken, Task>? BuildImageAsyncCallback { get; set; }
+ public Func, Dictionary, string?, CancellationToken, Task>? BuildImageAsyncCallback { get; set; }
public Task CheckIfRunningAsync(CancellationToken cancellationToken)
{
@@ -70,7 +70,7 @@ public Task PushImageAsync(IResource resource, CancellationToken cancellationTok
return Task.CompletedTask;
}
- public async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken)
+ public async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken)
{
// Capture the arguments for verification in tests
CapturedBuildArguments = buildArguments;
diff --git a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs
index 1a9f24eff41..d4cb66a2e4c 100644
--- a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs
+++ b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs
@@ -676,7 +676,8 @@ public async Task CanBuildImageFromDockerfileWithBuildArgsSecretsAndStage()
// Verify that the correct build secrets were passed
Assert.NotNull(fakeContainerRuntime.CapturedBuildSecrets);
Assert.Single(fakeContainerRuntime.CapturedBuildSecrets);
- Assert.Equal("mysecret", fakeContainerRuntime.CapturedBuildSecrets["SECRET_ASENV"]);
+ Assert.Equal("mysecret", fakeContainerRuntime.CapturedBuildSecrets["SECRET_ASENV"].Value);
+ Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["SECRET_ASENV"].Type);
// Verify that the correct stage was passed
Assert.Equal("runner", fakeContainerRuntime.CapturedStage);
@@ -829,10 +830,117 @@ public async Task CanResolveBuildSecretsWithDifferentValueTypes()
Assert.Equal(2, fakeContainerRuntime.CapturedBuildSecrets.Count);
// Parameter should resolve to its configured value
- Assert.Equal("secret-value", fakeContainerRuntime.CapturedBuildSecrets["STRING_SECRET"]);
+ Assert.Equal("secret-value", fakeContainerRuntime.CapturedBuildSecrets["STRING_SECRET"].Value);
+ Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["STRING_SECRET"].Type);
// Null parameter should resolve to null
- Assert.Null(fakeContainerRuntime.CapturedBuildSecrets["NULL_SECRET"]);
+ Assert.Null(fakeContainerRuntime.CapturedBuildSecrets["NULL_SECRET"].Value);
+ Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["NULL_SECRET"].Type);
+ }
+
+ [Fact]
+ public async Task CanResolveBuildSecretsWithFileType()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(output);
+
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddFakeLogging();
+ logging.AddXunit(output);
+ });
+
+ // Create a fake container runtime to capture build secrets
+ var fakeContainerRuntime = new FakeContainerRuntime(shouldFail: false);
+ builder.Services.AddKeyedSingleton("docker", fakeContainerRuntime);
+
+ var (tempContextPath, tempDockerfilePath) = await DockerfileUtils.CreateTemporaryDockerfileAsync();
+
+ // Create a temporary file to use as a file-based secret
+ using var tempDir = new TestTempDirectory();
+ var tempSecretFile = System.IO.Path.Combine(tempDir.Path, ".npmrc");
+ await File.WriteAllTextAsync(tempSecretFile, "secret-file-content");
+
+ // Add an env-based secret parameter
+ builder.Configuration["Parameters:envsecret"] = "env-secret-value";
+ var envSecret = builder.AddParameter("envsecret", secret: true);
+
+ var container = builder.AddDockerfile("container", tempContextPath, tempDockerfilePath)
+ .WithBuildSecret("ENV_SECRET", envSecret);
+
+ // Add a file-based secret directly via the annotation
+ var annotation = container.Resource.Annotations.OfType().Single();
+ annotation.BuildSecrets["FILE_SECRET"] = new FileInfo(tempSecretFile);
+
+ using var app = builder.Build();
+
+ using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
+ var imageBuilder = app.Services.GetRequiredService();
+ await imageBuilder.BuildImageAsync(container.Resource, cts.Token);
+
+ // Verify that both secret types are resolved correctly
+ Assert.NotNull(fakeContainerRuntime.CapturedBuildSecrets);
+ Assert.Equal(2, fakeContainerRuntime.CapturedBuildSecrets.Count);
+
+ // Environment-based secret
+ Assert.Equal("env-secret-value", fakeContainerRuntime.CapturedBuildSecrets["ENV_SECRET"].Value);
+ Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["ENV_SECRET"].Type);
+
+ // File-based secret should resolve to the full file path
+ Assert.Equal(new FileInfo(tempSecretFile).FullName, fakeContainerRuntime.CapturedBuildSecrets["FILE_SECRET"].Value);
+ Assert.Equal(BuildImageSecretType.File, fakeContainerRuntime.CapturedBuildSecrets["FILE_SECRET"].Type);
+ }
+
+ [Fact]
+ public void BuildSecretsStringFormatsEnvSecretCorrectly()
+ {
+ var secrets = new Dictionary
+ {
+ ["MY_SECRET"] = new BuildImageSecretValue("secret-value", BuildImageSecretType.Environment)
+ };
+
+ var result = ContainerRuntimeBase.BuildSecretsString(secrets);
+
+ Assert.Equal(" --secret \"id=MY_SECRET,type=env,env=MY_SECRET\"", result);
+ }
+
+ [Fact]
+ public void BuildSecretsStringFormatsFileSecretCorrectly()
+ {
+ var secrets = new Dictionary
+ {
+ ["npmrc"] = new BuildImageSecretValue("/path/to/.npmrc", BuildImageSecretType.File)
+ };
+
+ var result = ContainerRuntimeBase.BuildSecretsString(secrets);
+
+ Assert.Equal(" --secret \"id=npmrc,type=file,src=/path/to/.npmrc\"", result);
+ }
+
+ [Fact]
+ public void BuildSecretsStringFormatsNullEnvSecretWithRequireValue()
+ {
+ var secrets = new Dictionary
+ {
+ ["MY_SECRET"] = new BuildImageSecretValue(null, BuildImageSecretType.Environment)
+ };
+
+ var result = ContainerRuntimeBase.BuildSecretsString(secrets, requireValue: true);
+
+ Assert.Equal(" --secret \"id=MY_SECRET,type=env\"", result);
+ }
+
+ [Fact]
+ public void BuildSecretsStringFormatsMixedSecretTypes()
+ {
+ var secrets = new Dictionary
+ {
+ ["ENV_TOKEN"] = new BuildImageSecretValue("token-value", BuildImageSecretType.Environment),
+ ["npmrc"] = new BuildImageSecretValue("/app/.npmrc", BuildImageSecretType.File)
+ };
+
+ var result = ContainerRuntimeBase.BuildSecretsString(secrets);
+
+ Assert.Equal(" --secret \"id=ENV_TOKEN,type=env,env=ENV_TOKEN\" --secret \"id=npmrc,type=file,src=/app/.npmrc\"", result);
}
[Fact]