diff --git a/src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesEnvironmentResource.cs b/src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesEnvironmentResource.cs index 2f1b3918db4..baaeedf32ca 100644 --- a/src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesEnvironmentResource.cs @@ -214,7 +214,12 @@ private static bool HasPersistedHelmReleaseState(JsonObject deploymentState) /// /// Gets the underlying Kubernetes environment resource used for Helm-based deployment. /// - internal KubernetesEnvironmentResource KubernetesEnvironment { get; set; } = default!; + /// + /// This is the environment used by the AKS chart-generation pipeline, not a separate compute environment. + /// Its is available after + /// the publish-{Name} pipeline step completes. + /// + public KubernetesEnvironmentResource KubernetesEnvironment { get; internal set; } = default!; /// /// Gets the resource ID of the AKS cluster. diff --git a/src/Aspire.Hosting.Kubernetes/HelmChartPublishingResult.cs b/src/Aspire.Hosting.Kubernetes/HelmChartPublishingResult.cs new file mode 100644 index 00000000000..c766844e43d --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/HelmChartPublishingResult.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Kubernetes; + +/// +/// Describes a generated Helm chart and the value mappings needed by an external deployment publisher. +/// +/// +/// This is a read-only snapshot of publishing metadata, not of the chart files or the application model. +/// Resource and value-provider objects retain their original identity and are not evaluated or cloned. +/// Secret parameters and provisioned outputs remain deferred until the consuming publisher resolves them. +/// +public sealed class HelmChartPublishingResult +{ + internal HelmChartPublishingResult(string outputPath, KubernetesEnvironmentResource environment) + { + OutputPath = outputPath; + + // Repeated chart generation can capture the same mapping more than once. + // Copy distinct entries without exposing or modifying the publisher's capture buffers. + Parameters = Array.AsReadOnly(environment.CapturedHelmValues.Distinct() + .Select(value => new HelmValueMapping(value.Section, value.ResourceKey, value.ValueKey, value.Parameter)) + .ToArray()); + ValueProviders = Array.AsReadOnly(environment.CapturedHelmValueProviders.Distinct() + .Select(value => new HelmValueMapping(value.Section, value.ResourceKey, value.ValueKey, value.ValueProvider)) + .ToArray()); + Images = Array.AsReadOnly(environment.CapturedHelmImageReferences.Distinct() + .Select(value => new HelmValueMapping(value.Section, value.ResourceKey, value.ValueKey, value.Resource)) + .ToArray()); + CrossReferences = Array.AsReadOnly(environment.CapturedHelmCrossReferences.Distinct() + .Select(value => new HelmValueMapping(value.Section, value.ResourceKey, value.ValueKey, value.TemplateValue)) + .ToArray()); + } + + /// + /// Gets the directory containing Chart.yaml, values.yaml, and the chart templates. + /// + public string OutputPath { get; } + + /// + /// Gets the mappings to parameters that require deployment-time resolution, including secret parameters. + /// + public IReadOnlyList> Parameters { get; } + + /// + /// Gets the mappings to deferred value providers, such as provisioned outputs. + /// + public IReadOnlyList> ValueProviders { get; } + + /// + /// Gets the mappings to resources whose container images must be resolved at deployment time. + /// + /// + /// Use to resolve an image after its registry is available. + /// + public IReadOnlyList> Images { get; } + + /// + /// Gets the mappings to composite templates containing references to other Helm values. + /// + /// + /// For example, a template can contain {{ .Values.secrets.cache.password }}. + /// Resolve the referenced parameter mappings before substituting these expressions. + /// + public IReadOnlyList> CrossReferences { get; } +} diff --git a/src/Aspire.Hosting.Kubernetes/HelmValueMapping.cs b/src/Aspire.Hosting.Kubernetes/HelmValueMapping.cs new file mode 100644 index 00000000000..8a23145fc19 --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/HelmValueMapping.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Kubernetes; + +/// +/// Maps a Helm values path to a source that can be resolved by a deployment publisher. +/// +/// The type of the deferred source or template. +/// +/// The path consists of three separate keys: , , +/// and . Treat each key as a literal dictionary key, not as a dotted path. +/// +public sealed class HelmValueMapping +{ + internal HelmValueMapping(string section, string resourceKey, string valueKey, T value) + { + Section = section; + ResourceKey = resourceKey; + ValueKey = valueKey; + Value = value; + } + + /// + /// Gets the top-level values section, such as parameters, config, or secrets. + /// + public string Section { get; } + + /// + /// Gets the resource key within the values section. + /// + public string ResourceKey { get; } + + /// + /// Gets the value key within the resource's values. + /// + public string ValueKey { get; } + + /// + /// Gets the original deferred source or the unresolved Helm template. + /// + public T Value { get; } +} diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs index 0c41f9efa27..2e0b88526dc 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs @@ -149,6 +149,31 @@ public sealed class KubernetesEnvironmentResource : Resource, IComputeEnvironmen internal IPortAllocator PortAllocator { get; } = new PortAllocator(); + /// + /// Gets the generated Helm chart and its deferred value mappings, or null before publishing completes. + /// + /// + /// Read this property from a pipeline step that depends on publish-{Name}. + /// A successful publish replaces the result with a new read-only snapshot. Starting another + /// publish clears the property, and a failed publish does not expose a successful result. + /// Previously obtained snapshots remain unchanged, but their referenced resources and chart files + /// are not copied. + /// + /// + /// + /// var environment = builder.AddKubernetesEnvironment("k8s"); + /// builder.Pipeline.AddStep("consume-chart", context => + /// { + /// var result = environment.Resource.HelmChartPublishingResult + /// ?? throw new InvalidOperationException("The Helm chart has not been published."); + /// // Consume result.OutputPath and its deferred value mappings. + /// return Task.CompletedTask; + /// }, dependsOn: $"publish-{environment.Resource.Name}", requiredBy: "publish"); + /// + /// + [AspireExportIgnore(Reason = "Publishing results are consumed by C# deployment pipeline extensions, not AppHost authoring APIs.")] + public HelmChartPublishingResult? HelmChartPublishingResult { get; internal set; } + /// /// Captured parameter-to-values.yaml mappings populated during publish, consumed during deploy /// to resolve parameter values and composite references into the environment values file. diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs index cbd0a961c80..b92a027164b 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs @@ -45,6 +45,8 @@ internal sealed class KubernetesPublishingContext( internal async Task WriteModelAsync(DistributedApplicationModel model, KubernetesEnvironmentResource environment) { + environment.HelmChartPublishingResult = null; + if (!executionContext.IsPublishMode) { return; @@ -63,6 +65,8 @@ internal async Task WriteModelAsync(DistributedApplicationModel model, Kubernete await WriteKubernetesOutputAsync(model, environment).ConfigureAwait(false); + environment.HelmChartPublishingResult = new HelmChartPublishingResult(OutputPath, environment); + logger.FinishGeneratingKubernetes(OutputPath); } diff --git a/src/Aspire.Hosting.Kubernetes/README.md b/src/Aspire.Hosting.Kubernetes/README.md index c49bbc18752..4bc9ac6bf7b 100644 --- a/src/Aspire.Hosting.Kubernetes/README.md +++ b/src/Aspire.Hosting.Kubernetes/README.md @@ -62,6 +62,30 @@ For this example, `aspire publish` writes `registry.example.com/apps/api:latest` Literal registry values and non-secret parameter defaults can be resolved during publishing. Registry values that require provisioning, secrets, or parameters without defaults remain deferred, with `:latest` as the published placeholder. `aspire deploy` resolves the image names again after provisioning and supplies them through a Helm values override file. If deploying the published chart yourself, supply the deferred image values to Helm. +### Consuming generated charts from a deployment publisher + +C# deployment pipeline extensions can read `KubernetesEnvironmentResource.HelmChartPublishingResult` +from a step that depends on `publish-{environment.Name}`. For AKS, use +`AzureKubernetesEnvironmentResource.KubernetesEnvironment` to access the actual chart-producing +environment; do not register a second Kubernetes environment. + +The result is `null` until chart generation succeeds. It contains the chart's `OutputPath` and +read-only mappings for `Parameters`, `ValueProviders`, `Images`, and `CrossReferences`. +Each mapping preserves `Section`, `ResourceKey`, and `ValueKey` as separate dictionary keys. +Parameter, provider, and image-resource objects retain their original identity; publishing the +result does not evaluate them or materialize secrets. + +Resolve parameters and provisioned value providers only in the external publisher's appropriate +deployment phase. Resolve image resources with `ContainerImageReference` after registries are +available. Composite cross-reference templates contain Helm expressions such as +`{{ .Values.secrets.cache.password }}` that must be substituted using the resolved parameter +mappings. Merely copying `values.yaml` does not resolve these deferred values. + +Every successful publish produces a new metadata snapshot. Previous snapshots do not change, +but referenced application-model objects and files in the output directory are not copied. +Starting a new publish clears the current result; a failed publish leaves it `null`. +This API generates no additional deployment steps and does not provision infrastructure. + ### Volumes Use a target-neutral volume when the Kubernetes environment's default storage policy is sufficient: diff --git a/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesEnvironmentExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesEnvironmentExtensionsTests.cs index 8110a0bb363..64f77735dd0 100644 --- a/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesEnvironmentExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesEnvironmentExtensionsTests.cs @@ -20,6 +20,48 @@ namespace Aspire.Hosting.Azure.Tests; public class AzureKubernetesEnvironmentExtensionsTests(ITestOutputHelper outputHelper) { + [Theory] + [InlineData(DistributedApplicationOperation.Run)] + [InlineData(DistributedApplicationOperation.Publish)] + public void KubernetesEnvironmentExposesActualChartEnvironment(DistributedApplicationOperation operation) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = AzureKubernetesTestBuilder.Create(outputHelper, workspace, operation); + var aks = builder.AddAzureKubernetesEnvironment("aks"); + var chart = aks.AddHelmChart("sample", "oci://registry.example.com/charts/sample", "1.0.0"); + + Assert.Same(aks.Resource.KubernetesEnvironment, chart.Resource.Parent); + Assert.Same(aks.Resource, aks.Resource.KubernetesEnvironment.OwningComputeEnvironment); + Assert.Null(aks.Resource.KubernetesEnvironment.HelmChartPublishingResult); + Assert.Empty(builder.Resources.OfType()); + } + + [Fact] + public async Task KubernetesEnvironmentExposesPublishedChartToDependentStep() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = AzureKubernetesTestBuilder.Create(outputHelper, workspace); + var aks = builder.AddAzureKubernetesEnvironment("aks"); + var parameter = builder.AddParameter("setting"); + builder.AddContainer("api", "nginx") + .WithEnvironment("SETTING", parameter) + .WithComputeEnvironment(aks); + HelmChartPublishingResult? observed = null; + builder.Pipeline.AddStep("consume-chart", _ => + { + observed = Assert.IsType(aks.Resource.KubernetesEnvironment.HelmChartPublishingResult); + Assert.True(File.Exists(Path.Combine(observed.OutputPath, "Chart.yaml"))); + Assert.Contains(observed.Parameters, mapping => ReferenceEquals(mapping.Value, parameter.Resource)); + return Task.CompletedTask; + }, dependsOn: $"publish-{aks.Resource.KubernetesEnvironment.Name}", requiredBy: WellKnownPipelineSteps.Publish); + + using var app = builder.Build(); + await app.RunAsync(); + + Assert.NotNull(observed); + Assert.Same(observed, aks.Resource.KubernetesEnvironment.HelmChartPublishingResult); + } + [Fact] public async Task AddAzureKubernetesEnvironment_BasicConfiguration() { diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/HelmChartPublishingResultTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/HelmChartPublishingResultTests.cs new file mode 100644 index 00000000000..4507338ad47 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/HelmChartPublishingResultTests.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Kubernetes.Tests; + +public class HelmChartPublishingResultTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task ResultIsAvailableToDependentStepAndPreservesDeferredSources() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + var environment = builder.AddKubernetesEnvironment("env").WithDashboard(false); + var parameter = builder.AddParameter("ordinary"); + var secret = builder.AddParameter("password", secret: true); + var provider = new TestValueProvider("must-not-be-published", "{outputs.deferred}"); + var api = builder.AddContainer("api-service", "nginx") + .WithEnvironment("ORDINARY", parameter) + .WithEnvironment("SECRET", secret) + .WithEnvironment("DEFERRED", provider) + .WithEnvironment("COMPOSITE", $"prefix-{parameter}-{secret}-suffix"); + var project = builder.AddDotnetProject("worker", "worker.csproj", options => options.ExcludeLaunchProfile = true); + + Assert.Null(environment.Resource.HelmChartPublishingResult); + HelmChartPublishingResult? observed = null; + builder.Pipeline.AddStep("consume-chart", _ => + { + observed = Assert.IsType(environment.Resource.HelmChartPublishingResult); + Assert.True(File.Exists(Path.Combine(observed.OutputPath, "Chart.yaml"))); + Assert.True(File.Exists(Path.Combine(observed.OutputPath, "values.yaml"))); + Assert.True(File.Exists(Path.Combine(observed.OutputPath, "templates", api.Resource.Name, "deployment.yaml"))); + return Task.CompletedTask; + }, dependsOn: $"publish-{environment.Resource.Name}", requiredBy: WellKnownPipelineSteps.Publish); + + using var app = builder.Build(); + await app.RunAsync(); + + var result = Assert.IsType(observed); + Assert.Same(result, environment.Resource.HelmChartPublishingResult); + Assert.Equal(workspace.Path, result.OutputPath); + Assert.Contains(result.Parameters, mapping => ReferenceEquals(mapping.Value, parameter.Resource)); + Assert.Contains(result.Parameters, mapping => ReferenceEquals(mapping.Value, secret.Resource)); + Assert.Same(provider, Assert.Single(result.ValueProviders).Value); + Assert.Equal(0, provider.GetValueCallCount); + Assert.Same(project.Resource, Assert.Single(result.Images).Value); + Assert.NotEmpty(result.CrossReferences); + + Assert.Equal(environment.Resource.CapturedHelmValues.Select(value => + (value.Section, value.ResourceKey, value.ValueKey, value.Parameter)), + result.Parameters.Select(value => (value.Section, value.ResourceKey, value.ValueKey, value.Value))); + Assert.Equal(environment.Resource.CapturedHelmValueProviders.Select(value => + (value.Section, value.ResourceKey, value.ValueKey, value.ValueProvider)), + result.ValueProviders.Select(value => (value.Section, value.ResourceKey, value.ValueKey, value.Value))); + Assert.Equal(environment.Resource.CapturedHelmImageReferences.Select(value => + (value.Section, value.ResourceKey, value.ValueKey, value.Resource)), + result.Images.Select(value => (value.Section, value.ResourceKey, value.ValueKey, value.Value))); + Assert.Equal(environment.Resource.CapturedHelmCrossReferences.Select(value => + (value.Section, value.ResourceKey, value.ValueKey, value.TemplateValue)), + result.CrossReferences.Select(value => (value.Section, value.ResourceKey, value.ValueKey, value.Value))); + + await Verify(new + { + Parameters = result.Parameters.Select(value => new { value.Section, value.ResourceKey, value.ValueKey, value.Value.Name, value.Value.Secret }), + ValueProviders = result.ValueProviders.Select(value => new { value.Section, value.ResourceKey, value.ValueKey }), + Images = result.Images.Select(value => new { value.Section, value.ResourceKey, value.ValueKey, value.Value.Name }), + result.CrossReferences + }).AppendContentAsFile(await File.ReadAllTextAsync(Path.Combine(result.OutputPath, "values.yaml")), "yaml"); + } + + [Fact] + public async Task RepeatedPublishingReplacesResultWithoutMutatingPreviousSnapshot() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + var environment = builder.AddKubernetesEnvironment("env").WithDashboard(false); + var parameter = builder.AddParameter("parameter", secret: true); + var provider = new TestValueProvider("deferred"); + builder.AddDotnetProject("api", "api.csproj", options => options.ExcludeLaunchProfile = true) + .WithEnvironment("PARAMETER", parameter) + .WithEnvironment("DEFERRED", provider) + .WithEnvironment("COMPOSITE", $"prefix-{parameter}"); + using var app = builder.Build(); + await app.RunAsync(); + + var first = Assert.IsType(environment.Resource.HelmChartPublishingResult); + var parameters = first.Parameters.ToArray(); + var providers = first.ValueProviders.ToArray(); + var images = first.Images.ToArray(); + var templates = first.CrossReferences.ToArray(); + Assert.Throws(() => ((IList>)first.Parameters).Clear()); + Assert.Throws(() => ((IList>)first.ValueProviders).Clear()); + Assert.Throws(() => ((IList>)first.Images).Clear()); + Assert.Throws(() => ((IList>)first.CrossReferences).Clear()); + + var secondOutput = Path.Combine(workspace.Path, "second"); + var context = new KubernetesPublishingContext(builder.ExecutionContext, secondOutput, NullLogger.Instance, environment.Resource); + await context.WriteModelAsync(app.Services.GetRequiredService(), environment.Resource); + + var second = Assert.IsType(environment.Resource.HelmChartPublishingResult); + Assert.NotSame(first, second); + Assert.Equal(secondOutput, second.OutputPath); + Assert.Equal(first.Parameters.Count, second.Parameters.Count); + Assert.Equal(first.ValueProviders.Count, second.ValueProviders.Count); + Assert.Equal(first.Images.Count, second.Images.Count); + Assert.Equal(first.CrossReferences.Count, second.CrossReferences.Count); + + environment.Resource.CapturedHelmValues.Clear(); + environment.Resource.CapturedHelmValueProviders.Clear(); + environment.Resource.CapturedHelmImageReferences.Clear(); + environment.Resource.CapturedHelmCrossReferences.Clear(); + Assert.Equal(parameters, first.Parameters); + Assert.Equal(providers, first.ValueProviders); + Assert.Equal(images, first.Images); + Assert.Equal(templates, first.CrossReferences); + Assert.Equal(workspace.Path, first.OutputPath); + } + + [Fact] + public async Task FailedPublishingClearsPreviousResult() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + var environment = builder.AddKubernetesEnvironment("env").WithDashboard(false); + builder.AddContainer("api", "nginx"); + using var app = builder.Build(); + await app.RunAsync(); + Assert.NotNull(environment.Resource.HelmChartPublishingResult); + + var outputFile = Path.Combine(workspace.Path, "not-a-directory"); + await File.WriteAllTextAsync(outputFile, "existing file"); + var context = new KubernetesPublishingContext(builder.ExecutionContext, outputFile, NullLogger.Instance, environment.Resource); + await Assert.ThrowsAnyAsync(() => + context.WriteModelAsync(app.Services.GetRequiredService(), environment.Resource)); + + Assert.Null(environment.Resource.HelmChartPublishingResult); + } + + [Fact] + public async Task RunModeDoesNotProduceResult() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var environment = builder.AddKubernetesEnvironment("env"); + using var app = builder.Build(); + var context = new KubernetesPublishingContext(builder.ExecutionContext, "unused", NullLogger.Instance, environment.Resource); + + await context.WriteModelAsync(app.Services.GetRequiredService(), environment.Resource); + + Assert.Null(environment.Resource.HelmChartPublishingResult); + } +} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/HelmChartPublishingResultTests.ResultIsAvailableToDependentStepAndPreservesDeferredSources.verified.txt b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/HelmChartPublishingResultTests.ResultIsAvailableToDependentStepAndPreservesDeferredSources.verified.txt new file mode 100644 index 00000000000..2d3e4948628 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/HelmChartPublishingResultTests.ResultIsAvailableToDependentStepAndPreservesDeferredSources.verified.txt @@ -0,0 +1,41 @@ +{ + Parameters: [ + { + Section: config, + ResourceKey: api_service, + ValueKey: ordinary, + Name: ordinary, + Secret: false + }, + { + Section: secrets, + ResourceKey: api_service, + ValueKey: password, + Name: password, + Secret: true + } + ], + ValueProviders: [ + { + Section: config, + ResourceKey: api_service, + ValueKey: DEFERRED + } + ], + Images: [ + { + Section: parameters, + ResourceKey: worker, + ValueKey: worker_image, + Name: worker + } + ], + CrossReferences: [ + { + Section: secrets, + ResourceKey: api_service, + ValueKey: COMPOSITE, + Value: prefix-{{ .Values.config.api_service.ordinary }}-{{ .Values.secrets.api_service.password }}-suffix + } + ] +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/HelmChartPublishingResultTests.ResultIsAvailableToDependentStepAndPreservesDeferredSources.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/HelmChartPublishingResultTests.ResultIsAvailableToDependentStepAndPreservesDeferredSources.verified.yaml new file mode 100644 index 00000000000..30ed87dcb03 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/HelmChartPublishingResultTests.ResultIsAvailableToDependentStepAndPreservesDeferredSources.verified.yaml @@ -0,0 +1,13 @@ +parameters: + worker: + worker_image: "worker:latest" +secrets: + api_service: + password: "" + COMPOSITE: "" +config: + api_service: + ordinary: "" + DEFERRED: "" + worker: + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/TestValueProvider.cs b/tests/Aspire.Hosting.Kubernetes.Tests/TestValueProvider.cs index 1021ea4de2a..244eb23b8a4 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/TestValueProvider.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/TestValueProvider.cs @@ -9,13 +9,20 @@ internal sealed class TestValueProvider( string value, string valueExpression = "{test-value}") : IValueProvider, IManifestExpressionProvider { + private int _getValueCallCount; + public string ValueExpression { get; } = valueExpression; + public int GetValueCallCount => Volatile.Read(ref _getValueCallCount); + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) - => new(value); + { + Interlocked.Increment(ref _getValueCallCount); + return new(value); + } public ValueTask GetValueAsync( ValueProviderContext context, CancellationToken cancellationToken = default) - => new(value); + => GetValueAsync(cancellationToken); }