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..9cdb19bb90c --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs @@ -0,0 +1,57 @@ +// 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 ContainsHelmExpression(this string value) + => value.Contains($"{{{{ {ValuesSegment}.", StringComparison.Ordinal); + + public static bool ContainsHelmSecretExpression(this string value) + => value.Contains($"{{{{ {ValuesSegment}.{SecretsKey}.", StringComparison.Ordinal); +} diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs new file mode 100644 index 00000000000..b293d241903 --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs @@ -0,0 +1,429 @@ +// 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?.ContainsHelmExpression() == true) + ? kvp.Value.Value + : kvp.Value.HelmExpression; + 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.HelmExpression; + 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 = new(mapping.Port), + TargetPort = new(mapping.Port), + 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 = new(mapping.Port), + 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..c9de7fd9f1f 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 sealed 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()) + .WithTypeConverter(new IntOrStringYamlConverter()) + .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) + { + AddValuesToHelmSection(resource, resourceContext.Parameters, HelmExtensions.ParametersKey); + AddValuesToHelmSection(resource, resourceContext.EnvironmentVariables, HelmExtensions.ConfigKey); + AddValuesToHelmSection(resource, resourceContext.Secrets, HelmExtensions.SecretsKey); + } + + private void AddValuesToHelmSection( + IResource resource, + Dictionary contextItems, + string helmKey) + { + if (contextItems.Count <= 0 || _helmValues[helmKey] is not Dictionary helmSection) + { + return; + } + + var paramValues = new Dictionary(); + + foreach (var (key, helmExpressionWithValue) in contextItems) + { + if (helmExpressionWithValue.ValueContainsHelmExpression) + { + continue; + } + + paramValues[key] = helmExpressionWithValue.Value ?? string.Empty; + } + + if (paramValues.Count > 0) + { + helmSection[resource.Name] = paramValues; + } + } + + 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..914b4f2c51a --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResourceContext.cs @@ -0,0 +1,390 @@ +// 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 EndpointMapping(string Scheme, string Host, string Port, string Name, string? HelmExpression = null); + 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(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) + { + if (resource is ProjectResource && endpoint.TargetPort is null) + { + GenerateDefaultProjectEndpointMapping(endpoint); + continue; + } + + 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)) + { + 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); + } + + foreach (var environmentVariable in context.EnvironmentVariables) + { + var key = environmentVariable.Key.ToManifestFriendlyResourceName(); + var value = await ProcessValueAsync(environmentVariable.Value).ConfigureAwait(false); + + switch (value) + { + case HelmExpressionWithValue helmExpression: + ProcessEnvironmentHelmExpression(helmExpression, key); + continue; + case string stringValue: + ProcessEnvironmentStringValue(stringValue, key, resource.Name); + continue; + default: + ProcessEnvironmentDefaultValue(value, key, resource.Name); + break; + } + } + } + } + + 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; + } + } + + 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) + { + var (scheme, host, port, _, _) = mapping; + + return property switch + { + EndpointProperty.Url => GetHostValue($"{scheme}://", suffix: $":{port}"), + EndpointProperty.Host or EndpointProperty.IPV4Host => GetHostValue(), + EndpointProperty.Port => port, + EndpointProperty.HostAndPort => GetHostValue(suffix: $":{port}"), + EndpointProperty.TargetPort => port, + 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 HelmExpressionWithValue AllocateParameter(ParameterResource parameter) + { + var formattedName = parameter.Name.ToManifestFriendlyResourceName(); + + var expression = parameter.Secret ? + formattedName.ToHelmSecretExpression(resource.Name) : + formattedName.ToHelmConfigExpression(resource.Name); + + var value = parameter.Default is null ? null : parameter.Value; + return new(expression, value); + } + + private HelmExpressionWithValue ResolveUnknownValue(IManifestExpressionProvider parameter) + { + var formattedName = parameter.ValueExpression.Replace("{", "") + .Replace("}", "") + .Replace(".", "_") + .ToManifestFriendlyResourceName(); + + var helmExpression = parameter.ValueExpression.ContainsHelmSecretExpression() ? + formattedName.ToHelmSecretExpression(resource.Name) : + formattedName.ToHelmConfigExpression(resource.Name); + + return new(helmExpression, parameter.ValueExpression); + } + + 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/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/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/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/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/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/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.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 6a9ea7630b5..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 new file mode 100644 index 00000000000..c7885625ba7 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/ExpectedValues.cs @@ -0,0 +1,201 @@ +// 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" + 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" + services__myapp__http__0: "http://myapp:8080" + + """; + + 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 }}" + 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: "8080" + 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: "8080" + targetPort: "8080" + + """; + + 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 }}" + 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/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 1c959322469..cd61a79f406 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; @@ -9,50 +11,89 @@ namespace Aspire.Hosting.Kubernetes.Tests; -public class KubernetesPublisherTests +[Collection(KubernetesPublisherFixture.CollectionName)] +public class KubernetesPublisherTests(KubernetesPublisherFixture fixture) { - [Fact] - public async Task PublishAsync_GeneratesValidHelmChart() - { - using var tempDir = new TempDirectory(); - // Arrange - var options = new OptionsMonitor(new KubernetesPublisherOptions() { OutputPath = tempDir.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) - .WithArgs("--cs", cs.Resource); - - builder.AddProject("project1", launchProfileName: null) - .WithReference(api.GetEndpoint("http")); - - var app = builder.Build(); + private static bool s_publisherHasRun; - var model = app.Services.GetRequiredService(); - - var publisher = new KubernetesPublisher("test", options, - NullLogger.Instance, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish)); + private static readonly Dictionary s_expectedFilesCache = new() + { + ["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) + { + // Arrange + ArgumentNullException.ThrowIfNull(fixture.TempDirectoryInstance); + var options = new OptionsMonitor( + new() + { + OutputPath = fixture.TempDirectoryInstance.Path, + }); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + 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(targetPort: 8080) + .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(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var publisher = new KubernetesPublisher( + "test", options, + NullLogger.Instance, + builder.ExecutionContext); + + // Act + await publisher.PublishAsync(model, CancellationToken.None); + s_publisherHasRun = true; + } - // Act - var act = publisher.PublishAsync(model, CancellationToken.None); + ArgumentNullException.ThrowIfNull(fixture.TempDirectoryInstance); // Assert - // TODO: implement once the publisher is implemented. - await Assert.ThrowsAsync(() => act); + 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(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; @@ -62,23 +103,6 @@ private sealed class OptionsMonitor(KubernetesPublisherOptions options) : IOptio public KubernetesPublisherOptions CurrentValue => options; } - private sealed class TempDirectory : IDisposable - { - public TempDirectory() - { - Path = Directory.CreateTempSubdirectory(".aspire-kubernetes").FullName; - } - - public string Path { get; } - public void Dispose() - { - if (File.Exists(Path)) - { - File.Delete(Path); - } - } - } - private sealed class TestProject : IProjectMetadata { public string ProjectPath => "another-path";