From d956d93138d1d2c7b2190ad3fe45b053a81bd6c5 Mon Sep 17 00:00:00 2001 From: Prom3theu5 Date: Thu, 20 Mar 2025 23:50:41 +0000 Subject: [PATCH 1/6] k8s first pass no ingress support directly yet --- .../DockerComposePublisherLoggerExtensions.cs | 3 + .../DockerComposePublishingContext.cs | 3 +- .../Extensions/HelmExtensions.cs | 60 +++ .../Extensions/ResourceExtensions.cs | 428 ++++++++++++++++++ .../KubernetesPublisherLoggerExtensions.cs | 30 ++ .../KubernetesPublisherOptions.cs | 62 +++ .../KubernetesPublishingContext.cs | 168 ++++++- .../KubernetesResourceContext.cs | 368 +++++++++++++++ .../Resources/BaseKubernetesResource.cs | 24 - .../Resources/LabelSelectorV1.cs | 31 +- .../Resources/ObjectMetaV1.cs | 2 +- .../Resources/PersistentVolumeSpecV1.cs | 2 +- .../Resources/ServiceSpecV1.cs | 2 +- .../Aspire.Hosting.Kubernetes.Tests.csproj | 4 + .../ExpectedValues/Chart.yaml | 11 + .../templates/myapp/configmap.yaml | 13 + .../templates/myapp/deployment.yaml | 45 ++ .../templates/myapp/secret.yaml | 12 + .../templates/myapp/service.yaml | 15 + .../templates/project1/configmap.yaml | 15 + .../templates/project1/deployment.yaml | 30 ++ .../ExpectedValues/values.yaml | 19 + .../KubernetesPublisherTests.cs | 80 +++- 23 files changed, 1371 insertions(+), 56 deletions(-) create mode 100644 src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs create mode 100644 src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs create mode 100644 src/Aspire.Hosting.Kubernetes/KubernetesPublisherLoggerExtensions.cs create mode 100644 src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/Chart.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/configmap.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/deployment.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/secret.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/service.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/configmap.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/deployment.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/values.yaml diff --git a/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs b/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs index 58a84581dc4..4fcab3526f4 100644 --- a/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs +++ b/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs @@ -30,4 +30,7 @@ internal static partial class DockerComposePublisherLoggerExtensions [LoggerMessage(LogLevel.Warning, "Failed to get container image for resource '{ResourceName}', it will be skipped in the output.")] internal static partial void FailedToGetContainerImage(this ILogger logger, string resourceName); + + [LoggerMessage(LogLevel.Warning, "Not in publishing mode. Skipping writing docker-compose.yaml output file.")] + internal static partial void NotInPublishingMode(this ILogger logger); } diff --git a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs index 78c702bdb9b..1ce4a2ae4c8 100644 --- a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs +++ b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs @@ -33,8 +33,9 @@ internal sealed class DockerComposePublishingContext( internal async Task WriteModelAsync(DistributedApplicationModel model) { - if (executionContext.IsRunMode) + if (!executionContext.IsPublishMode) { + logger.NotInPublishingMode(); return; } diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs new file mode 100644 index 00000000000..383f20e75f9 --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs @@ -0,0 +1,60 @@ +// 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.Extensions; + +internal static class HelmExtensions +{ + private const string DeploymentKey = "deployment"; + private const string StatefulSetKey = "statefulset"; + private const string ServiceKey = "service"; + private const string PvcKey = "pvc"; + private const string PvKey = "pv"; + private const string ValuesSegment = ".Values"; + public const string ParametersKey = "parameters"; + public const string SecretsKey = "secrets"; + public const string ConfigKey = "config"; + public const string TemplateFileSeparator = "---"; + + public static string ToManifestFriendlyResourceName(this string name) + => name.Replace("-", "_"); + + public static string ToHelmParameterExpression(this string parameterName, string resourceName) + => $"{{{{ {ValuesSegment}.{ParametersKey}.{resourceName}.{parameterName} }}}}"; + + public static string ToHelmSecretExpression(this string parameterName, string resourceName) + => $"{{{{ {ValuesSegment}.{SecretsKey}.{resourceName}.{parameterName} }}}}"; + + public static string ToHelmConfigExpression(this string parameterName, string resourceName) + => $"{{{{ {ValuesSegment}.{ConfigKey}.{resourceName}.{parameterName} }}}}"; + + public static string ToConfigMapName(this string resourceName) + => $"{resourceName}-{ConfigKey}"; + + public static string ToSecretName(this string resourceName) + => $"{resourceName}-{SecretsKey}"; + + public static string ToDeploymentName(this string resourceName) + => $"{resourceName}-{DeploymentKey}"; + + public static string ToStatefulSetName(this string resourceName) + => $"{resourceName}-{StatefulSetKey}"; + + public static string ToServiceName(this string resourceName) + => $"{resourceName}-{ServiceKey}"; + + public static string ToPvcName(this string resourceName, string volumeName) + => $"{resourceName}-{volumeName}-{PvcKey}"; + + public static string ToPvName(this string resourceName, string volumeName) + => $"{resourceName}-{volumeName}-{PvKey}"; + + public static bool IsHelmExpression(this string value) + => value.Contains($"{{{{ {ValuesSegment}.", StringComparison.Ordinal); + + public static bool IsHelmSecretExpression(this string value) + => value.Contains($"{{{{ {ValuesSegment}.{SecretsKey}.", StringComparison.Ordinal); + + public static bool IsConnectionString(this string value) + => value.StartsWith("CONNECTIONSTRINGS__", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs new file mode 100644 index 00000000000..289fc0dd49e --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs @@ -0,0 +1,428 @@ +// 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.Kubernetes.Resources; + +namespace Aspire.Hosting.Kubernetes.Extensions; + +internal static class ResourceExtensions +{ + internal static Deployment ToDeployment(this IResource resource, KubernetesResourceContext context) + { + var deployment = new Deployment + { + Metadata = + { + Name = resource.Name.ToDeploymentName(), + }, + Spec = + { + Selector = new(context.Labels.ToDictionary()), + Replicas = resource.GetReplicaCount(), + Template = resource.ToPodTemplateSpec(context), + Strategy = new() + { + Type = "RollingUpdate", + RollingUpdate = new() + { + MaxUnavailable = 1, MaxSurge = 1, + }, + }, + }, + }; + + return deployment; + } + + internal static StatefulSet ToStatefulSet(this IResource resource, KubernetesResourceContext context) + { + var statefulSet = new StatefulSet + { + Metadata = + { + Name = resource.Name.ToStatefulSetName(), + }, + Spec = + { + Selector = new(context.Labels.ToDictionary()), + Replicas = resource.GetReplicaCount(), + Template = resource.ToPodTemplateSpec(context), + }, + }; + + return statefulSet; + } + + internal static Secret? ToSecret(this IResource resource, KubernetesResourceContext context) + { + if (context.Secrets.Count == 0) + { + return null; + } + + var secret = new Secret + { + Metadata = + { + Name = resource.Name.ToSecretName(), + Labels = context.Labels.ToDictionary(), + }, + }; + + var processedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var kvp in context.Secrets.Where(kvp => !processedKeys.Contains(kvp.Key))) + { + // If the value itself contains Helm expressions, use it directly in the template + // Otherwise use the expression to reference values.yaml + secret.StringData[kvp.Key] = (kvp.Value.Value?.IsHelmExpression() == true) + ? kvp.Value.Value + : kvp.Value.Expression; + processedKeys.Add(kvp.Key); + } + + return secret; + } + + internal static ConfigMap? ToConfigMap(this IResource resource, KubernetesResourceContext context) + { + if (context.EnvironmentVariables.Count == 0) + { + return null; + } + + var configMap = new ConfigMap + { + Metadata = + { + Name = resource.Name.ToConfigMapName(), + Labels = context.Labels.ToDictionary(), + }, + }; + + var processedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var kvp in context.EnvironmentVariables.Where(kvp => !processedKeys.Contains(kvp.Key))) + { + configMap.Data[kvp.Key] = kvp.Value.Expression; + processedKeys.Add(kvp.Key); + } + + return configMap; + } + + internal static Service? ToService(this IResource resource, KubernetesResourceContext context) + { + if (context.EndpointMappings.Count == 0) + { + return null; + } + + var service = new Service + { + Metadata = + { + Name = resource.Name.ToServiceName(), + }, + Spec = + { + Selector = context.Labels.ToDictionary(), + Type = context.PublisherOptions.ServiceType, + }, + }; + + foreach (var (_, mapping) in context.EndpointMappings) + { + service.Spec.Ports.Add( + new() + { + Name = mapping.Name, + Port = mapping.InternalPort, + TargetPort = mapping.ExposedPort, + Protocol = "TCP", + }); + } + + return service; + } + + private static PodTemplateSpecV1 ToPodTemplateSpec(this IResource resource, KubernetesResourceContext context) + { + var podTemplateSpec = new PodTemplateSpecV1 + { + Metadata = + { + Labels = context.Labels.ToDictionary(), + }, + Spec = + { + Containers = + { + resource.ToContainerV1(context), + }, + }, + }; + + return podTemplateSpec.WithPodSpecVolumes(context); + } + + private static PodTemplateSpecV1 WithPodSpecVolumes(this PodTemplateSpecV1 podTemplateSpec, KubernetesResourceContext context) + { + if (context.Volumes.Count == 0) + { + return podTemplateSpec; + } + + foreach (var volume in context.Volumes) + { + var podVolume = new VolumeV1 + { + Name = volume.Name, + }; + + switch (context.PublisherOptions.StorageType.ToLowerInvariant()) + { + case "emptydir": + podVolume.EmptyDir = new(); + break; + + case "hostpath": + podVolume.HostPath = new() + { + Path = volume.MountPath, + Type = "Directory", + }; + break; + + case "pvc": + _ = CreatePersistentVolume(context, volume); + var pvc = CreatePersistentVolumeClaim(context, volume); + podVolume.PersistentVolumeClaim = new() + { + ClaimName = pvc.Metadata.Name, + }; + break; + + default: + throw new InvalidOperationException($"Unsupported storage type: {context.PublisherOptions.StorageType}"); + } + + podTemplateSpec.Spec.Volumes.Add(podVolume); + } + + return podTemplateSpec; + } + + private static ContainerV1 ToContainerV1(this IResource resource, KubernetesResourceContext context) + { + var container = new ContainerV1 + { + Name = resource.Name, + ImagePullPolicy = context.PublisherOptions.ImagePullPolicy, + }; + + return container + .WithContainerImage(context) + .WithContainerEntrypoint(context) + .WithContainerArgs(context) + .WithContainerEnvironmentalVariables(context) + .WithContainerSecrets(context) + .WithContainerPorts(context) + .WithContainerVolumes(context); + } + + private static ContainerV1 WithContainerVolumes(this ContainerV1 container, KubernetesResourceContext context) + { + if (context.Volumes.Count == 0) + { + return container; + } + + foreach (var volume in context.Volumes) + { + container.VolumeMounts.Add( + new() + { + Name = volume.Name, + MountPath = volume.MountPath, + }); + } + + return container; + } + + private static ContainerV1 WithContainerPorts(this ContainerV1 container, KubernetesResourceContext context) + { + if (context.EndpointMappings.Count == 0) + { + return container; + } + + foreach (var (_, mapping) in context.EndpointMappings) + { + container.Ports.Add( + new() + { + Name = mapping.Name, + ContainerPort = mapping.InternalPort, + Protocol = "TCP", + }); + } + + return container; + } + + private static ContainerV1 WithContainerImage(this ContainerV1 container, KubernetesResourceContext context) + { + if (!context.TryGetContainerImageName(context.Resource, out var containerImageName)) + { + context.Logger.FailedToGetContainerImage(context.Resource.Name); + } + + if (containerImageName is not null) + { + container.Image = containerImageName; + } + + return container; + } + + private static ContainerV1 WithContainerEntrypoint(this ContainerV1 container, KubernetesResourceContext context) + { + if (context.Resource is ContainerResource {Entrypoint: { } entrypoint}) + { + container.Command.Add(entrypoint); + } + + return container; + } + + private static ContainerV1 WithContainerArgs(this ContainerV1 container, KubernetesResourceContext context) + { + if (context.Commands.Count == 0) + { + return container; + } + + foreach (var command in context.Commands) + { + container.Args.Add(command); + } + + return container; + } + + private static ContainerV1 WithContainerEnvironmentalVariables(this ContainerV1 container, KubernetesResourceContext context) + { + if (context.EnvironmentVariables.Count > 0) + { + container.EnvFrom.Add( + new() + { + ConfigMapRef = new() + { + Name = context.Resource.Name.ToConfigMapName(), + }, + }); + } + + return container; + } + + private static ContainerV1 WithContainerSecrets(this ContainerV1 container, KubernetesResourceContext context) + { + if (context.Secrets.Count > 0) + { + container.EnvFrom.Add( + new() + { + SecretRef = new() + { + Name = context.Resource.Name.ToSecretName(), + }, + }); + } + + return container; + } + + private static PersistentVolume CreatePersistentVolume(KubernetesResourceContext context, VolumeMountV1 volume) + { + var pvName = context.Resource.Name.ToPvName(volume.Name); + + if (context.TemplatedResources.OfType().FirstOrDefault(pv => pv.Metadata.Name == pvName) is { } existingVolume) + { + return existingVolume; + } + + var newPv = new PersistentVolume + { + Metadata = + { + Name = pvName, + Labels = context.Labels.ToDictionary(), + }, + Spec = new() + { + Capacity = new() + { + ["storage"] = context.PublisherOptions.StorageSize, + }, + AccessModes = { context.PublisherOptions.StorageReadWritePolicy }, + }, + }; + + if (!string.IsNullOrEmpty(context.PublisherOptions.StorageClassName)) + { + newPv.Spec.StorageClassName = context.PublisherOptions.StorageClassName; + } + + if (context.PublisherOptions.StorageType.Equals("hostpath", StringComparison.OrdinalIgnoreCase)) + { + newPv.Spec.HostPath = new() + { + Path = volume.Name, + }; + } + + context.TemplatedResources.Add(newPv); + + return newPv; + } + + private static PersistentVolumeClaim CreatePersistentVolumeClaim(KubernetesResourceContext context, VolumeMountV1 volume) + { + var pvcName = context.Resource.Name.ToPvcName(volume.Name); + + if (context.TemplatedResources.OfType().FirstOrDefault(pvc => pvc.Metadata.Name == pvcName) is { } existingVolumeClaim) + { + return existingVolumeClaim; + } + + var pvc = new PersistentVolumeClaim + { + Metadata = + { + Name = pvcName, + Labels = context.Labels.ToDictionary(), + }, + Spec = new() + { + Resources = new(), + }, + }; + + pvc.Spec.AccessModes.Add(context.PublisherOptions.StorageReadWritePolicy); + pvc.Spec.Resources.Requests.Add("storage", context.PublisherOptions.StorageSize); + + if (!string.IsNullOrEmpty(context.PublisherOptions.StorageClassName)) + { + pvc.Spec.StorageClassName = context.PublisherOptions.StorageClassName; + } + + context.TemplatedResources.Add(pvc); + + return pvc; + } +} diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPublisherLoggerExtensions.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPublisherLoggerExtensions.cs new file mode 100644 index 00000000000..8a005fee906 --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublisherLoggerExtensions.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Kubernetes; + +internal static partial class KubernetesPublisherLoggerExtensions +{ + [LoggerMessage(LogLevel.Warning, "{ResourceName} with type '{ResourceType}' is not supported by this publisher")] + internal static partial void NotSupportedResourceWarning(this ILogger logger, string resourceName, string resourceType); + + [LoggerMessage(LogLevel.Information, "{Message}")] + internal static partial void WriteMessage(this ILogger logger, string message); + + [LoggerMessage(LogLevel.Information, "Generating Kubernetes output")] + internal static partial void StartGeneratingKubernetes(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "No resources found in the model.")] + internal static partial void EmptyModel(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "Successfully generated Kubernetes output in '{OutputPath}'")] + internal static partial void FinishGeneratingKubernetes(this ILogger logger, string outputPath); + + [LoggerMessage(LogLevel.Warning, "Failed to get container image for resource '{ResourceName}', it will be skipped in the output.")] + internal static partial void FailedToGetContainerImage(this ILogger logger, string resourceName); + + [LoggerMessage(LogLevel.Warning, "Not in publishing mode. Skipping writing kubernetes manifests.")] + internal static partial void NotInPublishingMode(this ILogger logger); +} diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPublisherOptions.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPublisherOptions.cs index 2ecff12e4e7..e5847e847b1 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPublisherOptions.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublisherOptions.cs @@ -10,4 +10,66 @@ namespace Aspire.Hosting.Kubernetes; /// public sealed class KubernetesPublisherOptions : PublishingOptions { + /// + /// Gets or sets the name of the Helm chart to be generated. + /// + public string HelmChartName { get; set; } = "aspire"; + + /// + /// Gets or sets the version of the Helm chart to be generated. + /// This property specifies the version number that will be assigned to the Helm chart, + /// typically following semantic versioning conventions. + /// + public string HelmChartVersion { get; set; } = "0.1.0"; + + /// + /// Gets or sets the description of the Helm chart being generated. + /// + public string HelmChartDescription { get; set; } = "Aspire Helm Chart"; + + /// + /// Specifies the type of storage used for Kubernetes deployments. + /// + /// + /// This property determines the storage medium used for the application. + /// Possible values include "emptyDir", "hostPath", "pvc" + /// + public string StorageType { get; set; } = "emptyDir"; + + /// + /// Specifies the name of the storage class to be used for persistent volume claims in Kubernetes. + /// This property allows customization of the storage class for specifying storage requirements + /// such as performance, retention policies, and provisioning parameters. + /// If set to null, the default storage class for the cluster will be used. + /// + public string? StorageClassName { get; set; } + + /// + /// Gets or sets the default storage size for persistent volumes. + /// + public string StorageSize { get; set; } = "1Gi"; + + /// + /// Gets or sets the default access policy for reading and writing to the storage. + /// + public string StorageReadWritePolicy { get; set; } = "ReadWriteOnce"; + + /// + /// Gets or sets the policy that determines how Docker images are pulled during deployment. + /// Possible values are: + /// "Always" - Always attempt to pull the image from the registry. + /// "IfNotPresent" - Pull the image only if it is not already present locally. + /// "Never" - Never pull the image, use only the local image. + /// The default value is "IfNotPresent". + /// + public string ImagePullPolicy { get; set; } = "IfNotPresent"; + + /// + /// Gets or sets the Kubernetes service type to be used when generating artifacts. + /// + /// + /// The default value is "ClusterIP". This property determines the type of service + /// (e.g., ClusterIP, NodePort, LoadBalancer) created in Kubernetes for the application. + /// + public string ServiceType { get; set; } = "ClusterIP"; } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs index 9462b1ebfd8..a4c57550253 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs @@ -2,29 +2,183 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Kubernetes.Extensions; +using Aspire.Hosting.Kubernetes.Resources; +using Aspire.Hosting.Kubernetes.Yaml; +using Aspire.Hosting.Yaml; using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; namespace Aspire.Hosting.Kubernetes; -internal class KubernetesPublishingContext( +internal partial class KubernetesPublishingContext( DistributedApplicationExecutionContext executionContext, KubernetesPublisherOptions publisherOptions, ILogger logger, CancellationToken cancellationToken = default) { - internal Task WriteModelAsync(DistributedApplicationModel model) + private readonly Dictionary _kubernetesComponents = []; + + private readonly Dictionary _helmValues = new() { - _ = logger; - _ = cancellationToken; + [HelmExtensions.ParametersKey] = new Dictionary(), + [HelmExtensions.SecretsKey] = new Dictionary(), + [HelmExtensions.ConfigKey] = new Dictionary(), + }; + + private readonly ISerializer _serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithTypeConverter(new ByteArrayStringYamlConverter()) + .WithEventEmitter(nextEmitter => new ForceQuotedStringsEventEmitter(nextEmitter)) + .WithEventEmitter(e => new FloatEmitter(e)) + .WithEmissionPhaseObjectGraphVisitor(args => new YamlIEnumerableSkipEmptyObjectGraphVisitor(args.InnerVisitor)) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) + .WithNewLine("\n") + .WithIndentedSequences() + .Build(); - if (executionContext.IsRunMode) + public ILogger Logger => logger; + + internal async Task WriteModelAsync(DistributedApplicationModel model) + { + if (!executionContext.IsPublishMode) { - return Task.CompletedTask; + logger.NotInPublishingMode(); + return; } + logger.StartGeneratingKubernetes(); + ArgumentNullException.ThrowIfNull(model); ArgumentNullException.ThrowIfNull(publisherOptions.OutputPath); - throw new NotImplementedException("Publishing to Kubernetes is not yet implemented."); + if (model.Resources.Count == 0) + { + logger.EmptyModel(); + return; + } + + await WriteKubernetesOutputAsync(model).ConfigureAwait(false); + + logger.FinishGeneratingKubernetes(publisherOptions.OutputPath); + } + + private async Task WriteKubernetesOutputAsync(DistributedApplicationModel model) + { + foreach (var resource in model.Resources) + { + if (resource.TryGetLastAnnotation(out var lastAnnotation) && + lastAnnotation == ManifestPublishingCallbackAnnotation.Ignore) + { + continue; + } + + if (!resource.IsContainer() && resource is not ProjectResource) + { + continue; + } + + var kubernetesComponentContext = await ProcessResourceAsync(resource).ConfigureAwait(false); + kubernetesComponentContext.BuildKubernetesResources(); + + await WriteKubernetesTemplatesForResource(resource, kubernetesComponentContext.TemplatedResources).ConfigureAwait(false); + AppendResourceContextToHelmValues(resource, kubernetesComponentContext); + } + + await WriteKubernetesHelmChartAsync().ConfigureAwait(false); + await WriteKubernetesHelmValuesAsync().ConfigureAwait(false); + } + + private void AppendResourceContextToHelmValues(IResource resource, KubernetesResourceContext resourceContext) + { + // Process parameters used in anything that isn't a configmap / secret. + if (resourceContext.Parameters.Count > 0 && _helmValues[HelmExtensions.ParametersKey] is Dictionary helmParameters) + { + var paramValues = resourceContext.Parameters + .Where(kvp => kvp.Value.Value != null) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value!); + + if (paramValues.Count > 0) + { + helmParameters[resource.Name] = paramValues; + } + } + + // Add config section if needed + if (resourceContext.EnvironmentVariables.Count > 0 && _helmValues[HelmExtensions.ConfigKey] is Dictionary helmConfig) + { + var configValues = resourceContext.EnvironmentVariables + .Where(kvp => kvp.Value.Value == null || !kvp.Value.Value.IsHelmExpression()) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value ?? string.Empty); + + helmConfig[resource.Name] = configValues; + } + + // Add secrets section if needed + if (resourceContext.Secrets.Count > 0 && _helmValues[HelmExtensions.SecretsKey] is Dictionary helmSecrets) + { + var secretValues = resourceContext.Secrets + .Where(kvp => kvp.Value.Value == null || !kvp.Value.Value.IsHelmExpression()) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value ?? string.Empty); + + helmSecrets[resource.Name] = secretValues; + } + } + + private async Task WriteKubernetesTemplatesForResource(IResource resource, List templatedItems) + { + var templatesFolder = Path.Combine(publisherOptions.OutputPath!, "templates", resource.Name); + Directory.CreateDirectory(templatesFolder); + + foreach (var templatedItem in templatedItems) + { + var fileName = $"{templatedItem.GetType().Name.ToLowerInvariant()}.yaml"; + var outputFile = Path.Combine(templatesFolder, fileName); + var yaml = _serializer.Serialize(templatedItem); + + using var writer = new StreamWriter(outputFile); + await writer.WriteLineAsync(HelmExtensions.TemplateFileSeparator).ConfigureAwait(false); + await writer.WriteAsync(yaml).ConfigureAwait(false); + } + } + + private async Task WriteKubernetesHelmValuesAsync() + { + var valuesYaml = _serializer.Serialize(_helmValues); + var outputFile = Path.Combine(publisherOptions.OutputPath!, "values.yaml"); + Directory.CreateDirectory(publisherOptions.OutputPath!); + await File.WriteAllTextAsync(outputFile, valuesYaml, cancellationToken).ConfigureAwait(false); + } + + private async Task WriteKubernetesHelmChartAsync() + { + var helmChart = new HelmChart + { + Name = publisherOptions.HelmChartName, + Version = publisherOptions.HelmChartVersion, + AppVersion = publisherOptions.HelmChartVersion, + Description = publisherOptions.HelmChartDescription, + Type = "application", + ApiVersion = "v2", + Keywords = ["aspire", "kubernetes"], + KubeVersion = ">= 1.18.0-0", + }; + + var chartYaml = _serializer.Serialize(helmChart); + var outputFile = Path.Combine(publisherOptions.OutputPath!, "Chart.yaml"); + Directory.CreateDirectory(publisherOptions.OutputPath!); + await File.WriteAllTextAsync(outputFile, chartYaml, cancellationToken).ConfigureAwait(false); + } + + internal async Task ProcessResourceAsync(IResource resource) + { + if (!_kubernetesComponents.TryGetValue(resource, out var context)) + { + _kubernetesComponents[resource] = context = new(resource, this, publisherOptions); + await context.ProcessResourceAsync(executionContext, cancellationToken).ConfigureAwait(false); + } + + return context; } } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs new file mode 100644 index 00000000000..b2f30ad4bf7 --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs @@ -0,0 +1,368 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Kubernetes.Extensions; +using Aspire.Hosting.Kubernetes.Resources; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Kubernetes; + +internal sealed class KubernetesResourceContext( + IResource resource, + KubernetesPublishingContext kubernetesPublishingContext, + KubernetesPublisherOptions publisherOptions) +{ + internal record struct EndpointMapping(string Scheme, string Host, int InternalPort, int ExposedPort, string Name); + internal record struct HelmExpressionValue(string Expression, string? Value); + public readonly Dictionary EndpointMappings = []; + public readonly Dictionary EnvironmentVariables = []; + public readonly Dictionary Secrets = []; + public readonly Dictionary Parameters = []; + public Dictionary Labels = []; + public List TemplatedResources { get; } = []; + internal List Commands { get; } = []; + internal List Volumes { get; } = []; + internal IResource Resource => resource; + internal ILogger Logger => kubernetesPublishingContext.Logger; + internal KubernetesPublisherOptions PublisherOptions => publisherOptions; + + public void BuildKubernetesResources() + { + SetLabels(); + CreateApplication(); + AddIfExists(resource.ToConfigMap(this)); + AddIfExists(resource.ToSecret(this)); + AddIfExists(resource.ToService(this)); + } + + private void SetLabels() + { + Labels = new() + { + ["app"] = "aspire", + ["component"] = resource.Name, + }; + } + + private void CreateApplication() + { + if (resource is IResourceWithConnectionString) + { + var statefulSet = resource.ToStatefulSet(this); + TemplatedResources.Add(statefulSet); + return; + } + + var deployment = resource.ToDeployment(this); + TemplatedResources.Add(deployment); + } + + private void AddIfExists(BaseKubernetesResource? instance) + { + if (instance is not null) + { + TemplatedResources.Add(instance); + } + } + + internal bool TryGetContainerImageName(IResource resourceInstance, out string? containerImageName) + { + if (!resourceInstance.TryGetLastAnnotation(out _) && resourceInstance is not ProjectResource) + { + return resourceInstance.TryGetContainerImageName(out containerImageName); + } + + var imageEnvName = $"{resourceInstance.Name.ToManifestFriendlyResourceName()}_image"; + var value = $"{resourceInstance.Name}:latest"; + var expression = imageEnvName.ToHelmParameterExpression(resource.Name); + + Parameters[imageEnvName] = new HelmExpressionValue(expression, value); + containerImageName = expression; + return false; + + } + + public async Task ProcessResourceAsync(DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + ProcessEndpoints(); + ProcessVolumes(); + + await ProcessEnvironmentAsync(executionContext, cancellationToken).ConfigureAwait(false); + await ProcessArgumentsAsync(cancellationToken).ConfigureAwait(false); + } + + private void ProcessEndpoints() + { + if (!resource.TryGetEndpoints(out var endpoints)) + { + return; + } + + foreach (var endpoint in endpoints) + { + var port = endpoint.TargetPort ?? 80; + + EndpointMappings[endpoint.Name] = new(endpoint.UriScheme, resource.Name, port, port, endpoint.Name); + } + } + + private void ProcessVolumes() + { + if (!resource.TryGetContainerMounts(out var mounts)) + { + return; + } + + foreach (var volume in mounts) + { + if (volume.Source is null || volume.Target is null) + { + throw new InvalidOperationException("Volume source and target must be set"); + } + + if (volume.Type == ContainerMountType.BindMount) + { + throw new InvalidOperationException("Bind mounts are not supported by the Kubernetes publisher"); + } + + var newVolume = new VolumeMountV1 + { + Name = volume.Source, + ReadOnly = volume.IsReadOnly, + MountPath = volume.Target, + }; + + Volumes.Add(newVolume); + } + } + + private async Task ProcessArgumentsAsync(CancellationToken cancellationToken) + { + if (resource.TryGetAnnotationsOfType(out var commandLineArgsCallbackAnnotations)) + { + var context = new CommandLineArgsCallbackContext([], cancellationToken: cancellationToken); + + foreach (var c in commandLineArgsCallbackAnnotations) + { + await c.Callback(context).ConfigureAwait(false); + } + + foreach (var arg in context.Args) + { + var value = await ProcessValueAsync(arg).ConfigureAwait(false); + + if (value is not string str) + { + throw new NotSupportedException("Command line args must be strings"); + } + + Commands.Add(new(str)); + } + } + } + + private async Task ProcessEnvironmentAsync(DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + if (resource.TryGetAnnotationsOfType(out var environmentCallbacks)) + { + var context = new EnvironmentCallbackContext(executionContext, cancellationToken: cancellationToken); + + foreach (var c in environmentCallbacks) + { + await c.Callback(context).ConfigureAwait(false); + } + + var processedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var kv in context.EnvironmentVariables) + { + var value = await ProcessValueAsync(kv.Value).ConfigureAwait(false); + var key = kv.Key.ToManifestFriendlyResourceName(); + var stringValue = value.ToString() ?? string.Empty; + + if (processedKeys.Contains(key)) + { + continue; + } + + // Move connection strings to secrets + if (key.IsConnectionString()) + { + var expression = key.ToHelmSecretExpression(resource.Name); + Secrets[key] = new HelmExpressionValue(expression, stringValue); + continue; + } + + if (stringValue.IsHelmSecretExpression()) + { + // If the value references secrets, it belongs in secrets + var expression = key.ToHelmSecretExpression(resource.Name); + Secrets[key] = new HelmExpressionValue(expression, stringValue); + continue; + } + + // All other values go to environment variables + var configExpression = key.ToHelmConfigExpression(resource.Name); + EnvironmentVariables[key] = new HelmExpressionValue(configExpression, stringValue); + processedKeys.Add(key); + } + } + } + + private static string GetEndpointValue(EndpointMapping mapping, EndpointProperty property) + { + var (scheme, host, internalPort, exposedPort, _) = mapping; + + return property switch + { + EndpointProperty.Url => GetHostValue($"{scheme}://", suffix: $":{internalPort}"), + EndpointProperty.Host or EndpointProperty.IPV4Host => GetHostValue(), + EndpointProperty.Port => internalPort.ToString(CultureInfo.InvariantCulture), + EndpointProperty.HostAndPort => GetHostValue(suffix: $":{internalPort}"), + EndpointProperty.TargetPort => $"{exposedPort}", + EndpointProperty.Scheme => scheme, + _ => throw new NotSupportedException(), + }; + + string GetHostValue(string? prefix = null, string? suffix = null) + { + return $"{prefix}{host}{suffix}"; + } + } + + private async Task ProcessValueAsync(object value) + { + while (true) + { + if (value is string s) + { + return s; + } + + if (value is EndpointReference ep) + { + var context = ep.Resource == resource + ? this + : await kubernetesPublishingContext.ProcessResourceAsync(ep.Resource) + .ConfigureAwait(false); + + var mapping = context.EndpointMappings[ep.EndpointName]; + + var url = GetEndpointValue(mapping, EndpointProperty.Url); + + return url; + } + + if (value is ParameterResource param) + { + return AllocateParameter(param); + } + + if (value is ConnectionStringReference cs) + { + value = cs.Resource.ConnectionStringExpression; + continue; + } + + if (value is IResourceWithConnectionString csrs) + { + value = csrs.ConnectionStringExpression; + continue; + } + + if (value is EndpointReferenceExpression epExpr) + { + var context = epExpr.Endpoint.Resource == resource + ? this + : await kubernetesPublishingContext.ProcessResourceAsync(epExpr.Endpoint.Resource).ConfigureAwait(false); + + var mapping = context.EndpointMappings[epExpr.Endpoint.EndpointName]; + + var val = GetEndpointValue(mapping, epExpr.Property); + + return val; + } + + if (value is ReferenceExpression expr) + { + if (expr is {Format: "{0}", ValueProviders.Count: 1}) + { + return (await ProcessValueAsync(expr.ValueProviders[0]).ConfigureAwait(false)).ToString() ?? string.Empty; + } + + var args = new object[expr.ValueProviders.Count]; + var index = 0; + + foreach (var vp in expr.ValueProviders) + { + var val = await ProcessValueAsync(vp).ConfigureAwait(false); + args[index++] = val ?? throw new InvalidOperationException("Value is null"); + } + + return string.Format(CultureInfo.InvariantCulture, expr.Format, args); + } + + // If we don't know how to process the value, we just return it as an external reference + if (value is IManifestExpressionProvider r) + { + kubernetesPublishingContext.Logger.NotSupportedResourceWarning(nameof(value), r.GetType().Name); + + return ResolveUnknownValue(r); + } + + throw new NotSupportedException($"Unsupported value type: {value.GetType().Name}"); + } + } + + private string AllocateParameter(ParameterResource parameter) + { + var formattedName = parameter.Name.ToManifestFriendlyResourceName(); + var value = parameter.Default is null ? null : parameter.Value; + + if (parameter.Secret) + { + var expression = formattedName.ToHelmSecretExpression(resource.Name); + Secrets[formattedName] = new HelmExpressionValue(expression, value); + return expression; + } + + // For non-secret parameters, store in config map + var configExpression = formattedName.ToHelmConfigExpression(resource.Name); + var configValue = parameter.Default is null ? null : parameter.Value; + EnvironmentVariables[formattedName] = new HelmExpressionValue(configExpression, configValue); + return configValue ?? configExpression; + } + + private string ResolveUnknownValue(IManifestExpressionProvider parameter) + { + var formattedName = parameter.ValueExpression.Replace("{", "") + .Replace("}", "") + .Replace(".", "_") + .ToManifestFriendlyResourceName(); + + var value = parameter.ValueExpression; + + // If the value contains Helm expressions + if (value.IsHelmExpression()) + { + // Store in secrets if it references secrets, otherwise in config + if (value.IsHelmSecretExpression()) + { + var expression = formattedName.ToHelmSecretExpression(resource.Name); + Secrets[formattedName] = new HelmExpressionValue(expression, value); + return expression; + } + + var configExpression = formattedName.ToHelmConfigExpression(resource.Name); + EnvironmentVariables[formattedName] = new HelmExpressionValue(configExpression, value); + return configExpression; + } + + // For values without Helm expressions + var targetExpression = formattedName.ToHelmConfigExpression(resource.Name); + EnvironmentVariables[formattedName] = new HelmExpressionValue(targetExpression, value); + return targetExpression; + } +} diff --git a/src/Aspire.Hosting.Kubernetes/Resources/BaseKubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/Resources/BaseKubernetesResource.cs index d73af52770a..3cc96cf2fd1 100644 --- a/src/Aspire.Hosting.Kubernetes/Resources/BaseKubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/Resources/BaseKubernetesResource.cs @@ -1,10 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting.Kubernetes.Yaml; -using Aspire.Hosting.Yaml; using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; namespace Aspire.Hosting.Kubernetes.Resources; @@ -30,25 +27,4 @@ public abstract class BaseKubernetesResource(string apiVersion, string kind) : B /// [YamlMember(Alias = "metadata", Order = -1)] public ObjectMetaV1 Metadata { get; set; } = new(); - - /// - /// Converts the current Kubernetes resource object into its YAML representation. - /// - /// Specifies the line endings to be used in the YAML output. Defaults to a newline character ("\n"). - /// A string representing the YAML-encoded content of the current resource object. - public string ToYaml(string lineEndings = "\n") - { - var serializer = new SerializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .WithTypeConverter(new ByteArrayStringYamlConverter()) - .WithEventEmitter(nextEmitter => new ForceQuotedStringsEventEmitter(nextEmitter)) - .WithEventEmitter(e => new FloatEmitter(e)) - .WithEmissionPhaseObjectGraphVisitor(args => new YamlIEnumerableSkipEmptyObjectGraphVisitor(args.InnerVisitor)) - .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) - .WithNewLine(lineEndings) - .WithIndentedSequences() - .Build(); - - return serializer.Serialize(this); - } } diff --git a/src/Aspire.Hosting.Kubernetes/Resources/LabelSelectorV1.cs b/src/Aspire.Hosting.Kubernetes/Resources/LabelSelectorV1.cs index 965197aca6c..b875bec26e5 100644 --- a/src/Aspire.Hosting.Kubernetes/Resources/LabelSelectorV1.cs +++ b/src/Aspire.Hosting.Kubernetes/Resources/LabelSelectorV1.cs @@ -23,7 +23,7 @@ public sealed class LabelSelectorV1 /// This property is used to form more complex selection logic based on multiple conditions. /// [YamlMember(Alias = "matchExpressions")] - public List MatchExpressions { get; } = []; + public List MatchExpressions { get; set; } = []; /// /// A collection of key-value pairs used to specify matching labels for Kubernetes resources. @@ -31,5 +31,32 @@ public sealed class LabelSelectorV1 /// a Kubernetes environment. /// [YamlMember(Alias = "matchLabels")] - public Dictionary MatchLabels { get; } = []; + public Dictionary MatchLabels { get; set; } = []; + + /// + /// Represents a label selector used to determine a set of resources + /// in Kubernetes that match the defined criteria. + /// + /// + /// LabelSelectorV1 is commonly used in Kubernetes resource specifications + /// where filtering objects based on labels is required, such as in ReplicaSets, + /// Deployments, or custom metrics. + /// + public LabelSelectorV1() + { + } + + /// + /// Represents a label selector used to determine a set of resources + /// in Kubernetes that match the defined criteria. + /// + /// + /// LabelSelectorV1 is commonly used in Kubernetes resource specifications + /// where filtering objects based on labels is required, such as in ReplicaSets, + /// Deployments, or custom metrics. + /// + public LabelSelectorV1(Dictionary matchLabels) + { + MatchLabels = matchLabels; + } } diff --git a/src/Aspire.Hosting.Kubernetes/Resources/ObjectMetaV1.cs b/src/Aspire.Hosting.Kubernetes/Resources/ObjectMetaV1.cs index 4dd48e8c048..3a6cfe3fea3 100644 --- a/src/Aspire.Hosting.Kubernetes/Resources/ObjectMetaV1.cs +++ b/src/Aspire.Hosting.Kubernetes/Resources/ObjectMetaV1.cs @@ -166,7 +166,7 @@ public sealed class ObjectMetaV1 /// management operations and can also assist in search and filtering processes. /// [YamlMember(Alias = "labels")] - public Dictionary Labels { get; } = []; + public Dictionary Labels { get; set; } = []; /// /// A collection of ManagedFieldsEntryV1 instances that provide metadata about field-level management in a Kubernetes resource. diff --git a/src/Aspire.Hosting.Kubernetes/Resources/PersistentVolumeSpecV1.cs b/src/Aspire.Hosting.Kubernetes/Resources/PersistentVolumeSpecV1.cs index 5af89cce6af..96b11681023 100644 --- a/src/Aspire.Hosting.Kubernetes/Resources/PersistentVolumeSpecV1.cs +++ b/src/Aspire.Hosting.Kubernetes/Resources/PersistentVolumeSpecV1.cs @@ -90,7 +90,7 @@ public sealed class PersistentVolumeSpecV1 /// and the values define the corresponding quantity for the capacity type. /// [YamlMember(Alias = "capacity")] - public Dictionary Capacity { get; } = []; + public Dictionary Capacity { get; set; } = []; /// /// Specifies constraints that limit which nodes a persistent volume can be accessed from. diff --git a/src/Aspire.Hosting.Kubernetes/Resources/ServiceSpecV1.cs b/src/Aspire.Hosting.Kubernetes/Resources/ServiceSpecV1.cs index a836e0ec71d..07ff91b7def 100644 --- a/src/Aspire.Hosting.Kubernetes/Resources/ServiceSpecV1.cs +++ b/src/Aspire.Hosting.Kubernetes/Resources/ServiceSpecV1.cs @@ -90,7 +90,7 @@ public sealed class ServiceSpecV1 /// that the Service targets. The Service routes traffic to these pods based on the selector definition. /// [YamlMember(Alias = "selector")] - public Dictionary Selector { get; } = []; + public Dictionary Selector { get; set; } = []; /// /// Indicates whether node ports should be automatically allocated for a service of type LoadBalancer. diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj b/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj index 6a9ea7630b5..9ead8fadc8f 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/Chart.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/Chart.yaml new file mode 100644 index 00000000000..5a1d07a4bbb --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: "v2" +name: "aspire" +version: "0.1.0" +kubeVersion: ">= 1.18.0-0" +description: "Aspire Helm Chart" +type: "application" +keywords: + - "aspire" + - "kubernetes" +appVersion: "0.1.0" +deprecated: false diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/configmap.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/configmap.yaml new file mode 100644 index 00000000000..db70f9d4cbb --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/configmap.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "myapp-config" + labels: + app: "aspire" + component: "myapp" +data: + ASPNETCORE_ENVIRONMENT: "{{ .Values.config.myapp.ASPNETCORE_ENVIRONMENT }}" + PORT: "{{ .Values.config.myapp.PORT }}" + param0: "{{ .Values.config.myapp.param0 }}" + param2: "{{ .Values.config.myapp.param2 }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/deployment.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/deployment.yaml new file mode 100644 index 00000000000..cd1e9939bd3 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/deployment.yaml @@ -0,0 +1,45 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + name: "myapp-deployment" +spec: + template: + metadata: + labels: + app: "aspire" + component: "myapp" + spec: + containers: + - image: "mcr.microsoft.com/dotnet/aspnet:8.0" + name: "myapp" + envFrom: + - configMapRef: + name: "myapp-config" + - secretRef: + name: "myapp-secrets" + args: + - "--cs" + - "Url={{ .Values.config.myapp.param0 }}, Secret={{ .Values.secrets.myapp.param1 }}" + ports: + - name: "http" + protocol: "TCP" + containerPort: 80 + volumeMounts: + - name: "logs" + mountPath: "/logs" + imagePullPolicy: "IfNotPresent" + volumes: + - name: "logs" + emptyDir: {} + selector: + matchLabels: + app: "aspire" + component: "myapp" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/secret.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/secret.yaml new file mode 100644 index 00000000000..0f1f4638202 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/secret.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: "v1" +kind: "Secret" +metadata: + name: "myapp-secrets" + labels: + app: "aspire" + component: "myapp" +stringData: + param1: "{{ .Values.secrets.myapp.param1 }}" + ConnectionStrings__cs: "Url={{ .Values.config.myapp.param0 }}, Secret={{ .Values.secrets.myapp.param1 }}" +type: "Opaque" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/service.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/service.yaml new file mode 100644 index 00000000000..ea69c5b1256 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/service.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: "v1" +kind: "Service" +metadata: + name: "myapp-service" +spec: + type: "ClusterIP" + selector: + app: "aspire" + component: "myapp" + ports: + - name: "http" + protocol: "TCP" + port: 80 + targetPort: 80 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/configmap.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/configmap.yaml new file mode 100644 index 00000000000..e5ade01a3b5 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/configmap.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "project1-config" + labels: + app: "aspire" + component: "project1" +data: + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES }}" + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES }}" + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY }}" + OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION }}" + OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION }}" + services__myapp__http__0: "{{ .Values.config.project1.services__myapp__http__0 }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/deployment.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/deployment.yaml new file mode 100644 index 00000000000..0bc171d53d7 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/deployment.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + name: "project1-deployment" +spec: + template: + metadata: + labels: + app: "aspire" + component: "project1" + spec: + containers: + - image: "{{ .Values.parameters.project1.project1_image }}" + name: "project1" + envFrom: + - configMapRef: + name: "project1-config" + imagePullPolicy: "IfNotPresent" + selector: + matchLabels: + app: "aspire" + component: "project1" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/values.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/values.yaml new file mode 100644 index 00000000000..d1af7420ac2 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/values.yaml @@ -0,0 +1,19 @@ +parameters: + project1: + project1_image: "project1:latest" +secrets: + myapp: + param1: "" +config: + myapp: + ASPNETCORE_ENVIRONMENT: "Development" + PORT: "80" + param0: "" + param2: "default" + project1: + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION: "true" + OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION: "true" + services__myapp__http__0: "http://myapp:80" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 1c959322469..eaf4da0f1b5 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -1,8 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Reflection; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; @@ -11,12 +13,32 @@ namespace Aspire.Hosting.Kubernetes.Tests; public class KubernetesPublisherTests { + private readonly List _expectedFiles = + [ + "Chart.yaml", + "values.yaml", + "templates/project1/deployment.yaml", + "templates/project1/configmap.yaml", + "templates/myapp/deployment.yaml", + "templates/myapp/service.yaml", + "templates/myapp/configmap.yaml", + "templates/myapp/secret.yaml", + ]; + + private readonly Dictionary _expectedValuesContentCache = []; + [Fact] public async Task PublishAsync_GeneratesValidHelmChart() { - using var tempDir = new TempDirectory(); // Arrange - var options = new OptionsMonitor(new KubernetesPublisherOptions() { OutputPath = tempDir.Path }); + LoadSnapshots(); + using var tempDirectory = new TempDirectory(); + var options = new OptionsMonitor( + new() + { + OutputPath = tempDirectory.Path, + }); + var builder = DistributedApplication.CreateBuilder(); var param0 = builder.AddParameter("param0"); @@ -26,13 +48,14 @@ public async Task PublishAsync_GeneratesValidHelmChart() // Add a container to the application var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0") - .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") - .WithHttpEndpoint(env: "PORT") - .WithEnvironment("param0", param0) - .WithEnvironment("param1", param1) - .WithEnvironment("param2", param2) - .WithReference(cs) - .WithArgs("--cs", cs.Resource); + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") + .WithHttpEndpoint(env: "PORT") + .WithEnvironment("param0", param0) + .WithEnvironment("param1", param1) + .WithEnvironment("param2", param2) + .WithReference(cs) + .WithVolume("logs", "/logs") + .WithArgs("--cs", cs.Resource); builder.AddProject("project1", launchProfileName: null) .WithReference(api.GetEndpoint("http")); @@ -41,16 +64,39 @@ public async Task PublishAsync_GeneratesValidHelmChart() var model = app.Services.GetRequiredService(); - var publisher = new KubernetesPublisher("test", options, + var publisher = new KubernetesPublisher( + "test", options, NullLogger.Instance, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish)); + new(DistributedApplicationOperation.Publish)); // Act - var act = publisher.PublishAsync(model, CancellationToken.None); + await publisher.PublishAsync(model, CancellationToken.None); // Assert - // TODO: implement once the publisher is implemented. - await Assert.ThrowsAsync(() => act); + foreach (var expectedFile in _expectedFiles) + { + await AssertOutputFileContentsEqualExpectedFileContents(tempDirectory, expectedFile); + } + } + + private void LoadSnapshots() + { + var embeddedProvider = new EmbeddedFileProvider(Assembly.GetExecutingAssembly()); + + foreach (var expectedFile in _expectedFiles) + { + using var stream = embeddedProvider.GetFileInfo($"ExpectedValues.{expectedFile.Replace('/', '.')}").CreateReadStream() ?? throw new FileNotFoundException($"Expected file not found: {expectedFile}"); + using var reader = new StreamReader(stream); + _expectedValuesContentCache[expectedFile] = reader.ReadToEnd(); + } + } + + private async Task AssertOutputFileContentsEqualExpectedFileContents(TempDirectory tempDirectory, string outputPath) + { + var file = Path.Combine(tempDirectory.Path, outputPath); + Assert.True(File.Exists(file), $"File not found: {file}"); + var outputContent = await File.ReadAllTextAsync(file); + Assert.Equal(_expectedValuesContentCache[outputPath], outputContent, ignoreAllWhiteSpace: true, ignoreLineEndingDifferences: true); } private sealed class OptionsMonitor(KubernetesPublisherOptions options) : IOptionsMonitor @@ -64,12 +110,8 @@ private sealed class OptionsMonitor(KubernetesPublisherOptions options) : IOptio private sealed class TempDirectory : IDisposable { - public TempDirectory() - { - Path = Directory.CreateTempSubdirectory(".aspire-kubernetes").FullName; - } + public string Path { get; } = Directory.CreateTempSubdirectory(".aspire-kubernetes").FullName; - public string Path { get; } public void Dispose() { if (File.Exists(Path)) From 332bcc8ce18fdb7bb1fed35a0bc844058928f52d Mon Sep 17 00:00:00 2001 From: Prom3theu5 Date: Sun, 23 Mar 2025 16:24:56 +0000 Subject: [PATCH 2/6] restructure tests, remove embedded files --- .../Aspire.Hosting.Kubernetes.Tests.csproj | 6 +- .../ExpectedValues.cs | 207 ++++++++++++++++++ .../ExpectedValues/Chart.yaml | 11 - .../templates/myapp/configmap.yaml | 13 -- .../templates/myapp/deployment.yaml | 45 ---- .../templates/myapp/secret.yaml | 12 - .../templates/myapp/service.yaml | 15 -- .../templates/project1/configmap.yaml | 15 -- .../templates/project1/deployment.yaml | 30 --- .../ExpectedValues/values.yaml | 19 -- .../KubernetesPublisherFixture.cs | 40 ++++ .../KubernetesPublisherTests.cs | 159 ++++++-------- 12 files changed, 314 insertions(+), 258 deletions(-) create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/Chart.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/configmap.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/deployment.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/secret.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/service.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/configmap.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/deployment.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/values.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherFixture.cs diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj b/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj index 9ead8fadc8f..565ea8f555b 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj @@ -12,9 +12,5 @@ - - - - - + diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs new file mode 100644 index 00000000000..adae8ec469f --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs @@ -0,0 +1,207 @@ +// 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.Tests; + +public static class ExpectedValues +{ + public const string Chart = + """ + apiVersion: "v2" + name: "aspire" + version: "0.1.0" + kubeVersion: ">= 1.18.0-0" + description: "Aspire Helm Chart" + type: "application" + keywords: + - "aspire" + - "kubernetes" + appVersion: "0.1.0" + deprecated: false + + """; + + public const string Values = + """ + parameters: + project1: + project1_image: "project1:latest" + secrets: + myapp: + param1: "" + config: + myapp: + ASPNETCORE_ENVIRONMENT: "Development" + PORT: "80" + param0: "" + param2: "default" + project1: + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION: "true" + OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION: "true" + services__myapp__http__0: "http://myapp:80" + + """; + + public const string ProjectOneDeployment = + """ + --- + apiVersion: "apps/v1" + kind: "Deployment" + metadata: + name: "project1-deployment" + spec: + template: + metadata: + labels: + app: "aspire" + component: "project1" + spec: + containers: + - image: "{{ .Values.parameters.project1.project1_image }}" + name: "project1" + envFrom: + - configMapRef: + name: "project1-config" + imagePullPolicy: "IfNotPresent" + selector: + matchLabels: + app: "aspire" + component: "project1" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" + + """; + + public const string ProjectOneConfigMap = + """ + --- + apiVersion: "v1" + kind: "ConfigMap" + metadata: + name: "project1-config" + labels: + app: "aspire" + component: "project1" + data: + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES }}" + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES }}" + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY }}" + OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION }}" + OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION }}" + services__myapp__http__0: "{{ .Values.config.project1.services__myapp__http__0 }}" + + """; + + public const string MyAppDeployment = + """ + --- + apiVersion: "apps/v1" + kind: "Deployment" + metadata: + name: "myapp-deployment" + spec: + template: + metadata: + labels: + app: "aspire" + component: "myapp" + spec: + containers: + - image: "mcr.microsoft.com/dotnet/aspnet:8.0" + name: "myapp" + envFrom: + - configMapRef: + name: "myapp-config" + - secretRef: + name: "myapp-secrets" + args: + - "--cs" + - "Url={{ .Values.config.myapp.param0 }}, Secret={{ .Values.secrets.myapp.param1 }}" + ports: + - name: "http" + protocol: "TCP" + containerPort: 80 + volumeMounts: + - name: "logs" + mountPath: "/logs" + imagePullPolicy: "IfNotPresent" + volumes: + - name: "logs" + emptyDir: {} + selector: + matchLabels: + app: "aspire" + component: "myapp" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" + + """; + + public const string MyAppService = + """ + --- + apiVersion: "v1" + kind: "Service" + metadata: + name: "myapp-service" + spec: + type: "ClusterIP" + selector: + app: "aspire" + component: "myapp" + ports: + - name: "http" + protocol: "TCP" + port: 80 + targetPort: 80 + + """; + + public const string MyAppConfigMap = + """ + --- + apiVersion: "v1" + kind: "ConfigMap" + metadata: + name: "myapp-config" + labels: + app: "aspire" + component: "myapp" + data: + ASPNETCORE_ENVIRONMENT: "{{ .Values.config.myapp.ASPNETCORE_ENVIRONMENT }}" + PORT: "{{ .Values.config.myapp.PORT }}" + param0: "{{ .Values.config.myapp.param0 }}" + param2: "{{ .Values.config.myapp.param2 }}" + + """; + + public const string MyAppSecret = + """ + --- + apiVersion: "v1" + kind: "Secret" + metadata: + name: "myapp-secrets" + labels: + app: "aspire" + component: "myapp" + stringData: + param1: "{{ .Values.secrets.myapp.param1 }}" + ConnectionStrings__cs: "Url={{ .Values.config.myapp.param0 }}, Secret={{ .Values.secrets.myapp.param1 }}" + type: "Opaque" + + """; +} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/Chart.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/Chart.yaml deleted file mode 100644 index 5a1d07a4bbb..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/Chart.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: "v2" -name: "aspire" -version: "0.1.0" -kubeVersion: ">= 1.18.0-0" -description: "Aspire Helm Chart" -type: "application" -keywords: - - "aspire" - - "kubernetes" -appVersion: "0.1.0" -deprecated: false diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/configmap.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/configmap.yaml deleted file mode 100644 index db70f9d4cbb..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/configmap.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: "v1" -kind: "ConfigMap" -metadata: - name: "myapp-config" - labels: - app: "aspire" - component: "myapp" -data: - ASPNETCORE_ENVIRONMENT: "{{ .Values.config.myapp.ASPNETCORE_ENVIRONMENT }}" - PORT: "{{ .Values.config.myapp.PORT }}" - param0: "{{ .Values.config.myapp.param0 }}" - param2: "{{ .Values.config.myapp.param2 }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/deployment.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/deployment.yaml deleted file mode 100644 index cd1e9939bd3..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/deployment.yaml +++ /dev/null @@ -1,45 +0,0 @@ ---- -apiVersion: "apps/v1" -kind: "Deployment" -metadata: - name: "myapp-deployment" -spec: - template: - metadata: - labels: - app: "aspire" - component: "myapp" - spec: - containers: - - image: "mcr.microsoft.com/dotnet/aspnet:8.0" - name: "myapp" - envFrom: - - configMapRef: - name: "myapp-config" - - secretRef: - name: "myapp-secrets" - args: - - "--cs" - - "Url={{ .Values.config.myapp.param0 }}, Secret={{ .Values.secrets.myapp.param1 }}" - ports: - - name: "http" - protocol: "TCP" - containerPort: 80 - volumeMounts: - - name: "logs" - mountPath: "/logs" - imagePullPolicy: "IfNotPresent" - volumes: - - name: "logs" - emptyDir: {} - selector: - matchLabels: - app: "aspire" - component: "myapp" - replicas: 1 - revisionHistoryLimit: 3 - strategy: - rollingUpdate: - maxSurge: 1 - maxUnavailable: 1 - type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/secret.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/secret.yaml deleted file mode 100644 index 0f1f4638202..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/secret.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -apiVersion: "v1" -kind: "Secret" -metadata: - name: "myapp-secrets" - labels: - app: "aspire" - component: "myapp" -stringData: - param1: "{{ .Values.secrets.myapp.param1 }}" - ConnectionStrings__cs: "Url={{ .Values.config.myapp.param0 }}, Secret={{ .Values.secrets.myapp.param1 }}" -type: "Opaque" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/service.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/service.yaml deleted file mode 100644 index ea69c5b1256..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/myapp/service.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -apiVersion: "v1" -kind: "Service" -metadata: - name: "myapp-service" -spec: - type: "ClusterIP" - selector: - app: "aspire" - component: "myapp" - ports: - - name: "http" - protocol: "TCP" - port: 80 - targetPort: 80 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/configmap.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/configmap.yaml deleted file mode 100644 index e5ade01a3b5..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/configmap.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -apiVersion: "v1" -kind: "ConfigMap" -metadata: - name: "project1-config" - labels: - app: "aspire" - component: "project1" -data: - OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES }}" - OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES }}" - OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY }}" - OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION }}" - OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION }}" - services__myapp__http__0: "{{ .Values.config.project1.services__myapp__http__0 }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/deployment.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/deployment.yaml deleted file mode 100644 index 0bc171d53d7..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/templates/project1/deployment.yaml +++ /dev/null @@ -1,30 +0,0 @@ ---- -apiVersion: "apps/v1" -kind: "Deployment" -metadata: - name: "project1-deployment" -spec: - template: - metadata: - labels: - app: "aspire" - component: "project1" - spec: - containers: - - image: "{{ .Values.parameters.project1.project1_image }}" - name: "project1" - envFrom: - - configMapRef: - name: "project1-config" - imagePullPolicy: "IfNotPresent" - selector: - matchLabels: - app: "aspire" - component: "project1" - replicas: 1 - revisionHistoryLimit: 3 - strategy: - rollingUpdate: - maxSurge: 1 - maxUnavailable: 1 - type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/values.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/values.yaml deleted file mode 100644 index d1af7420ac2..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues/values.yaml +++ /dev/null @@ -1,19 +0,0 @@ -parameters: - project1: - project1_image: "project1:latest" -secrets: - myapp: - param1: "" -config: - myapp: - ASPNETCORE_ENVIRONMENT: "Development" - PORT: "80" - param0: "" - param2: "default" - project1: - OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" - OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" - OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" - OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION: "true" - OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION: "true" - services__myapp__http__0: "http://myapp:80" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherFixture.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherFixture.cs new file mode 100644 index 00000000000..d0c51e02dde --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherFixture.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.Hosting.Kubernetes.Tests; + +public class KubernetesPublisherFixture : IDisposable +{ + public const string CollectionName = "Kubernetes Publisher Collection"; + + public TempDirectory? TempDirectoryInstance { get; } = new(); + + public void Dispose() + { + TempDirectoryInstance?.Dispose(); + GC.SuppressFinalize(this); + } + + public sealed class TempDirectory : IDisposable + { + public string Path { get; } = Directory.CreateTempSubdirectory(".aspire-kubernetes").FullName; + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } +} + +[CollectionDefinition(KubernetesPublisherFixture.CollectionName)] +public class DatabaseCollection : ICollectionFixture +{ + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index eaf4da0f1b5..728769f2fc2 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -1,102 +1,88 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Reflection; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; namespace Aspire.Hosting.Kubernetes.Tests; -public class KubernetesPublisherTests +[Collection(KubernetesPublisherFixture.CollectionName)] +public class KubernetesPublisherTests(KubernetesPublisherFixture fixture) { - private readonly List _expectedFiles = - [ - "Chart.yaml", - "values.yaml", - "templates/project1/deployment.yaml", - "templates/project1/configmap.yaml", - "templates/myapp/deployment.yaml", - "templates/myapp/service.yaml", - "templates/myapp/configmap.yaml", - "templates/myapp/secret.yaml", - ]; - - private readonly Dictionary _expectedValuesContentCache = []; - - [Fact] - public async Task PublishAsync_GeneratesValidHelmChart() - { - // Arrange - LoadSnapshots(); - using var tempDirectory = new TempDirectory(); - var options = new OptionsMonitor( - new() - { - OutputPath = tempDirectory.Path, - }); - - var builder = DistributedApplication.CreateBuilder(); - - var param0 = builder.AddParameter("param0"); - var param1 = builder.AddParameter("param1", secret: true); - var param2 = builder.AddParameter("param2", "default", publishValueAsDefault: true); - 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(env: "PORT") - .WithEnvironment("param0", param0) - .WithEnvironment("param1", param1) - .WithEnvironment("param2", param2) - .WithReference(cs) - .WithVolume("logs", "/logs") - .WithArgs("--cs", cs.Resource); - - builder.AddProject("project1", launchProfileName: null) - .WithReference(api.GetEndpoint("http")); - - var app = builder.Build(); - - var model = app.Services.GetRequiredService(); - - var publisher = new KubernetesPublisher( - "test", options, - NullLogger.Instance, - new(DistributedApplicationOperation.Publish)); - - // Act - await publisher.PublishAsync(model, CancellationToken.None); + private static bool s_publisherHasRun; - // Assert - foreach (var expectedFile in _expectedFiles) - { - await AssertOutputFileContentsEqualExpectedFileContents(tempDirectory, expectedFile); - } - } - - private void LoadSnapshots() + private static readonly Dictionary s_expectedFilesCache = new() { - var embeddedProvider = new EmbeddedFileProvider(Assembly.GetExecutingAssembly()); - - foreach (var expectedFile in _expectedFiles) + ["Chart.yaml"] = ExpectedValues.Chart, + ["values.yaml"] = ExpectedValues.Values, + ["templates/project1/deployment.yaml"] = ExpectedValues.ProjectOneDeployment, + ["templates/project1/configmap.yaml"] = ExpectedValues.ProjectOneConfigMap, + ["templates/myapp/deployment.yaml"] = ExpectedValues.MyAppDeployment, + ["templates/myapp/service.yaml"] = ExpectedValues.MyAppService, + ["templates/myapp/configmap.yaml"] = ExpectedValues.MyAppConfigMap, + ["templates/myapp/secret.yaml"] = ExpectedValues.MyAppSecret, + }; + + public static TheoryData GetExpectedFiles() => new(s_expectedFilesCache.Keys); + + [Theory, MemberData(nameof(GetExpectedFiles))] + public async Task PublishAsync_GeneratesValidHelmChart(string expectedFile) + { + if (!s_publisherHasRun) { - using var stream = embeddedProvider.GetFileInfo($"ExpectedValues.{expectedFile.Replace('/', '.')}").CreateReadStream() ?? throw new FileNotFoundException($"Expected file not found: {expectedFile}"); - using var reader = new StreamReader(stream); - _expectedValuesContentCache[expectedFile] = reader.ReadToEnd(); + // Arrange + ArgumentNullException.ThrowIfNull(fixture.TempDirectoryInstance); + var options = new OptionsMonitor( + new() + { + OutputPath = fixture.TempDirectoryInstance.Path, + }); + + var builder = DistributedApplication.CreateBuilder(); + + var param0 = builder.AddParameter("param0"); + var param1 = builder.AddParameter("param1", secret: true); + var param2 = builder.AddParameter("param2", "default", publishValueAsDefault: true); + 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(env: "PORT") + .WithEnvironment("param0", param0) + .WithEnvironment("param1", param1) + .WithEnvironment("param2", param2) + .WithReference(cs) + .WithVolume("logs", "/logs") + .WithArgs("--cs", cs.Resource); + + builder.AddProject("project1", launchProfileName: null) + .WithReference(api.GetEndpoint("http")); + + var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + + var publisher = new KubernetesPublisher( + "test", options, + NullLogger.Instance, + new(DistributedApplicationOperation.Publish)); + + // Act + await publisher.PublishAsync(model, CancellationToken.None); + s_publisherHasRun = true; } - } - private async Task AssertOutputFileContentsEqualExpectedFileContents(TempDirectory tempDirectory, string outputPath) - { - var file = Path.Combine(tempDirectory.Path, outputPath); + ArgumentNullException.ThrowIfNull(fixture.TempDirectoryInstance); + + // Assert + var file = Path.Combine(fixture.TempDirectoryInstance.Path, expectedFile); Assert.True(File.Exists(file), $"File not found: {file}"); var outputContent = await File.ReadAllTextAsync(file); - Assert.Equal(_expectedValuesContentCache[outputPath], outputContent, ignoreAllWhiteSpace: true, ignoreLineEndingDifferences: true); + Assert.Equal(s_expectedFilesCache[expectedFile], outputContent, ignoreAllWhiteSpace: true, ignoreLineEndingDifferences: true); } private sealed class OptionsMonitor(KubernetesPublisherOptions options) : IOptionsMonitor @@ -108,19 +94,6 @@ private sealed class OptionsMonitor(KubernetesPublisherOptions options) : IOptio public KubernetesPublisherOptions CurrentValue => options; } - private sealed class TempDirectory : IDisposable - { - public string Path { get; } = Directory.CreateTempSubdirectory(".aspire-kubernetes").FullName; - - public void Dispose() - { - if (File.Exists(Path)) - { - File.Delete(Path); - } - } - } - private sealed class TestProject : IProjectMetadata { public string ProjectPath => "another-path"; From 0f5f8c31c0df0dc06e988d496eeb55f9693a3821 Mon Sep 17 00:00:00 2001 From: Prom3theu5 Date: Sun, 23 Mar 2025 16:28:18 +0000 Subject: [PATCH 3/6] comment resolution Still this strange case with tests failing from dotnet run, but not in the ide. --- .../Extensions/HelmExtensions.cs | 7 +- .../Extensions/ResourceExtensions.cs | 6 +- .../KubernetesPublishingContext.cs | 45 +++--- .../KubernetesResourceContext.cs | 142 +++++++++--------- .../ExpectedValues.cs | 10 +- 5 files changed, 106 insertions(+), 104 deletions(-) diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs index 383f20e75f9..9cdb19bb90c 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs @@ -49,12 +49,9 @@ public static string ToPvcName(this string resourceName, string volumeName) public static string ToPvName(this string resourceName, string volumeName) => $"{resourceName}-{volumeName}-{PvKey}"; - public static bool IsHelmExpression(this string value) + public static bool ContainsHelmExpression(this string value) => value.Contains($"{{{{ {ValuesSegment}.", StringComparison.Ordinal); - public static bool IsHelmSecretExpression(this string value) + public static bool ContainsHelmSecretExpression(this string value) => value.Contains($"{{{{ {ValuesSegment}.{SecretsKey}.", StringComparison.Ordinal); - - public static bool IsConnectionString(this string value) - => value.StartsWith("CONNECTIONSTRINGS__", StringComparison.OrdinalIgnoreCase); } diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs index 289fc0dd49e..cd4799164f5 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs @@ -76,9 +76,9 @@ internal static StatefulSet ToStatefulSet(this IResource resource, KubernetesRes { // If the value itself contains Helm expressions, use it directly in the template // Otherwise use the expression to reference values.yaml - secret.StringData[kvp.Key] = (kvp.Value.Value?.IsHelmExpression() == true) + secret.StringData[kvp.Key] = (kvp.Value.Value?.ContainsHelmExpression() == true) ? kvp.Value.Value - : kvp.Value.Expression; + : kvp.Value.HelmExpression; processedKeys.Add(kvp.Key); } @@ -105,7 +105,7 @@ internal static StatefulSet ToStatefulSet(this IResource resource, KubernetesRes foreach (var kvp in context.EnvironmentVariables.Where(kvp => !processedKeys.Contains(kvp.Key))) { - configMap.Data[kvp.Key] = kvp.Value.Expression; + configMap.Data[kvp.Key] = kvp.Value.HelmExpression; processedKeys.Add(kvp.Key); } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs index a4c57550253..15d1495ffc5 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs @@ -92,37 +92,36 @@ private async Task WriteKubernetesOutputAsync(DistributedApplicationModel model) private void AppendResourceContextToHelmValues(IResource resource, KubernetesResourceContext resourceContext) { - // Process parameters used in anything that isn't a configmap / secret. - if (resourceContext.Parameters.Count > 0 && _helmValues[HelmExtensions.ParametersKey] is Dictionary helmParameters) - { - var paramValues = resourceContext.Parameters - .Where(kvp => kvp.Value.Value != null) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value!); + AddValuesToHelmSection(resource, resourceContext.Parameters, HelmExtensions.ParametersKey); + AddValuesToHelmSection(resource, resourceContext.EnvironmentVariables, HelmExtensions.ConfigKey); + AddValuesToHelmSection(resource, resourceContext.Secrets, HelmExtensions.SecretsKey); + } - if (paramValues.Count > 0) - { - helmParameters[resource.Name] = paramValues; - } + private void AddValuesToHelmSection( + IResource resource, + Dictionary contextItems, + string helmKey) + { + if (contextItems.Count <= 0 || _helmValues[helmKey] is not Dictionary helmSection) + { + return; } - // Add config section if needed - if (resourceContext.EnvironmentVariables.Count > 0 && _helmValues[HelmExtensions.ConfigKey] is Dictionary helmConfig) + var paramValues = new Dictionary(); + + foreach (var (key, helmExpressionWithValue) in contextItems) { - var configValues = resourceContext.EnvironmentVariables - .Where(kvp => kvp.Value.Value == null || !kvp.Value.Value.IsHelmExpression()) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value ?? string.Empty); + if (helmExpressionWithValue.ValueContainsHelmExpression) + { + continue; + } - helmConfig[resource.Name] = configValues; + paramValues[key] = helmExpressionWithValue.Value ?? string.Empty; } - // Add secrets section if needed - if (resourceContext.Secrets.Count > 0 && _helmValues[HelmExtensions.SecretsKey] is Dictionary helmSecrets) + if (paramValues.Count > 0) { - var secretValues = resourceContext.Secrets - .Where(kvp => kvp.Value.Value == null || !kvp.Value.Value.IsHelmExpression()) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value ?? string.Empty); - - helmSecrets[resource.Name] = secretValues; + helmSection[resource.Name] = paramValues; } } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs index b2f30ad4bf7..72dfaf2f26b 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs @@ -15,11 +15,10 @@ internal sealed class KubernetesResourceContext( KubernetesPublisherOptions publisherOptions) { internal record struct EndpointMapping(string Scheme, string Host, int InternalPort, int ExposedPort, string Name); - internal record struct HelmExpressionValue(string Expression, string? Value); public readonly Dictionary EndpointMappings = []; - public readonly Dictionary EnvironmentVariables = []; - public readonly Dictionary Secrets = []; - public readonly Dictionary Parameters = []; + public readonly Dictionary EnvironmentVariables = []; + public readonly Dictionary Secrets = []; + public readonly Dictionary Parameters = []; public Dictionary Labels = []; public List TemplatedResources { get; } = []; internal List Commands { get; } = []; @@ -78,7 +77,7 @@ internal bool TryGetContainerImageName(IResource resourceInstance, out string? c var value = $"{resourceInstance.Name}:latest"; var expression = imageEnvName.ToHelmParameterExpression(resource.Name); - Parameters[imageEnvName] = new HelmExpressionValue(expression, value); + Parameters[imageEnvName] = new(expression, value); containerImageName = expression; return false; @@ -102,7 +101,12 @@ private void ProcessEndpoints() foreach (var endpoint in endpoints) { - var port = endpoint.TargetPort ?? 80; + var port = endpoint.TargetPort ?? endpoint.UriScheme switch + { + "http" => 8080, + "https" => 8443, + _ => 9000, + }; EndpointMappings[endpoint.Name] = new(endpoint.UriScheme, resource.Name, port, port, endpoint.Name); } @@ -174,41 +178,57 @@ private async Task ProcessEnvironmentAsync(DistributedApplicationExecutionContex await c.Callback(context).ConfigureAwait(false); } - var processedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var kv in context.EnvironmentVariables) + foreach (var environmentVariable in context.EnvironmentVariables) { - var value = await ProcessValueAsync(kv.Value).ConfigureAwait(false); - var key = kv.Key.ToManifestFriendlyResourceName(); - var stringValue = value.ToString() ?? string.Empty; + var key = environmentVariable.Key.ToManifestFriendlyResourceName(); + var value = await ProcessValueAsync(environmentVariable.Value).ConfigureAwait(false); - if (processedKeys.Contains(key)) + switch (value) { - continue; - } - - // Move connection strings to secrets - if (key.IsConnectionString()) - { - var expression = key.ToHelmSecretExpression(resource.Name); - Secrets[key] = new HelmExpressionValue(expression, stringValue); - continue; + case HelmExpressionWithValue helmExpression: + ProcessEnvironmentHelmExpression(helmExpression, key); + continue; + case string stringValue: + ProcessEnvironmentStringValue(stringValue, key, resource.Name); + continue; + default: + ProcessEnvironmentDefaultValue(value, key, resource.Name); + break; } + } + } + } - if (stringValue.IsHelmSecretExpression()) - { - // If the value references secrets, it belongs in secrets - var expression = key.ToHelmSecretExpression(resource.Name); - Secrets[key] = new HelmExpressionValue(expression, stringValue); - continue; - } + private void ProcessEnvironmentHelmExpression(HelmExpressionWithValue helmExpression, string key) + { + switch (helmExpression) + { + case {IsHelmSecretExpression: true, ValueContainsSecretExpression: false}: + Secrets[key] = helmExpression; + return; + case {IsHelmSecretExpression: false, ValueContainsSecretExpression: false}: + EnvironmentVariables[key] = helmExpression; + break; + } + } - // All other values go to environment variables - var configExpression = key.ToHelmConfigExpression(resource.Name); - EnvironmentVariables[key] = new HelmExpressionValue(configExpression, stringValue); - processedKeys.Add(key); - } + private void ProcessEnvironmentStringValue(string stringValue, string key, string resourceName) + { + if (stringValue.ContainsHelmSecretExpression()) + { + var secretExpression = stringValue.ToHelmSecretExpression(resourceName); + Secrets[key] = new(secretExpression, stringValue); + return; } + + var configExpression = key.ToHelmConfigExpression(resourceName); + EnvironmentVariables[key] = new(configExpression, stringValue); + } + + private void ProcessEnvironmentDefaultValue(object value, string key, string resourceName) + { + var configExpression = key.ToHelmConfigExpression(resourceName); + EnvironmentVariables[key] = new(configExpression, value.ToString() ?? string.Empty); } private static string GetEndpointValue(EndpointMapping mapping, EndpointProperty property) @@ -316,53 +336,39 @@ private async Task ProcessValueAsync(object value) } } - private string AllocateParameter(ParameterResource parameter) + private HelmExpressionWithValue AllocateParameter(ParameterResource parameter) { var formattedName = parameter.Name.ToManifestFriendlyResourceName(); - var value = parameter.Default is null ? null : parameter.Value; - if (parameter.Secret) - { - var expression = formattedName.ToHelmSecretExpression(resource.Name); - Secrets[formattedName] = new HelmExpressionValue(expression, value); - return expression; - } + var expression = parameter.Secret ? + formattedName.ToHelmSecretExpression(resource.Name) : + formattedName.ToHelmConfigExpression(resource.Name); - // For non-secret parameters, store in config map - var configExpression = formattedName.ToHelmConfigExpression(resource.Name); - var configValue = parameter.Default is null ? null : parameter.Value; - EnvironmentVariables[formattedName] = new HelmExpressionValue(configExpression, configValue); - return configValue ?? configExpression; + var value = parameter.Default is null ? null : parameter.Value; + return new(expression, value); } - private string ResolveUnknownValue(IManifestExpressionProvider parameter) + private HelmExpressionWithValue ResolveUnknownValue(IManifestExpressionProvider parameter) { var formattedName = parameter.ValueExpression.Replace("{", "") .Replace("}", "") .Replace(".", "_") .ToManifestFriendlyResourceName(); - var value = parameter.ValueExpression; - - // If the value contains Helm expressions - if (value.IsHelmExpression()) - { - // Store in secrets if it references secrets, otherwise in config - if (value.IsHelmSecretExpression()) - { - var expression = formattedName.ToHelmSecretExpression(resource.Name); - Secrets[formattedName] = new HelmExpressionValue(expression, value); - return expression; - } + var helmExpression = parameter.ValueExpression.ContainsHelmSecretExpression() ? + formattedName.ToHelmSecretExpression(resource.Name) : + formattedName.ToHelmConfigExpression(resource.Name); - var configExpression = formattedName.ToHelmConfigExpression(resource.Name); - EnvironmentVariables[formattedName] = new HelmExpressionValue(configExpression, value); - return configExpression; - } + return new(helmExpression, parameter.ValueExpression); + } - // For values without Helm expressions - var targetExpression = formattedName.ToHelmConfigExpression(resource.Name); - EnvironmentVariables[formattedName] = new HelmExpressionValue(targetExpression, value); - return targetExpression; + internal class HelmExpressionWithValue(string helmExpression, string? value) + { + public string HelmExpression { get; } = helmExpression; + public string? Value { get; } = value; + public bool IsHelmSecretExpression => HelmExpression.ContainsHelmSecretExpression(); + public bool ValueContainsSecretExpression => Value?.ContainsHelmSecretExpression() ?? false; + public bool ValueContainsHelmExpression => Value?.ContainsHelmExpression() ?? false; + public override string ToString() => Value ?? HelmExpression; } } diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs index adae8ec469f..7582aa7cea6 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs @@ -32,7 +32,7 @@ public static class ExpectedValues config: myapp: ASPNETCORE_ENVIRONMENT: "Development" - PORT: "80" + PORT: "8080" param0: "" param2: "default" project1: @@ -41,7 +41,7 @@ public static class ExpectedValues OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION: "true" OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION: "true" - services__myapp__http__0: "http://myapp:80" + services__myapp__http__0: "http://myapp:8080" """; @@ -128,7 +128,7 @@ public static class ExpectedValues ports: - name: "http" protocol: "TCP" - containerPort: 80 + containerPort: 8080 volumeMounts: - name: "logs" mountPath: "/logs" @@ -165,8 +165,8 @@ public static class ExpectedValues ports: - name: "http" protocol: "TCP" - port: 80 - targetPort: 80 + port: 8080 + targetPort: 8080 """; From 421dd435138c23502d599c16a3f04e45e722d8ea Mon Sep 17 00:00:00 2001 From: Prom3theu5 Date: Sun, 23 Mar 2025 18:34:43 +0000 Subject: [PATCH 4/6] barh! it was the environmental ordering doing it... why did Rider not pick that up.. --- .../KubernetesPublisherTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 728769f2fc2..926a13c44b2 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -60,6 +60,11 @@ public async Task PublishAsync_GeneratesValidHelmChart(string expectedFile) .WithArgs("--cs", cs.Resource); builder.AddProject("project1", launchProfileName: null) + .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES", "true") + .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES", "true") + .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY", "in_memory") + .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION", "true") + .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION", "true") .WithReference(api.GetEndpoint("http")); var app = builder.Build(); From 7d7796e8acc6129059e84911f36849c2689eeba4 Mon Sep 17 00:00:00 2001 From: Prom3theu5 Date: Sun, 23 Mar 2025 22:26:04 +0000 Subject: [PATCH 5/6] change tests to use TestDistributedApplicationBuilder in publishing mode And fix niggles to do with formatting (editorconfig sets them to no space ^^) --- .../KubernetesResourceContext.cs | 4 ++-- .../Aspire.Hosting.Docker.Tests.csproj | 1 + .../DockerComposePublisherTests.cs | 11 +++++++++-- .../Aspire.Hosting.Kubernetes.Tests.csproj | 1 + .../ExpectedValues.cs | 4 ---- .../KubernetesPublisherTests.cs | 18 +++++++++++------- 6 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs index 72dfaf2f26b..6483478f495 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs @@ -203,10 +203,10 @@ private void ProcessEnvironmentHelmExpression(HelmExpressionWithValue helmExpres { switch (helmExpression) { - case {IsHelmSecretExpression: true, ValueContainsSecretExpression: false}: + case { IsHelmSecretExpression: true, ValueContainsSecretExpression: false }: Secrets[key] = helmExpression; return; - case {IsHelmSecretExpression: false, ValueContainsSecretExpression: false}: + case { IsHelmSecretExpression: false, ValueContainsSecretExpression: false }: EnvironmentVariables[key] = helmExpression; break; } diff --git a/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj b/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj index 96b610ea5da..ec3b214ef3f 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj +++ b/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index e1bc4fbcdba..0b28b3fb391 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -17,7 +19,7 @@ public async Task PublishAsync_GeneratesValidDockerComposeFile() using var tempDir = new TempDirectory(); // Arrange var options = new OptionsMonitor(new DockerComposePublisherOptions { OutputPath = tempDir.Path }); - var builder = DistributedApplication.CreateBuilder(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var param0 = builder.AddParameter("param0"); var param1 = builder.AddParameter("param1", secret: true); @@ -41,9 +43,11 @@ public async Task PublishAsync_GeneratesValidDockerComposeFile() var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + var publisher = new DockerComposePublisher("test", options, NullLogger.Instance, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish)); + builder.ExecutionContext); // Act await publisher.PublishAsync(model, default); @@ -111,6 +115,9 @@ public async Task PublishAsync_GeneratesValidDockerComposeFile() envContent, ignoreAllWhiteSpace: true, ignoreLineEndingDifferences: true); } + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] + private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); + private sealed class OptionsMonitor(DockerComposePublisherOptions options) : IOptionsMonitor { public DockerComposePublisherOptions Get(string? name) => options; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj b/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj index 565ea8f555b..27fe66fc32a 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs index 7582aa7cea6..b1fb107c79e 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs @@ -39,8 +39,6 @@ public static class ExpectedValues OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" - OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION: "true" - OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION: "true" services__myapp__http__0: "http://myapp:8080" """; @@ -94,8 +92,6 @@ public static class ExpectedValues OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES }}" OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES }}" OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY }}" - OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION }}" - OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION: "{{ .Values.config.project1.OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION }}" services__myapp__http__0: "{{ .Values.config.project1.services__myapp__http__0 }}" """; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 926a13c44b2..37103c6447f 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -41,7 +43,7 @@ public async Task PublishAsync_GeneratesValidHelmChart(string expectedFile) OutputPath = fixture.TempDirectoryInstance.Path, }); - var builder = DistributedApplication.CreateBuilder(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var param0 = builder.AddParameter("param0"); var param1 = builder.AddParameter("param1", secret: true); @@ -60,21 +62,20 @@ public async Task PublishAsync_GeneratesValidHelmChart(string expectedFile) .WithArgs("--cs", cs.Resource); builder.AddProject("project1", launchProfileName: null) - .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES", "true") - .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES", "true") - .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY", "in_memory") - .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION", "true") - .WithEnvironment("OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION", "true") .WithReference(api.GetEndpoint("http")); var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + var publisher = new KubernetesPublisher( "test", options, NullLogger.Instance, - new(DistributedApplicationOperation.Publish)); + builder.ExecutionContext); // Act await publisher.PublishAsync(model, CancellationToken.None); @@ -90,6 +91,9 @@ public async Task PublishAsync_GeneratesValidHelmChart(string expectedFile) Assert.Equal(s_expectedFilesCache[expectedFile], outputContent, ignoreAllWhiteSpace: true, ignoreLineEndingDifferences: true); } + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] + private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); + private sealed class OptionsMonitor(KubernetesPublisherOptions options) : IOptionsMonitor { public KubernetesPublisherOptions Get(string? name) => options; From ce22f8a07e5db7d84764392056c6203ce57be955 Mon Sep 17 00:00:00 2001 From: Prom3theu5 Date: Mon, 24 Mar 2025 01:13:21 +0000 Subject: [PATCH 6/6] add the ability for ports to be helm expressions (int or string), also add project defaults endpoint - Still not sure how best to handle WithEndpoint where the assigned TargetPort comes from an Env var - how to ensure we dont get duplicate env vars when there is no publically accessible reference to the fact that the env var is actually an endpoint annotation value? Getting at the actual value seems to require access to TargetPortEnvironmentVariable which is populated by ```csharp if (env is not null && builder.Resource is IResourceWithEndpoints resourceWithEndpoints and IResourceWithEnvironment) { annotation.TargetPortEnvironmentVariable = env; var endpointReference = new EndpointReference(resourceWithEndpoints, annotation); builder.WithAnnotation(new EnvironmentCallbackAnnotation(context => { context.EnvironmentVariables[env] = endpointReference.Property(EndpointProperty.TargetPort); })); } return builder.WithAnnotation(annotation); ``` But thats just expanded back into a regular env var? There's no concept of that being tired to the endpoint annotation at that point?? --- .../Extensions/ResourceExtensions.cs | 9 +- .../KubernetesPublishingContext.cs | 3 +- .../KubernetesResourceContext.cs | 40 +++-- .../Resources/ContainerPortV1.cs | 4 +- .../Resources/Int32OrStringV1.cs | 168 ++++++++++++++++++ .../Resources/ServicePortV1.cs | 4 +- .../Yaml/IntOrStringConverter.cs | 67 +++++++ .../ExpectedValues.cs | 8 +- .../KubernetesPublisherTests.cs | 2 +- 9 files changed, 278 insertions(+), 27 deletions(-) create mode 100644 src/Aspire.Hosting.Kubernetes/Resources/Int32OrStringV1.cs create mode 100644 src/Aspire.Hosting.Kubernetes/Yaml/IntOrStringConverter.cs diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs index cd4799164f5..b293d241903 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs @@ -26,7 +26,8 @@ internal static Deployment ToDeployment(this IResource resource, KubernetesResou Type = "RollingUpdate", RollingUpdate = new() { - MaxUnavailable = 1, MaxSurge = 1, + MaxUnavailable = 1, + MaxSurge = 1, }, }, }, @@ -138,8 +139,8 @@ internal static StatefulSet ToStatefulSet(this IResource resource, KubernetesRes new() { Name = mapping.Name, - Port = mapping.InternalPort, - TargetPort = mapping.ExposedPort, + Port = new(mapping.Port), + TargetPort = new(mapping.Port), Protocol = "TCP", }); } @@ -265,7 +266,7 @@ private static ContainerV1 WithContainerPorts(this ContainerV1 container, Kubern new() { Name = mapping.Name, - ContainerPort = mapping.InternalPort, + ContainerPort = new(mapping.Port), Protocol = "TCP", }); } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs index 15d1495ffc5..c9de7fd9f1f 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs @@ -12,7 +12,7 @@ namespace Aspire.Hosting.Kubernetes; -internal partial class KubernetesPublishingContext( +internal sealed class KubernetesPublishingContext( DistributedApplicationExecutionContext executionContext, KubernetesPublisherOptions publisherOptions, ILogger logger, @@ -30,6 +30,7 @@ internal partial class KubernetesPublishingContext( private readonly ISerializer _serializer = new SerializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .WithTypeConverter(new ByteArrayStringYamlConverter()) + .WithTypeConverter(new IntOrStringYamlConverter()) .WithEventEmitter(nextEmitter => new ForceQuotedStringsEventEmitter(nextEmitter)) .WithEventEmitter(e => new FloatEmitter(e)) .WithEmissionPhaseObjectGraphVisitor(args => new YamlIEnumerableSkipEmptyObjectGraphVisitor(args.InnerVisitor)) diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs index 6483478f495..914b4f2c51a 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs @@ -14,7 +14,7 @@ internal sealed class KubernetesResourceContext( KubernetesPublishingContext kubernetesPublishingContext, KubernetesPublisherOptions publisherOptions) { - internal record struct EndpointMapping(string Scheme, string Host, int InternalPort, int ExposedPort, string Name); + internal record EndpointMapping(string Scheme, string Host, string Port, string Name, string? HelmExpression = null); public readonly Dictionary EndpointMappings = []; public readonly Dictionary EnvironmentVariables = []; public readonly Dictionary Secrets = []; @@ -101,17 +101,33 @@ private void ProcessEndpoints() foreach (var endpoint in endpoints) { - var port = endpoint.TargetPort ?? endpoint.UriScheme switch + if (resource is ProjectResource && endpoint.TargetPort is null) { - "http" => 8080, - "https" => 8443, - _ => 9000, - }; + GenerateDefaultProjectEndpointMapping(endpoint); + continue; + } - EndpointMappings[endpoint.Name] = new(endpoint.UriScheme, resource.Name, port, port, endpoint.Name); + var port = endpoint.TargetPort ?? throw new InvalidOperationException($"Unable to resolve port {endpoint.TargetPort} for endpoint {endpoint.Name} on resource {resource.Name}"); + var portValue = port.ToString(CultureInfo.InvariantCulture); + EndpointMappings[endpoint.Name] = new(endpoint.UriScheme, resource.Name, portValue, endpoint.Name); } } + private void GenerateDefaultProjectEndpointMapping(EndpointAnnotation endpoint) + { + const string defaultPort = "8080"; + + var paramName = $"port_{endpoint.Name}".ToManifestFriendlyResourceName(); + + var helmExpression = paramName.ToHelmParameterExpression(resource.Name); + Parameters[paramName] = new(helmExpression, defaultPort); + + var aspNetCoreUrlsExpression = "ASPNETCORE_URLS".ToHelmConfigExpression(resource.Name); + EnvironmentVariables["ASPNETCORE_URLS"] = new(aspNetCoreUrlsExpression, $"http://+:${defaultPort}"); + + EndpointMappings[endpoint.Name] = new(endpoint.UriScheme, resource.Name, helmExpression, endpoint.Name, helmExpression); + } + private void ProcessVolumes() { if (!resource.TryGetContainerMounts(out var mounts)) @@ -233,15 +249,15 @@ private void ProcessEnvironmentDefaultValue(object value, string key, string res private static string GetEndpointValue(EndpointMapping mapping, EndpointProperty property) { - var (scheme, host, internalPort, exposedPort, _) = mapping; + var (scheme, host, port, _, _) = mapping; return property switch { - EndpointProperty.Url => GetHostValue($"{scheme}://", suffix: $":{internalPort}"), + EndpointProperty.Url => GetHostValue($"{scheme}://", suffix: $":{port}"), EndpointProperty.Host or EndpointProperty.IPV4Host => GetHostValue(), - EndpointProperty.Port => internalPort.ToString(CultureInfo.InvariantCulture), - EndpointProperty.HostAndPort => GetHostValue(suffix: $":{internalPort}"), - EndpointProperty.TargetPort => $"{exposedPort}", + EndpointProperty.Port => port, + EndpointProperty.HostAndPort => GetHostValue(suffix: $":{port}"), + EndpointProperty.TargetPort => port, EndpointProperty.Scheme => scheme, _ => throw new NotSupportedException(), }; diff --git a/src/Aspire.Hosting.Kubernetes/Resources/ContainerPortV1.cs b/src/Aspire.Hosting.Kubernetes/Resources/ContainerPortV1.cs index b97b47fb559..4789577d84e 100644 --- a/src/Aspire.Hosting.Kubernetes/Resources/ContainerPortV1.cs +++ b/src/Aspire.Hosting.Kubernetes/Resources/ContainerPortV1.cs @@ -68,12 +68,12 @@ public sealed class ContainerPortV1 /// for the proper routing of network traffic within a containerized application. /// [YamlMember(Alias = "containerPort")] - public int ContainerPort { get; set; } + public Int32OrStringV1? ContainerPort { get; set; } /// /// Gets or sets the port number on the host machine that is mapped to the container's port. /// This enables external access to the container's service. /// [YamlMember(Alias = "hostPort")] - public int? HostPort { get; set; } + public Int32OrStringV1? HostPort { get; set; } } diff --git a/src/Aspire.Hosting.Kubernetes/Resources/Int32OrStringV1.cs b/src/Aspire.Hosting.Kubernetes/Resources/Int32OrStringV1.cs new file mode 100644 index 00000000000..76b04263d66 --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/Resources/Int32OrStringV1.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; + +namespace Aspire.Hosting.Kubernetes.Resources; + +/// +/// Represents a value that can be either a 32-bit integer or a string. +/// +/// +/// This class provides functionality to handle values that could be either +/// an integer or a string. It supports implicit and explicit conversions, +/// equality comparisons, and YAML serialization/deserialization handling. +/// +public sealed record Int32OrStringV1(int? Number = null, string? Text = null) : IEquatable, IEquatable +{ + /// + /// Initializes a new instance of the class with a 32-bit integer value. + /// + /// The integer value to initialize. + public Int32OrStringV1(int value) : this(Number: value) + { } + + /// + /// Initializes a new instance of the class with a string value. + /// + /// The string value to initialize. + public Int32OrStringV1(string? value) : this( + int.TryParse(value, out var intValue) ? intValue : null, + !int.TryParse(value, out _) ? value : null) + { } + + /// + /// Gets the string value if the instance represents a string; + /// otherwise, returns the string representation of the 32-bit integer value if the instance represents an integer. + /// + public string? Value => + Number?.ToString(CultureInfo.InvariantCulture) ?? Text; + + /// + /// Determines whether the current instance is equal to another integer. + /// + /// The integer to compare with. + /// True if the current instance is equal to the other integer; otherwise, false. + public bool Equals(int other) => + Number == other; + + /// + /// Determines whether the current instance is equal to another string. + /// + /// The string to compare with. + /// True if the current instance is equal to the other string; otherwise, false. + public bool Equals(string? other) => + Text == other; + + /// + /// Returns a string representation of the current instance. + /// + /// The string representation of the value. + public override string? ToString() => Value; + + /// + /// Gets the value as a 32-bit integer. + /// + /// The value to get. + /// + /// Thrown if the value isn't a valid integer. + public static explicit operator int(Int32OrStringV1 value) => + value.Number ?? throw new InvalidCastException("The specified value is not an Int32."); + + /// + /// Gets the value as a string. + /// + /// The value to get. + /// The value as a string. + public static explicit operator string?(Int32OrStringV1? value) => + value?.Text ?? value?.Number?.ToString(CultureInfo.InvariantCulture); + + /// + /// Gets the integer value as a Int32OrStringV1 instance. + /// + /// The integer to get. + /// An Int32OrStringV1 instance representing the integer value. + public static implicit operator Int32OrStringV1(int value) + { + return new(value); + } + + /// + /// Gets the string value as a Int32OrStringV1 instance. + /// + /// The string to get + /// An Int32OrStringV1 instance representing the string value. + public static implicit operator Int32OrStringV1?(string? value) + { + return value is not null ? new Int32OrStringV1(value) : null; + } + + /// + /// Compares an instance of Int32OrStringV1 to an integer for equality. + /// + /// An instance of Int32OrStringV1. + /// The integer instance to check against. + /// a boolean value indicating whether the two instances are equal. + public static bool operator ==(Int32OrStringV1? left, int right) + { + return left is not null && left.Equals(right); + } + + /// + /// Compares an instance of Int32OrStringV1 to an integer for equality. + /// + /// An instance of Int32OrStringV1. + /// The integer instance to check against. + /// a boolean value indicating whether the two instances are not equal. + public static bool operator !=(Int32OrStringV1? left, int right) + { + if (left is null) + { + return true; + } + + return !left.Equals(right); + } + + /// + /// Compares an instance of Int32OrStringV1 to a string for equality. + /// + /// An instance of Int32OrStringV1. + /// The string instance to check against. + /// a boolean value indicating whether the two instances are equal. + public static bool operator ==(Int32OrStringV1? left, string? right) + { + if (left is null && right is null) + { + return true; + } + + if (left is null || right is null) + { + return false; + } + + return left.Equals(right); + } + + /// + /// Compares an instance of Int32OrStringV1 to a string for equality. + /// + /// An instance of Int32OrStringV1. + /// The string instance to check against. + /// a boolean value indicating whether the two instances are not equal. + public static bool operator !=(Int32OrStringV1? left, string? right) + { + if (left is null && right is null) + { + return false; + } + + if (left is null || right is null) + { + return true; + } + + return !left.Equals(right); + } +} diff --git a/src/Aspire.Hosting.Kubernetes/Resources/ServicePortV1.cs b/src/Aspire.Hosting.Kubernetes/Resources/ServicePortV1.cs index 94f390b12da..a698155a0b0 100644 --- a/src/Aspire.Hosting.Kubernetes/Resources/ServicePortV1.cs +++ b/src/Aspire.Hosting.Kubernetes/Resources/ServicePortV1.cs @@ -52,12 +52,12 @@ public sealed class ServicePortV1 /// This value specifies the port on which the service is accessible. /// [YamlMember(Alias = "port")] - public int Port { get; set; } + public Int32OrStringV1 Port { get; set; } = null!; /// /// Specifies the port on the target container to which traffic should be directed. /// Typically used in Kubernetes Service definitions to map incoming traffic to the appropriate port of the application running in a pod. /// [YamlMember(Alias = "targetPort")] - public int TargetPort { get; set; } + public Int32OrStringV1 TargetPort { get; set; } = null!; } diff --git a/src/Aspire.Hosting.Kubernetes/Yaml/IntOrStringConverter.cs b/src/Aspire.Hosting.Kubernetes/Yaml/IntOrStringConverter.cs new file mode 100644 index 00000000000..80ba8db711f --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/Yaml/IntOrStringConverter.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Kubernetes.Resources; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace Aspire.Hosting.Kubernetes.Yaml; + +/// +/// Provides a custom YAML type converter that facilitates serialization +/// and deserialization of objects of type . +/// This converter supports both integers and strings. +/// +public class IntOrStringYamlConverter : IYamlTypeConverter +{ + /// + /// Determines whether the given type is supported by this YAML type converter. + /// + /// The type to check for compatibility with the YAML converter. + /// Returns true if the specified type is , otherwise false. + public bool Accepts(Type type) + { + return type == typeof(Int32OrStringV1); + } + + /// + /// Reads a YAML scalar from the parser and converts it into an instance of . + /// + /// The YAML parser to read the scalar value from. + /// The target type for deserialization, expected to be . + /// The root deserializer used for handling nested deserialization. + /// Returns an instance of constructed from the parsed scalar value. + /// Thrown if the current YAML event is not a scalar. + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + if (parser.Current is not YamlDotNet.Core.Events.Scalar scalar) + { + throw new InvalidOperationException(parser.Current?.ToString()); + } + + var value = scalar.Value; + parser.MoveNext(); + + return string.IsNullOrEmpty(value) ? null : new Int32OrStringV1(value); + } + + /// + /// Writes the given object to the provided YAML emitter using the appropriate format. + /// + /// The emitter used to write the YAML output. + /// The object to be serialized. Expected to be of type . + /// The type of the object being serialized. + /// The serializer to be used for complex object serialization. + /// Thrown when the provided value is not of type . + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) + { + if (value is not Int32OrStringV1 obj) + { + throw new InvalidOperationException($"Expected {nameof(Int32OrStringV1)} but got {value?.GetType()}"); + } + + var val = obj.Value ?? string.Empty; + + serializer(val); + } +} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs index b1fb107c79e..c7885625ba7 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs @@ -32,7 +32,6 @@ public static class ExpectedValues config: myapp: ASPNETCORE_ENVIRONMENT: "Development" - PORT: "8080" param0: "" param2: "default" project1: @@ -124,7 +123,7 @@ public static class ExpectedValues ports: - name: "http" protocol: "TCP" - containerPort: 8080 + containerPort: "8080" volumeMounts: - name: "logs" mountPath: "/logs" @@ -161,8 +160,8 @@ public static class ExpectedValues ports: - name: "http" protocol: "TCP" - port: 8080 - targetPort: 8080 + port: "8080" + targetPort: "8080" """; @@ -178,7 +177,6 @@ public static class ExpectedValues component: "myapp" data: ASPNETCORE_ENVIRONMENT: "{{ .Values.config.myapp.ASPNETCORE_ENVIRONMENT }}" - PORT: "{{ .Values.config.myapp.PORT }}" param0: "{{ .Values.config.myapp.param0 }}" param2: "{{ .Values.config.myapp.param2 }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 37103c6447f..cd61a79f406 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -53,7 +53,7 @@ public async Task PublishAsync_GeneratesValidHelmChart(string expectedFile) // Add a container to the application var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0") .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") - .WithHttpEndpoint(env: "PORT") + .WithHttpEndpoint(targetPort: 8080) .WithEnvironment("param0", param0) .WithEnvironment("param1", param1) .WithEnvironment("param2", param2)