// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIRECOMPUTE002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES001
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Kubernetes.Resources;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
namespace Aspire.Hosting.Kubernetes.Tests;
public class KubernetesPublisherTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task PublishAsync_GeneratesValidHelmChart()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
var param0 = builder.AddParameter("param0");
var param1 = builder.AddParameter("param1", secret: true);
var param2 = builder.AddParameter("param2", "default", publishValueAsDefault: true);
var param3 = builder.AddResource(ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, "param3"));
var cs = builder.AddConnectionString("cs", ReferenceExpression.Create($"Url={param0}, Secret={param1}"));
// Add a container to the application
var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
.WithHttpEndpoint(targetPort: 8080)
.WithEnvironment("param0", param0)
.WithEnvironment("param1", param1)
.WithEnvironment("param2", param2)
.WithEnvironment("param3", param3)
.WithReference(cs)
.WithVolume("logs", "/logs")
.WithArgs("--cs", cs.Resource);
builder.AddProject<TestProject>("project1", launchProfileName: null)
.WithReference(api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
// Assert
var expectedFiles = new[]
{
"Chart.yaml",
"values.yaml",
"templates/env-dashboard/deployment.yaml",
"templates/env-dashboard/service.yaml",
"templates/project1/deployment.yaml",
"templates/project1/config.yaml",
"templates/myapp/deployment.yaml",
"templates/myapp/service.yaml",
"templates/myapp/config.yaml",
"templates/myapp/secrets.yaml"
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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 PublishAppliesServiceCustomizations()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env")
.WithProperties(e => e.DefaultImagePullPolicy = "Always");
// Add a container to the application
var container = builder.AddContainer("service", "nginx")
.WithEnvironment("ORIGINAL_ENV", "value")
.PublishAsKubernetesService(serviceResource =>
{
serviceResource.Workload!.PodTemplate.Spec.Containers[0].ImagePullPolicy = "Always";
(serviceResource.Workload as Deployment)!.Spec.RevisionHistoryLimit = 5;
});
var app = builder.Build();
app.Run();
// Assert
var deploymentPath = Path.Combine(workspace.Path, "templates/service/deployment.yaml");
Assert.True(File.Exists(deploymentPath));
var content = await File.ReadAllTextAsync(deploymentPath);
await Verify(content, "yaml");
}
[Fact]
public async Task PublishAsync_CustomWorkloadAndResourceType()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
// Add a container to the application
var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
.WithHttpEndpoint(targetPort: 8080)
.PublishAsKubernetesService(serviceResource =>
{
serviceResource.Workload = new ArgoRollout
{
Metadata = { Name = "myapp-rollout", Labels = serviceResource.Labels.ToDictionary() },
Spec = { Template = serviceResource.Workload!.PodTemplate, Selector = { MatchLabels = serviceResource.Labels.ToDictionary() } }
};
serviceResource.AdditionalResources.Add(new KedaScaledObject
{
Metadata = { Name = "myapp-scaler" },
Spec = { ScaleTargetRef = { Kind = serviceResource.Workload.Kind!, Name = serviceResource.Workload.Metadata.Name }, MaxReplicaCount = 3 }
});
});
builder.AddProject<TestProject>("project1", launchProfileName: null)
.WithReference(api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
// Assert
var expectedFiles = new[]
{
"Chart.yaml",
"values.yaml",
"templates/env-dashboard/deployment.yaml",
"templates/env-dashboard/service.yaml",
"templates/myapp/rollout.yaml",
"templates/myapp/service.yaml",
"templates/myapp/config.yaml",
"templates/myapp/scaler.yaml"
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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_CustomManifestResource()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.PublishAsKubernetesService(serviceResource =>
{
serviceResource.AddManifest("keda.sh/v1alpha1", "ScaledObject", "myapp-scaler", manifest =>
{
manifest.WithNamespace("autoscaling")
.WithLabel("example.com/custom", "true")
.WithAnnotation("example.com/source", "polyglot")
.WithField("spec.scaleTargetRef.kind", "Deployment")
.WithField("spec.scaleTargetRef.name", "myapp")
.WithField("spec.minReplicaCount", 1)
.WithField("spec.maxReplicaCount", (double)3)
.WithField("data.enabled", true);
});
});
var app = builder.Build();
app.Run();
var manifestPath = Path.Combine(workspace.Path, "templates/myapp/scaler.yaml");
Assert.True(File.Exists(manifestPath), $"Manifest should exist at {manifestPath}");
var content = await File.ReadAllTextAsync(manifestPath);
Assert.Contains("apiVersion: keda.sh/v1alpha1", content);
Assert.Contains("kind: ScaledObject", content);
Assert.Contains("myapp-scaler", content);
Assert.Contains("namespace: \"autoscaling\"", content);
Assert.Contains("example.com/custom: \"true\"", content);
Assert.Contains("app.kubernetes.io/name:", content);
Assert.Contains("example.com/source: \"polyglot\"", content);
Assert.Contains("scaleTargetRef:", content);
Assert.Contains("kind: \"Deployment\"", content);
Assert.Contains("name: \"myapp\"", content);
Assert.Contains("minReplicaCount: 1", content);
var yaml = new YamlStream();
yaml.Load(new StringReader(content));
var root = Assert.IsType<YamlMappingNode>(yaml.Documents[0].RootNode);
var spec = Assert.IsType<YamlMappingNode>(root.Children.Single(static entry => entry.Key is YamlScalarNode { Value: "spec" }).Value);
var maxReplicaCount = Assert.IsType<YamlScalarNode>(spec.Children.Single(static entry => entry.Key is YamlScalarNode { Value: "maxReplicaCount" }).Value);
Assert.Equal("3", maxReplicaCount.Value);
Assert.DoesNotContain("maxReplicaCount: 3.0", content);
Assert.Contains("enabled: true", content);
}
[Fact]
public void KubernetesManifestResourceWithFieldThrowsWhenIntermediatePathIsScalar()
{
var manifest = new KubernetesManifestResource("example.com/v1", "Example", "example");
manifest.WithField("spec", "scalar");
var exception = Assert.Throws<ArgumentException>(() => manifest.WithField("spec.replicas", 3));
Assert.Contains("Cannot set nested manifest field 'spec.replicas' because 'spec' already has a scalar value.", exception.Message);
}
[Fact]
public async Task PublishAsync_HandlesSpecialResourceName()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithChartName("my-chart"));
var param0 = builder.AddParameter("param0");
var param1 = builder.AddParameter("param1", secret: true);
var cs = builder.AddConnectionString("api-cs", ReferenceExpression.Create($"Url={param0}, Secret={param1}"));
var csPlain = builder.AddConnectionString("api-cs2", ReferenceExpression.Create($"host.local:80"));
var param3 = builder.AddResource(ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, "param3"));
builder.AddProject<TestProject>("SpeciaL-ApP", launchProfileName: null)
.WithEnvironment("param3", param3)
.WithReference(cs)
.WithReference(csPlain);
var app = builder.Build();
app.Run();
// Assert
var expectedFiles = new[]
{
"Chart.yaml",
"values.yaml",
"templates/env-dashboard/deployment.yaml",
"templates/env-dashboard/service.yaml",
"templates/SpeciaL-ApP/deployment.yaml",
"templates/SpeciaL-ApP/config.yaml",
"templates/SpeciaL-ApP/secrets.yaml"
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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_ResourceWithProbes()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
// Add a container to the application
#pragma warning disable ASPIREPROBES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var api = builder
.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
.WithHttpEndpoint(targetPort: 8080)
.WithHttpProbe(ProbeType.Readiness, "/ready")
.WithHttpProbe(ProbeType.Liveness, "/health");
builder
.AddProject<TestProject>("project1", launchProfileName: null)
.WithHttpsEndpoint()
.WithHttpProbe(ProbeType.Readiness,"/ready", initialDelaySeconds: 60)
.WithHttpProbe(ProbeType.Liveness, "/health");
#pragma warning restore ASPIREPROBES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var app = builder.Build();
app.Run();
// Assert
var expectedFiles = new[]
{
"templates/env-dashboard/deployment.yaml",
"templates/myapp/deployment.yaml",
"templates/project1/deployment.yaml",
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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_WithDockerfileFactory_WritesDockerfileToOutputFolder()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
var dockerfileContent = "FROM alpine:latest\nRUN echo 'Generated for kubernetes'";
var container = builder.AddContainer("testcontainer", "testimage")
.WithDockerfileFactory(".", context => dockerfileContent);
var app = builder.Build();
app.Run();
// Verify Dockerfile was written to resource-specific path
var dockerfilePath = Path.Combine(workspace.Path, "testcontainer.Dockerfile");
Assert.True(File.Exists(dockerfilePath), $"Dockerfile should exist at {dockerfilePath}");
var actualContent = await File.ReadAllTextAsync(dockerfilePath);
await Verify(actualContent);
}
[Fact]
public async Task PublishAsync_SupportsProjectedSecretVolumes()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.PublishAsKubernetesService(serviceResource =>
{
var podTemplate = serviceResource.Workload!.PodTemplate;
podTemplate.Spec.Volumes.Add(new()
{
Name = "projected-secrets",
Projected = new()
{
DefaultMode = 420,
Sources =
{
new()
{
Secret = new()
{
Name = "first-secret",
Items =
{
new()
{
Key = "username",
Path = "first/username"
}
}
}
},
new()
{
Secret = new()
{
Name = "second-secret",
Optional = true,
Items =
{
new()
{
Key = "password",
Mode = 256,
Path = "second/password"
}
}
}
}
}
}
});
podTemplate.Spec.Containers[0].VolumeMounts.Add(new()
{
Name = "projected-secrets",
MountPath = "/mnt/secrets",
ReadOnly = true
});
});
var app = builder.Build();
app.Run();
var deploymentPath = Path.Combine(workspace.Path, "templates/myapp/deployment.yaml");
var deployment = await File.ReadAllTextAsync(deploymentPath);
await Verify(deployment, "yaml");
}
[Fact]
public async Task PublishAsync_SupportsProjectedConfigMapDownwardApiAndServiceAccountTokenVolumes()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.PublishAsKubernetesService(serviceResource =>
{
var podTemplate = serviceResource.Workload!.PodTemplate;
podTemplate.Spec.Volumes.Add(new()
{
Name = "projected-config",
Projected = new()
{
Sources =
{
new()
{
ConfigMap = new()
{
Name = "app-config",
Optional = true,
Items =
{
new()
{
Key = "appsettings.json",
Path = "config/appsettings.json"
}
}
}
},
new()
{
DownwardApi = new()
{
Items =
{
new()
{
Path = "pod/name",
FieldRef = new()
{
FieldPath = "metadata.name"
}
},
new()
{
Mode = 256,
Path = "resources/cpu-limit",
ResourceFieldRef = new()
{
ContainerName = "myapp",
Resource = "limits.cpu"
}
}
}
}
},
new()
{
ServiceAccountToken = new()
{
Audience = "api",
ExpirationSeconds = 3600,
Path = "tokens/api-token"
}
}
}
}
});
podTemplate.Spec.Containers[0].VolumeMounts.Add(new()
{
Name = "projected-config",
MountPath = "/mnt/config",
ReadOnly = true
});
});
var app = builder.Build();
app.Run();
var deploymentPath = Path.Combine(workspace.Path, "templates/myapp/deployment.yaml");
var deployment = await File.ReadAllTextAsync(deploymentPath);
await Verify(deployment, "yaml");
}
private sealed class KedaScaledObject() : BaseKubernetesResource("keda.sh/v1alpha1", "ScaledObject")
{
[YamlMember(Alias = "spec")]
public KedaScaledObjectSpec Spec { get; set; } = new();
public sealed class KedaScaledObjectSpec
{
[YamlMember(Alias = "scaleTargetRef")]
public ScaleTargetRefSpec ScaleTargetRef { get; set; } = new();
[YamlMember(Alias = "minReplicaCount")]
public int MinReplicaCount { get; set; } = 1;
[YamlMember(Alias = "maxReplicaCount")]
public int MaxReplicaCount { get; set; } = 1;
public sealed class ScaleTargetRefSpec
{
[YamlMember(Alias = "name")]
public string Name { get; set; } = null!;
[YamlMember(Alias = "kind")]
public string Kind { get; set; } = "Deployment";
}
// Omitted other properties for brevity
}
}
private sealed class ArgoRollout() : Workload("argoproj.io/v1alpha1", "Rollout")
{
public ArgoRolloutSpec Spec { get; set; } = new();
public sealed class ArgoRolloutSpec
{
[YamlMember(Alias = "replicas")]
public int Replicas { get; set; } = 1;
[YamlMember(Alias = "template")]
public PodTemplateSpecV1 Template { get; set; } = new();
[YamlMember(Alias = "selector")]
public LabelSelectorV1 Selector { get; set; } = new();
// Omitted other properties for brevity
}
[YamlIgnore]
public override PodTemplateSpecV1 PodTemplate => Spec.Template;
}
[Fact]
public async Task KubernetesWithProjectResources()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
// Add a project with multiple endpoint combinations
var project = builder.AddProject<TestProjectWithLaunchSettings>("project1")
.WithHttpEndpoint(name: "custom1") // port = null, targetPort = null
.WithHttpEndpoint(port: 7001, name: "custom2") // port = 7001, targetPort = null
.WithHttpEndpoint(targetPort: 7002, name: "custom3") // port = null, targetPort = 7002
.WithHttpEndpoint(port: 7003, targetPort: 7004, name: "custom4"); // port = 7003, targetPort = 7004
builder.AddContainer("api", "reg:api")
.WithReference(project);
var app = builder.Build();
app.Run();
// Assert
var expectedFiles = new[]
{
"Chart.yaml",
"values.yaml",
"templates/env-dashboard/deployment.yaml",
"templates/env-dashboard/service.yaml",
"templates/project1/deployment.yaml",
"templates/project1/service.yaml",
"templates/project1/config.yaml",
"templates/api/deployment.yaml",
"templates/api/config.yaml"
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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 KubernetesMapsPortsForBaitAndSwitchResources()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
var api = builder.AddExecutable("api", "node", ".")
.PublishAsDockerFile()
.WithHttpEndpoint(env: "PORT");
builder.AddContainer("gateway", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithReference(api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
// Assert
var expectedFiles = new[]
{
"Chart.yaml",
"values.yaml",
"templates/env-dashboard/deployment.yaml",
"templates/env-dashboard/service.yaml",
"templates/api/deployment.yaml",
"templates/api/service.yaml",
"templates/api/config.yaml",
"templates/gateway/deployment.yaml",
"templates/gateway/config.yaml"
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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 KubernetesTreatsZeroPublicPortAsUnspecified()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("database", "image")
.WithEndpoint(name: "tcp", port: 0, targetPort: 1433);
var app = builder.Build();
app.Run();
var servicePath = Path.Combine(workspace.Path, "templates/database/service.yaml");
await Verify(File.ReadAllText(servicePath), "yaml");
}
[Fact]
public async Task KubernetesEndpointReferenceUsesServicePortNotTargetPort()
{
// Regression test for https://github.com/microsoft/aspire/issues/18321
// When an endpoint has a distinct exposed `port` and container `targetPort`, a reference to
// that endpoint must resolve to the Kubernetes Service `port` (what clients connect to), not
// the container `targetPort`. The generated Service maps port -> targetPort, so a URL using
// the targetPort points at a port the Service is not listening on.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("k8s");
var questdb = builder.AddContainer("questdb", "questdb/questdb:9.4.1")
.WithHttpEndpoint(port: 9002, targetPort: 9000, name: "http");
builder.AddContainer("web", "questdb/questdb:9.4.1")
.WithEnvironment("QDB_CLIENT_CONF", questdb.GetEndpoint("http"));
var app = builder.Build();
app.Run();
// Assert. values.yaml holds the resolved reference URL (must use service port 9002), while
// questdb/service.yaml shows the Service mapping port 9002 -> targetPort 9000.
var expectedFiles = new[]
{
"values.yaml",
"templates/questdb/service.yaml",
"templates/web/config.yaml"
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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 KubernetesIngressAndGatewayRouteToServicePortForDistinctPorts()
{
// Companion to KubernetesEndpointReferenceUsesServicePortNotTargetPort (issue #18321).
// The GetEndpoint fix only touches service-to-service reference resolution; Ingress and
// Gateway backends resolve ports on separate code paths. This test locks in that all three
// stay consistent for an endpoint with a distinct exposed `port` (9002) and container
// `targetPort` (9000):
// - service.yaml maps Service port 9002 -> targetPort 9000
// - the Ingress backend references the Service port by NAME ("http"), so it is decoupled
// from the numeric port and resolves through the Service to 9002 -> 9000
// - the Gateway HTTPRoute backendRef uses the numeric Service port 9002 (not 9000)
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("k8s");
var ingress = k8s.AddIngress("ingress").WithIngressClass("nginx");
var gateway = k8s.AddGateway("gateway").WithGatewayClass("nginx");
var api = builder.AddContainer("api", "questdb/questdb:9.4.1")
.WithHttpEndpoint(port: 9002, targetPort: 9000, name: "http")
.WithExternalHttpEndpoints();
ingress.WithPath("/api", api.GetEndpoint("http"));
gateway.WithRoute("/api", api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
var expectedFiles = new[]
{
"templates/api/service.yaml",
"templates/ingress/ingress.yaml",
"templates/gateway/route.yaml"
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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 KubernetesProbeUsesContainerTargetPortNotServicePort()
{
// Guards EndpointProperty.TargetPort => GetPort(targetPort) in KubernetesResource.GetEndpointValue.
// Probes run against the pod directly (not through the Service), so when an endpoint has a
// distinct exposed `port` (9002) and container `targetPort` (9000), the generated probe must
// target the container port 9000. This is the inverse of issue #18321: client-facing
// references use the Service port, but the probe must keep using the container target port.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("k8s");
#pragma warning disable ASPIREPROBES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
builder.AddContainer("api", "questdb/questdb:9.4.1")
.WithHttpEndpoint(port: 9002, targetPort: 9000, name: "http")
.WithHttpProbe(ProbeType.Readiness, "/ready");
#pragma warning restore ASPIREPROBES001
var app = builder.Build();
app.Run();
// deployment.yaml carries the probe (httpGet.port must be 9000, the container port), while
// service.yaml shows the Service mapping port 9002 -> targetPort 9000.
var expectedFiles = new[]
{
"templates/api/deployment.yaml",
"templates/api/service.yaml"
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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_EmbeddedParametersInEnvironmentExpressionsPopulateValues()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
// Regression for https://github.com/microsoft/aspire/issues/11140: the base chart keeps
// deployment parameters empty, but every nested Helm reference must still be declared.
var host = builder.AddParameter("host", "localhost");
var token = builder.AddParameter("token", "test-token", secret: true);
builder.AddContainer("myapp", "nginx")
.WithEnvironment("SOME_URL", $"http://{host}/test")
.WithEnvironment("SECRET_URL", $"http://{host}/test?token={token}");
var app = builder.Build();
app.Run();
var expectedFiles = new[]
{
"values.yaml",
"templates/myapp/config.yaml",
"templates/myapp/secrets.yaml",
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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_ConflictingEmbeddedParameterValuesPathReportsError()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var reporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IPipelineActivityReporter>(reporter);
builder.AddKubernetesEnvironment("env");
var host = builder.AddParameter("host", "parameter-host", publishValueAsDefault: true);
builder.AddContainer("myapp", "nginx")
.WithEnvironment("host", "environment-host")
.WithEnvironment("URL", $"http://{host}/test");
using var app = builder.Build();
await app.RunAsync();
Assert.Equal(CompletionState.CompletedWithError, reporter.ResultCompletionState);
Assert.Contains(
"Resource 'myapp' maps both environment value 'host' and embedded parameter 'host' " +
"to Helm values path 'config.myapp.host'. Rename one of them so each value has a unique Helm path.",
Assert.IsType<string>(reporter.CompletionMessage));
}
[Fact]
public async Task PublishAsync_EmbeddedParametersWithSameNormalizedValuesPathReportError()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var reporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IPipelineActivityReporter>(reporter);
builder.AddKubernetesEnvironment("env");
var dashedHost = builder.AddParameter("api-host", "dashed-host", publishValueAsDefault: true);
var underscoredHost = new ParameterResource("api_host", _ => "underscored-host");
builder.AddContainer("myapp", "nginx")
.WithEnvironment("URL", $"http://{dashedHost}/{underscoredHost}");
using var app = builder.Build();
await app.RunAsync();
Assert.Equal(CompletionState.CompletedWithError, reporter.ResultCompletionState);
Assert.Contains(
"Resource 'myapp' maps both embedded parameter 'api-host' and embedded parameter 'api_host' " +
"to Helm values path 'config.myapp.api_host'. Rename one of them so each value has a unique Helm path.",
Assert.IsType<string>(reporter.CompletionMessage));
}
[Fact]
public async Task PublishAsync_DistinctEmbeddedParameterSourcesWithSameNameReportError()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
var firstHost = new ParameterResource("host", _ => "first-host");
var secondHost = new ParameterResource("host", _ => "second-host");
builder.AddContainer("myapp", "nginx")
.WithEnvironment("URL", $"http://{firstHost}/{secondHost}");
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => app.RunAsync());
Assert.Contains(
"Resource 'myapp' maps multiple distinct embedded parameter sources named 'host' " +
"to Helm values path 'config.myapp.host'. Reuse the same ParameterResource instance " +
"or give each source a unique name.",
exception.ToString());
}
[Fact]
public async Task PublishAsync_DistinctConditionalParameterSourcesWithSameNameReportError()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
var firstCondition = new ParameterResource("enable-tls", _ => bool.TrueString);
var secondCondition = new ParameterResource("enable-tls", _ => bool.FalseString);
builder.AddContainer("myapp", "nginx")
.WithEnvironment(context =>
{
context.EnvironmentVariables["FIRST"] = ReferenceExpression.CreateConditional(
firstCondition,
bool.TrueString,
ReferenceExpression.Create($"enabled"),
ReferenceExpression.Create($"disabled"));
context.EnvironmentVariables["SECOND"] = ReferenceExpression.CreateConditional(
secondCondition,
bool.TrueString,
ReferenceExpression.Create($"enabled"),
ReferenceExpression.Create($"disabled"));
});
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => app.RunAsync());
Assert.Contains(
"Resource 'myapp' maps multiple distinct condition parameter sources named 'enable-tls' " +
"to Helm values path 'parameters.myapp.enable_tls'. Reuse the same ParameterResource instance " +
"or give each source a unique name.",
exception.ToString());
}
[Fact]
public async Task PublishAsync_CompositeExpressionPreservesExpressionShape()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
var first = builder.AddParameter("first", "alpha", publishValueAsDefault: true);
var second = builder.AddParameter("second", "beta", publishValueAsDefault: true);
builder.AddContainer("myapp", "nginx")
.WithEnvironment("COMPOSITE", $"prefix-{{literal}}-{second}-{first}-{second}-suffix");
var app = builder.Build();
app.Run();
var expectedFiles = new[]
{
"values.yaml",
"templates/myapp/config.yaml",
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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_SharedEmbeddedParameterIsScopedPerResource()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
var sharedHost = builder.AddParameter("shared-host", "shared.internal", publishValueAsDefault: true);
builder.AddContainer("first", "nginx")
.WithEnvironment("URL", $"http://{sharedHost}/first");
builder.AddContainer("second", "nginx")
.WithEnvironment("URL", $"http://{sharedHost}/second");
var app = builder.Build();
app.Run();
var expectedFiles = new[]
{
"values.yaml",
"templates/first/config.yaml",
"templates/second/config.yaml",
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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_HandlesConditionalReferenceExpression()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.WithEnvironment(context =>
{
var conditional = ReferenceExpression.CreateConditional(
new TestValueProvider(bool.TrueString),
bool.TrueString,
ReferenceExpression.Create($",ssl=true"),
ReferenceExpression.Empty);
context.EnvironmentVariables["TLS_SUFFIX"] = conditional;
var conditionalFalse = ReferenceExpression.CreateConditional(
new TestValueProvider(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/env-dashboard/deployment.yaml",
"templates/env-dashboard/service.yaml",
"templates/myapp/deployment.yaml",
"templates/myapp/config.yaml",
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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_ConditionalBranchesCaptureEmbeddedParameters()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
var mode = builder.AddParameter("mode", "enabled", publishValueAsDefault: true);
var user = builder.AddParameter("user", "alice", publishValueAsDefault: true);
var password = builder.AddParameter("password", "test-password", secret: true);
builder.AddContainer("myapp", "nginx")
.WithEnvironment(context =>
{
context.EnvironmentVariables["OPTIONS"] = ReferenceExpression.CreateConditional(
mode.Resource,
"enabled",
ReferenceExpression.Create($"user={user};password={password}"),
ReferenceExpression.Create($"user={user};disabled"));
});
var app = builder.Build();
app.Run();
var expectedFiles = new[]
{
"values.yaml",
"templates/myapp/secrets.yaml",
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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 workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var environment = 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();
Assert.Contains(environment.Resource.CapturedHelmValues, captured =>
captured.Section == "parameters" &&
captured.ResourceKey == "myapp" &&
captured.ValueKey == "enable_tls" &&
captured.Parameter == enableTls.Resource);
var expectedFiles = new[]
{
"Chart.yaml",
"values.yaml",
"templates/env-dashboard/deployment.yaml",
"templates/env-dashboard/service.yaml",
"templates/myapp/deployment.yaml",
"templates/myapp/config.yaml",
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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 workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.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/env-dashboard/deployment.yaml",
"templates/env-dashboard/service.yaml",
"templates/myapp/deployment.yaml",
"templates/myapp/config.yaml",
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.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_HandlesScalarEnvironmentVariableTypes()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.WithEnvironment(context =>
{
context.EnvironmentVariables["BOOL_TRUE"] = true;
context.EnvironmentVariables["BOOL_FALSE"] = false;
context.EnvironmentVariables["INT_VALUE"] = 42;
context.EnvironmentVariables["DOUBLE_VALUE"] = 3.14;
context.EnvironmentVariables["DATETIMEOFFSET_VALUE"] = new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero);
context.EnvironmentVariables["TIMESPAN_VALUE"] = TimeSpan.FromMinutes(90);
context.EnvironmentVariables["URI_VALUE"] = new Uri("https://example.com/path");
int? nullableIntWithValue = 7;
context.EnvironmentVariables["NULLABLE_INT_WITH_VALUE"] = nullableIntWithValue!;
})
.WithEnvironment("STRING_VALUE", "hello");
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(workspace.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_WithPvcStorageType_GeneratesValidPersistentVolumeClaim()
{
// Regression test for https://github.com/microsoft/aspire/issues/16504 (PVC emits empty
// dataSource/dataSourceRef/selector blocks) caused by eager `= new()` initialization of
// complex sub-properties on V1 spec types; once those properties are nullable the YAML
// serializer's OmitNull configuration suppresses the empty mappings.
//
// Also asserts that the publisher does NOT emit a bare PersistentVolume manifest for
// DefaultStorageType = "pvc": dynamic provisioning is driven by the storageClassName on
// the PVC, and a PV without a PersistentVolumeSource (csi/hostPath/local/nfs/...) is
// rejected by `kubectl apply`. See
// https://kubernetes.io/docs/concepts/storage/persistent-volumes/#dynamic.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env")
.WithProperties(env =>
{
env.DefaultStorageType = "pvc";
env.DefaultStorageClassName = "managed-csi";
env.DefaultStorageSize = "10Gi";
env.DefaultStorageReadWritePolicy = "ReadWriteOnce";
});
builder.AddContainer("service", "nginx")
.WithVolume("data", "/var/lib/data");
var app = builder.Build();
app.Run();
var expectedFiles = new[]
{
"templates/service/deployment.yaml",
"templates/service/data-pvc.yaml",
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.Path, expectedFile);
Assert.True(File.Exists(filePath), $"Expected publisher to emit {expectedFile}.");
var content = await File.ReadAllTextAsync(filePath);
// Empty `{ }` mappings on the specific spec fields that Kubernetes rejects are
// the failure mode the bug fixes address; assert against them explicitly so a
// regression is obvious without snapshot-diffing. Uses the targeted helper
// because valid Kubernetes YAML routinely contains `{}` shorthand (e.g.
// `emptyDir: {}`, `resources: {}`), which a bare Assert.DoesNotContain("{}")
// would flag as a false positive.
AssertNoBuggyEmptyMappings(content);
settingsTask = settingsTask is null
? Verify(content, "yaml")
: settingsTask.AppendContentAsFile(content, "yaml");
}
// The bare PV manifest (data-pv.yaml) must NOT be emitted — see method comment.
var legacyPv = Path.Combine(workspace.Path, "templates", "service", "data-pv.yaml");
Assert.False(File.Exists(legacyPv), "DefaultStorageType=\"pvc\" must not emit a bare PV; dynamic provisioning is driven by the PVC's storageClassName.");
await settingsTask;
}
[Fact]
public async Task PublishAsync_WithHostPathStorageType_EmitsHostPathVolumeOnPodSpec()
{
// Regression test for the hostPath emission path in WithPodSpecVolumes. When
// DefaultStorageType = "hostpath" the publisher renders the volume directly as a
// pod hostPath volume (no PV/PVC objects are emitted — only the Deployment manifest
// is produced). The original bug emitted empty `{}` mappings on the hostPath
// sub-properties; with the nullable-property fixes those are now suppressed.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env")
.WithProperties(env =>
{
env.DefaultStorageType = "hostpath";
env.DefaultStorageSize = "5Gi";
});
builder.AddContainer("service", "nginx")
.WithVolume("logs", "/var/log/service");
var app = builder.Build();
app.Run();
var deploymentPath = Path.Combine(workspace.Path, "templates", "service", "deployment.yaml");
Assert.True(File.Exists(deploymentPath));
var deploymentContent = await File.ReadAllTextAsync(deploymentPath);
Assert.DoesNotContain("{}", deploymentContent);
await Verify(deploymentContent, "yaml");
}
[Fact]
public async Task PublishAsync_WithFirstClassPersistentVolume_BindsByName_PromotesToStatefulSet()
{
// First-class persistent volumes bind to a workload by matching a
// ContainerMountAnnotation source name. The publisher routes the pod's
// volumes[] entry through the generated PVC and promotes the workload to a
// StatefulSet (regardless of whether the resource implements
// IResourceWithConnectionString).
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var data = k8s.AddPersistentVolume("data")
.WithStorageClass("managed-csi")
.WithCapacity("20Gi")
.WithAccessMode(PersistentVolumeAccessMode.ReadWriteOnce)
.WithVolumeAnnotation("volume.beta.kubernetes.io/storage-provisioner", "disk.csi.azure.com");
builder.AddContainer("service", "nginx")
.WithVolume("data", "/var/lib/data")
.WithPersistentVolume(data);
var app = builder.Build();
app.Run();
var expectedFiles = new[]
{
"templates/service/statefulset.yaml",
"templates/data/data.yaml",
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.Path, expectedFile);
Assert.True(File.Exists(filePath), $"Expected publisher to emit {expectedFile}.");
var content = await File.ReadAllTextAsync(filePath);
AssertNoBuggyEmptyMappings(content);
settingsTask = settingsTask is null
? Verify(content, "yaml")
: settingsTask.AppendContentAsFile(content, "yaml");
}
// The pre-existing per-resource PVC under templates/service/ must NOT be emitted —
// the binding consumes the volume mount and routes it to the standalone PVC instead.
var legacyPvc = Path.Combine(workspace.Path, "templates", "service", "data-pvc.yaml");
Assert.False(File.Exists(legacyPvc), "Bound volumes must not also be emitted via the env-default PV/PVC path.");
// And no Deployment manifest for the bound workload — it must promote to StatefulSet.
var deploymentPath = Path.Combine(workspace.Path, "templates", "service", "deployment.yaml");
Assert.False(File.Exists(deploymentPath), "Workloads bound to a persistent volume must render as a StatefulSet, not a Deployment.");
await settingsTask;
}
[Fact]
public async Task PublishAsync_WithFirstClassPersistentVolume_OnProject_BindsViaMountPathOverload()
{
// Closes https://github.com/microsoft/aspire/issues/9430 — projects can bind a
// persistent volume via the (volume, mountPath) overload, which adds the
// ContainerMountAnnotation itself.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var media = k8s.AddPersistentVolume("media")
.WithStorageClass("azurefile-csi")
.WithCapacity("100Gi")
.WithAccessMode(PersistentVolumeAccessMode.ReadWriteMany);
builder.AddProject<TestProject>("api", launchProfileName: null)
.WithPersistentVolume(media, "/srv/media");
var app = builder.Build();
app.Run();
var expectedFiles = new[]
{
"templates/api/statefulset.yaml",
"templates/media/media.yaml",
};
SettingsTask settingsTask = default!;
foreach (var expectedFile in expectedFiles)
{
var filePath = Path.Combine(workspace.Path, expectedFile);
Assert.True(File.Exists(filePath), $"Expected publisher to emit {expectedFile}.");
var content = await File.ReadAllTextAsync(filePath);
AssertNoBuggyEmptyMappings(content);
settingsTask = settingsTask is null
? Verify(content, "yaml")
: settingsTask.AppendContentAsFile(content, "yaml");
}
await settingsTask;
}
[Fact]
public async Task PublishAsync_WithFirstClassPersistentVolume_KubernetesCustomizationOverridesDefaultFsGroup()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var data = k8s.AddPersistentVolume("data");
builder.AddProject<TestProject>("api", launchProfileName: null)
.WithPersistentVolume(data, "/srv/data")
.PublishAsKubernetesService(resource =>
{
var podSpec = resource.Workload?.PodTemplate.Spec
?? throw new InvalidOperationException("The Kubernetes workload was not generated.");
podSpec.SecurityContext ??= new();
podSpec.SecurityContext.FsGroup = 3000;
});
var app = builder.Build();
app.Run();
var statefulSetPath = Path.Combine(workspace.Path, "templates", "api", "statefulset.yaml");
var content = await File.ReadAllTextAsync(statefulSetPath);
await Verify(content, "yaml");
}
[Fact]
public async Task PublishAsync_WithFirstClassPersistentVolume_KubernetesCustomizationCanRemoveDefaultSecurityContext()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var data = k8s.AddPersistentVolume("data");
builder.AddProject<TestProject>("api", launchProfileName: null)
.WithPersistentVolume(data, "/srv/data")
.PublishAsKubernetesService(resource =>
{
var podSpec = resource.Workload?.PodTemplate.Spec
?? throw new InvalidOperationException("The Kubernetes workload was not generated.");
podSpec.SecurityContext = null;
});
var app = builder.Build();
app.Run();
var statefulSetPath = Path.Combine(workspace.Path, "templates", "api", "statefulset.yaml");
var content = await File.ReadAllTextAsync(statefulSetPath);
var yaml = new YamlStream();
using (var reader = new StringReader(content))
{
yaml.Load(reader);
}
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
var podSpec = (YamlMappingNode)root["spec"]["template"]["spec"];
Assert.False(podSpec.Children.ContainsKey(new YamlScalarNode("securityContext")));
await Verify(content, "yaml");
}
[Fact]
public async Task PublishAsync_WithFirstClassPersistentVolume_FallsThroughForUnboundVolumes()
{
// A workload may declare both a bound and an unbound volume. The bound one
// routes through the standalone PVC; the unbound one falls through to the
// env-default storage type (here: emptyDir) so existing workloads are not
// perturbed by this feature.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var data = k8s.AddPersistentVolume("data")
.WithStorageClass("managed-csi")
.WithCapacity("5Gi");
builder.AddContainer("service", "nginx")
.WithVolume("data", "/var/lib/data")
.WithVolume("scratch", "/srv/scratch")
.WithPersistentVolume(data);
var app = builder.Build();
app.Run();
var statefulSetPath = Path.Combine(workspace.Path, "templates", "service", "statefulset.yaml");
Assert.True(File.Exists(statefulSetPath));
var statefulSetContent = await File.ReadAllTextAsync(statefulSetPath);
AssertNoBuggyEmptyMappings(statefulSetContent);
var pvcPath = Path.Combine(workspace.Path, "templates", "data", "data.yaml");
Assert.True(File.Exists(pvcPath));
var pvcContent = await File.ReadAllTextAsync(pvcPath);
Assert.DoesNotContain("{}", pvcContent);
await Verify(statefulSetContent, "yaml")
.AppendContentAsFile(pvcContent, "yaml");
}
[Fact]
public async Task PublishAsync_WithFirstClassPersistentVolume_ThrowsWhenBoundAcrossEnvironments()
{
// A persistent volume declared on one Kubernetes environment cannot be bound
// by a workload assigned to a different environment: the two charts render
// into separate namespaces/clusters, so the workload's claimName would
// reference a PVC that does not exist alongside it. Fail fast at publish time
// with a clear message rather than silently emitting broken YAML.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var envA = builder.AddKubernetesEnvironment("envA");
var envB = builder.AddKubernetesEnvironment("envB");
var data = envA.AddPersistentVolume("data").WithCapacity("5Gi");
builder.AddContainer("service", "nginx")
.WithComputeEnvironment(envB)
.WithVolume("data", "/var/lib/data")
.WithPersistentVolume(data);
var app = builder.Build();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => app.RunAsync());
Assert.Contains("service", ex.Message);
Assert.Contains("envA", ex.Message);
Assert.Contains("envB", ex.Message);
Assert.Contains("data", ex.Message);
}
[Fact]
public async Task PublishAsync_WithFirstClassPersistentVolume_ReadOnlyFlag_PropagatesToVolumeMountAndPodVolume()
{
// The mount-path overload of WithPersistentVolume accepts an `isReadOnly`
// parameter. That flag must reach both the container's volumeMounts[i].readOnly
// (so the container sees the mount as read-only) AND the pod's
// volumes[i].persistentVolumeClaim.readOnly (so Kubernetes enforces read-only
// at the volume-source layer even if a co-scheduled container in the same
// pod forgot to set the mount flag). Prior to this test, both emission
// paths silently dropped the flag, so `isReadOnly: true` was a no-op.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var media = k8s.AddPersistentVolume("media")
.WithStorageClass("azurefile-csi")
.WithCapacity("50Gi")
.WithAccessMode(PersistentVolumeAccessMode.ReadOnlyMany);
builder.AddProject<TestProject>("api", launchProfileName: null)
.WithPersistentVolume(media, "/srv/media", isReadOnly: true);
var app = builder.Build();
app.Run();
var statefulSetPath = Path.Combine(workspace.Path, "templates", "api", "statefulset.yaml");
Assert.True(File.Exists(statefulSetPath), $"Expected StatefulSet manifest at {statefulSetPath}");
var content = await File.ReadAllTextAsync(statefulSetPath);
AssertNoBuggyEmptyMappings(content);
// Parse the emitted YAML and walk to the exact fields we care about so the
// assertion isn't satisfied by a `readOnly:` on some unrelated field
// (e.g. a securityContext) that happens to share the substring.
var yaml = new YamlStream();
using (var reader = new StringReader(content))
{
yaml.Load(reader);
}
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
var podSpec = (YamlMappingNode)root["spec"]["template"]["spec"];
var securityContext = (YamlMappingNode)podSpec["securityContext"];
Assert.Equal("2000", ((YamlScalarNode)securityContext["fsGroup"]).Value);
Assert.Equal("OnRootMismatch", ((YamlScalarNode)securityContext["fsGroupChangePolicy"]).Value);
// Container mount side: containers[0].volumeMounts[?(@.name == "media")].readOnly == true
var container = (YamlMappingNode)((YamlSequenceNode)podSpec["containers"])[0];
var volumeMount = ((YamlSequenceNode)container["volumeMounts"])
.Cast<YamlMappingNode>()
.Single(m => ((YamlScalarNode)m["name"]).Value == "media");
Assert.Equal("true", ((YamlScalarNode)volumeMount["readOnly"]).Value);
// Pod volume-source side: spec.volumes[?(@.name == "media")].persistentVolumeClaim.readOnly == true
var podVolume = ((YamlSequenceNode)podSpec["volumes"])
.Cast<YamlMappingNode>()
.Single(v => ((YamlScalarNode)v["name"]).Value == "media");
var pvcSource = (YamlMappingNode)podVolume["persistentVolumeClaim"];
Assert.Equal("true", ((YamlScalarNode)pvcSource["readOnly"]).Value);
}
[Fact]
public async Task PublishAsync_WithFirstClassPersistentVolume_ParameterOverloads_CaptureValuesInHelmChart()
{
// The parameterized overloads (WithStorageClass(ParameterResource),
// WithCapacity(ParameterResource), WithVolumeAnnotation(key, ParameterResource))
// go through the Helm placeholder / value-capture path: each parameter must
// render into the PVC manifest as `{{ .Values.parameters.<pv-name>.<param> }}`
// and land in values.yaml under `parameters.<pv-name>.<param>`. Literal-value
// overloads never exercise this path, so a regression here could silently
// break the generated PVC YAML or values.yaml without failing any other test.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
// Use unique parameter names per run to defeat any persistent state file lookup.
var suffix = Guid.NewGuid().ToString("N");
var storageClassName = $"storageclass{suffix}";
var capacityName = $"capacity{suffix}";
var backupTierName = $"backuptier{suffix}";
var storageClassParam = builder.AddParameter(storageClassName);
var capacityParam = builder.AddParameter(capacityName);
var backupTierParam = builder.AddParameter(backupTierName);
var k8s = builder.AddKubernetesEnvironment("env");
var data = k8s.AddPersistentVolume("data")
.WithStorageClass(storageClassParam)
.WithCapacity(capacityParam)
.WithAccessMode(PersistentVolumeAccessMode.ReadWriteOnce)
.WithVolumeAnnotation("backup.example.com/tier", backupTierParam);
builder.AddContainer("service", "nginx")
.WithVolume("data", "/var/lib/data")
.WithPersistentVolume(data);
var app = builder.Build();
app.Run();
var pvcPath = Path.Combine(workspace.Path, "templates", "data", "data.yaml");
Assert.True(File.Exists(pvcPath), $"Expected PVC manifest at {pvcPath}");
var pvcContent = await File.ReadAllTextAsync(pvcPath);
AssertNoBuggyEmptyMappings(pvcContent);
// Every parameter must render as a Helm template reference scoped to the PV name,
// not as a raw ReferenceExpression placeholder ("{0}") or an inlined literal.
Assert.DoesNotContain("\"{0}\"", pvcContent);
Assert.Contains($"{{{{ .Values.parameters.data.{storageClassName} }}}}", pvcContent);
Assert.Contains($"{{{{ .Values.parameters.data.{capacityName} }}}}", pvcContent);
Assert.Contains($"{{{{ .Values.parameters.data.{backupTierName} }}}}", pvcContent);
var valuesPath = Path.Combine(workspace.Path, "values.yaml");
Assert.True(File.Exists(valuesPath), $"Expected values.yaml at {valuesPath}");
var valuesContent = await File.ReadAllTextAsync(valuesPath);
// Consumers of the published Helm chart fill these in via --set / values overrides;
// each parameter must appear under parameters.data.<name>: so `helm template`
// won't substitute <no value>.
var parametersBlockRegex = new System.Text.RegularExpressions.Regex(
@"parameters:\s*[\r\n]+\s*data:\s*[\r\n]+(?:\s+\w+:.*[\r\n]+)*\s*" +
System.Text.RegularExpressions.Regex.Escape(storageClassName) + @"\s*:");
Assert.Matches(parametersBlockRegex, valuesContent);
Assert.Matches(
new System.Text.RegularExpressions.Regex(@"parameters:[\s\S]+data:[\s\S]+" +
System.Text.RegularExpressions.Regex.Escape(capacityName) + @"\s*:"),
valuesContent);
Assert.Matches(
new System.Text.RegularExpressions.Regex(@"parameters:[\s\S]+data:[\s\S]+" +
System.Text.RegularExpressions.Regex.Escape(backupTierName) + @"\s*:"),
valuesContent);
}
/// <summary>
/// Asserts that the rendered YAML does not contain known-buggy empty <c>{}</c>
/// mappings that Kubernetes rejects on apply. Some <c>{}</c> mappings — such as
/// <c>emptyDir: {}</c> — are valid Kubernetes shorthand and must be permitted.
/// </summary>
private static void AssertNoBuggyEmptyMappings(string content)
{
string[] buggyPatterns =
[
"volumeClaimRetentionPolicy: {}",
"updateStrategy: {}",
"rollingUpdate: {}",
"dataSource: {}",
"dataSourceRef: {}",
"selector: {}",
"claimRef: {}",
"hostPath: {}",
"local: {}",
"nodeAffinity:",
];
foreach (var pattern in buggyPatterns)
{
if (pattern == "nodeAffinity:")
{
// nodeAffinity is only buggy when followed by an empty `required: {}` block.
Assert.DoesNotContain("required: {}", content);
}
else
{
Assert.DoesNotContain(pattern, content);
}
}
}
private sealed class TestProject : IProjectMetadata
{
public string ProjectPath => "another-path";
public LaunchSettings? LaunchSettings { get; set; }
}
private sealed class TestProjectWithLaunchSettings : IProjectMetadata
{
public Dictionary<string, LaunchProfile>? Profiles { get; set; } = [];
public string ProjectPath => "another-path";
public LaunchSettings? LaunchSettings => new() { Profiles = Profiles! };
public TestProjectWithLaunchSettings() => Profiles = new()
{
["https"] = new()
{
CommandName = "Project",
LaunchBrowser = true,
ApplicationUrl = "http://localhost:5031;https://localhost:5032",
EnvironmentVariables = new()
{
["ASPNETCORE_ENVIRONMENT"] = "Development"
}
},
["http"] = new()
{
CommandName = "Project",
LaunchBrowser = true,
ApplicationUrl = "http://localhost:5031",
EnvironmentVariables = new()
{
["ASPNETCORE_ENVIRONMENT"] = "Development"
}
}
};
}
}