diff --git a/src/Aspire.Hosting.Docker/DockerComposeFileResource.cs b/src/Aspire.Hosting.Docker/DockerComposeFileResource.cs new file mode 100644 index 00000000000..863767745c2 --- /dev/null +++ b/src/Aspire.Hosting.Docker/DockerComposeFileResource.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Docker; + +/// +/// Represents a Docker Compose file resource that imports services from a docker-compose.yml file. +/// +/// The name of the resource. +/// The path to the docker-compose.yml file. +public class DockerComposeFileResource(string name, string composeFilePath) : Resource(name) +{ + /// + /// Gets the path to the docker-compose.yml file. + /// + public string ComposeFilePath { get; } = composeFilePath; + + /// + /// Gets the mapping of service names to their container resource builders. + /// + internal Dictionary> ServiceBuilders { get; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Aspire.Hosting.Docker/DockerComposeFileResourceBuilderExtensions.cs b/src/Aspire.Hosting.Docker/DockerComposeFileResourceBuilderExtensions.cs new file mode 100644 index 00000000000..fdbe3f26241 --- /dev/null +++ b/src/Aspire.Hosting.Docker/DockerComposeFileResourceBuilderExtensions.cs @@ -0,0 +1,480 @@ +// 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.Docker; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Docker Compose file resources to the application model. +/// +public static class DockerComposeFileResourceBuilderExtensions +{ + /// + /// Adds a Docker Compose file to the application model by parsing the compose file and creating container resources. + /// + /// The . + /// The name of the resource. + /// The path to the docker-compose.yml file. + /// A reference to the . + /// + /// This method parses the docker-compose.yml file and translates supported services into Aspire container resources. + /// Services that cannot be translated are skipped with a warning. + /// All created resources are children of the DockerComposeFileResource. + /// + /// + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddDockerComposeFile("mycompose", "./docker-compose.yml"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddDockerComposeFile( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + string composeFilePath) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(composeFilePath); + + // Resolve the compose file path to a full physical path relative to the app host directory + var fullComposeFilePath = Path.GetFullPath(composeFilePath, builder.AppHostDirectory); + + var resource = new DockerComposeFileResource(name, fullComposeFilePath); + + // Parse and import the compose file synchronously to add resources to the model + // Capture any exceptions to report during initialization + Exception? parseException = null; + List warnings = []; + + try + { + ParseAndImportComposeFile(builder, resource, fullComposeFilePath, warnings); + } + catch (Exception ex) + { + parseException = ex; + } + + // Use OnInitializeResource to report any issues that occurred during parsing + return builder.AddResource(resource).ExcludeFromManifest().OnInitializeResource(async (resource, e, ct) => + { + if (parseException is not null) + { + e.Logger.LogError(parseException, "Failed to parse Docker Compose file: {ComposeFilePath}", composeFilePath); + await e.Notifications.PublishUpdateAsync(resource, s => s with { State = KnownResourceStates.FailedToStart }).ConfigureAwait(false); + return; + } + + foreach (var warning in warnings) + { + e.Logger.LogWarning("{Warning}", warning); + } + + await e.Notifications.PublishUpdateAsync(resource, s => s with { State = KnownResourceStates.Running }).ConfigureAwait(false); + }); + } + + private static void ParseAndImportComposeFile( + IDistributedApplicationBuilder builder, + DockerComposeFileResource parentResource, + string composeFilePath, + List warnings) + { + if (!File.Exists(composeFilePath)) + { + throw new FileNotFoundException($"Docker Compose file not found: {composeFilePath}", composeFilePath); + } + + var yamlContent = File.ReadAllText(composeFilePath); + + Dictionary services; + try + { + services = DockerComposeParser.ParseComposeFile(yamlContent); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to parse Docker Compose file: {composeFilePath}", ex); + } + + if (services.Count == 0) + { + warnings.Add($"No services found in Docker Compose file: {composeFilePath}"); + return; + } + + // Collect all unique placeholders across all services and create parameters for them + var uniquePlaceholders = new Dictionary(StringComparer.Ordinal); + foreach (var service in services.Values) + { + foreach (var (placeholderName, placeholder) in service.Placeholders) + { + if (!uniquePlaceholders.ContainsKey(placeholderName)) + { + uniquePlaceholders[placeholderName] = placeholder; + } + } + } + + // Create ParameterResource for each unique placeholder + var parameters = new Dictionary(StringComparer.Ordinal); + foreach (var (placeholderName, placeholder) in uniquePlaceholders) + { + IResourceBuilder paramBuilder; + + if (placeholder.DefaultValue != null) + { + // Create parameter with default value + paramBuilder = builder.AddParameter(placeholderName, placeholder.DefaultValue); + } + else + { + // Create parameter without default (will need to be provided via configuration or user input) + paramBuilder = builder.AddParameter(placeholderName); + } + + parameters[placeholderName] = paramBuilder.Resource; + } + + // First pass: Create all container resources + foreach (var (serviceName, service) in services) + { + try + { + var containerBuilder = ImportService(builder, parentResource, serviceName, service, composeFilePath, parameters, warnings); + if (containerBuilder is not null) + { + parentResource.ServiceBuilders[serviceName] = containerBuilder; + } + } + catch (Exception ex) + { + warnings.Add($"Failed to import service '{serviceName}' from Docker Compose file: {ex.Message}"); + } + } + + // Second pass: Set up dependencies (depends_on) + foreach (var (serviceName, service) in services) + { + if (service.DependsOn.Count == 0) + { + continue; + } + + if (!parentResource.ServiceBuilders.TryGetValue(serviceName, out var containerBuilder)) + { + continue; // Service was skipped + } + + foreach (var (dependencyName, dependency) in service.DependsOn) + { + if (!parentResource.ServiceBuilders.TryGetValue(dependencyName, out var dependencyBuilder)) + { + warnings.Add($"Service '{serviceName}' depends on '{dependencyName}', but '{dependencyName}' was not imported."); + continue; + } + + try + { + // Map Docker Compose condition to Aspire WaitFor methods + var condition = dependency.Condition?.ToLowerInvariant(); + switch (condition) + { + case "service_started": + containerBuilder.WaitForStart(dependencyBuilder); + break; + case "service_healthy": + containerBuilder.WaitFor(dependencyBuilder); + break; + case "service_completed_successfully": + containerBuilder.WaitForCompletion(dependencyBuilder); + break; + case null: + case "": + // Default behavior: wait for service to start + containerBuilder.WaitForStart(dependencyBuilder); + break; + default: + warnings.Add($"Unknown depends_on condition '{dependency.Condition}' for service '{serviceName}' -> '{dependencyName}'. Using default (service_started)."); + containerBuilder.WaitForStart(dependencyBuilder); + break; + } + } + catch (Exception ex) + { + warnings.Add($"Failed to set up dependency for service '{serviceName}' -> '{dependencyName}': {ex.Message}"); + } + } + } + } + + private static IResourceBuilder? ImportService(IDistributedApplicationBuilder builder, DockerComposeFileResource parentResource, string serviceName, ParsedService service, string composeFilePath, Dictionary parameters, List warnings) + { + IResourceBuilder containerBuilder; + + // Check if service has a build configuration + if (service.Build is not null) + { + // Use AddDockerfile for services with build configurations + // Resolve context path relative to the compose file's directory + var contextPath = service.Build.Context ?? "."; + var composeFileDirectory = Path.GetDirectoryName(composeFilePath)!; + var resolvedContextPath = Path.GetFullPath(contextPath, composeFileDirectory); + + var dockerfilePath = service.Build.Dockerfile; + var stage = service.Build.Target; + + containerBuilder = builder.AddDockerfile(serviceName, resolvedContextPath, dockerfilePath, stage) + .WithAnnotation(new ResourceRelationshipAnnotation(parentResource, "parent")); + + // Add build args if present + if (service.Build.Args.Count > 0) + { + foreach (var (key, value) in service.Build.Args) + { + containerBuilder.WithBuildArg(key, value); + } + } + } + else if (!string.IsNullOrWhiteSpace(service.Image)) + { + // Use AddContainer for services with pre-built images + // Parse image using ContainerReferenceParser + ContainerReference containerRef; + try + { + containerRef = ContainerReferenceParser.Parse(service.Image); + } + catch (Exception ex) + { + warnings.Add($"Failed to parse image reference '{service.Image}' for service '{serviceName}': {ex.Message}"); + return null; + } + + var imageName = containerRef.Registry is null + ? containerRef.Image + : $"{containerRef.Registry}/{containerRef.Image}"; + var imageTag = containerRef.Tag ?? "latest"; + + containerBuilder = builder.AddContainer(serviceName, imageName, imageTag) + .WithAnnotation(new ResourceRelationshipAnnotation(parentResource, "parent")); + } + else + { + // Skip services without an image or build configuration + warnings.Add($"Service '{serviceName}' has neither image nor build configuration. Skipping."); + return null; + } + + // Import environment variables + if (service.Environment.Count > 0) + { + foreach (var (key, envVar) in service.Environment) + { + if (envVar.IsLiteral) + { + // Simple literal value + containerBuilder.WithEnvironment(key, envVar.LiteralValue!); + } + else + { + // Value contains placeholders - convert to ReferenceExpression using parameters + var expression = CreateReferenceExpressionFromPlaceholders(envVar, parameters); + containerBuilder.WithEnvironment(key, expression); + } + } + } + + // Import ports + if (service.Ports.Count > 0) + { + foreach (var port in service.Ports) + { + if (port.Target.HasValue) + { + // Determine scheme based on protocol + // Short syntax with explicit /tcp → use tcp scheme (for raw TCP connections) + // Long syntax with protocol:tcp → convert to http (common web scenario) + // No protocol specified → default to http + // UDP → use udp scheme + var scheme = port.Protocol?.ToLowerInvariant() switch + { + "udp" => "udp", + "tcp" when port.IsShortSyntax => "tcp", // Short syntax /tcp means raw TCP + "tcp" => "http", // Long syntax tcp means HTTP over TCP + null => "http", + _ => "http" + }; + + // Use the port name from long syntax if available, otherwise generate one + var endpointName = !string.IsNullOrWhiteSpace(port.Name) + ? port.Name + : (port.Published.HasValue ? $"port{port.Published.Value}" : $"port{port.Target.Value}"); + + containerBuilder.WithEndpoint( + name: endpointName, + scheme: scheme, + port: port.Published, + targetPort: port.Target.Value, + isExternal: true, + isProxied: false); + } + } + } + + // Import volumes + if (service.Volumes.Count > 0) + { + foreach (var volume in service.Volumes) + { + if (!string.IsNullOrWhiteSpace(volume.Target)) + { + if (string.IsNullOrWhiteSpace(volume.Source)) + { + // Anonymous volume - just target path + containerBuilder.WithVolume(volume.Target); + } + else + { + var isReadOnly = volume.ReadOnly; + if (volume.Type == "bind") + { + containerBuilder.WithBindMount(volume.Source, volume.Target, isReadOnly); + } + else + { + containerBuilder.WithVolume(volume.Source, volume.Target, isReadOnly); + } + } + } + } + } + + // Import command + if (service.Command.Count > 0) + { + containerBuilder.WithArgs(service.Command.ToArray()); + } + + // Import entrypoint + if (service.Entrypoint.Count > 0) + { + // WithEntrypoint expects a single string, so join them with space + containerBuilder.WithEntrypoint(string.Join(" ", service.Entrypoint)); + } + + return containerBuilder; + } + + /// + /// Gets a container resource builder for a specific service defined in the Docker Compose file. + /// + /// The . + /// The name of the service as defined in the docker-compose.yml file. + /// The for the specified service. + /// Thrown when the service is not found in the compose file. + /// + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var compose = builder.AddDockerComposeFile("mycompose", "./docker-compose.yml"); + /// + /// // Get a reference to a specific service to configure it further + /// var webService = compose.GetComposeService("web"); + /// webService.WithEnvironment("ADDITIONAL_VAR", "value"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder GetComposeService( + this IResourceBuilder builder, + string serviceName) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(serviceName); + + if (!builder.Resource.ServiceBuilders.TryGetValue(serviceName, out var serviceBuilder)) + { + throw new InvalidOperationException($"Service '{serviceName}' not found in Docker Compose file '{builder.Resource.ComposeFilePath}'. Available services: {string.Join(", ", builder.Resource.ServiceBuilders.Keys)}"); + } + + return serviceBuilder; + } + + /// + /// Creates a ReferenceExpression from a ParsedEnvironmentVariable containing placeholders. + /// + private static ReferenceExpression CreateReferenceExpressionFromPlaceholders(ParsedEnvironmentVariable envVar, Dictionary parameters) + { + var builder = new ReferenceExpressionBuilder(); + + // Parse the format string and append literal parts and placeholder parts + var parts = envVar.Format!.Split(new[] { '{', '}' }); + var isPlaceholder = false; + + for (int i = 0; i < parts.Length; i++) + { + var part = parts[i]; + + if (string.IsNullOrEmpty(part)) + { + isPlaceholder = !isPlaceholder; + continue; + } + + if (isPlaceholder && int.TryParse(part, out var index)) + { + // This is a placeholder reference - append the parameter resource + var placeholder = envVar.Placeholders[index]; + if (parameters.TryGetValue(placeholder.Name, out var parameterResource)) + { + builder.AppendFormatted(parameterResource); + } + else + { + // Fallback to default value if parameter not found (shouldn't happen) + builder.AppendLiteral(placeholder.DefaultValue ?? string.Empty); + } + } + else + { + // Literal text + builder.AppendLiteral(part); + } + + isPlaceholder = !isPlaceholder; + } + + return builder.Build(); + } +} + +/// +/// A value provider that returns a parameter's default value. +/// This is kept for backwards compatibility but is no longer used in the current implementation. +/// +file class ParameterDefault : IValueProvider, IManifestExpressionProvider +{ + private readonly string _value; + private readonly string _name; + + public ParameterDefault(string name, string value) + { + _name = name; + _value = value; + } + + public string ValueExpression => $"${{{_name}}}"; + + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + return new ValueTask(_value); + } +} diff --git a/src/Aspire.Hosting.Docker/DockerComposeParser.cs b/src/Aspire.Hosting.Docker/DockerComposeParser.cs new file mode 100644 index 00000000000..c6cea507c4e --- /dev/null +++ b/src/Aspire.Hosting.Docker/DockerComposeParser.cs @@ -0,0 +1,900 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using YamlDotNet.RepresentationModel; + +namespace Aspire.Hosting.Docker; + +/// +/// Parses Docker Compose service definitions and normalizes various format variations +/// according to the Docker Compose specification. +/// +internal static class DockerComposeParser +{ + /// + /// Parses a Docker Compose YAML file using low-level YamlDotNet APIs. + /// + /// The YAML content to parse. + /// A dictionary of service names to parsed service definitions. + public static Dictionary ParseComposeFile(string yaml) + { + var services = new Dictionary(StringComparer.OrdinalIgnoreCase); + + using var reader = new StringReader(yaml); + var yamlStream = new YamlStream(); + yamlStream.Load(reader); + + if (yamlStream.Documents.Count == 0) + { + return services; + } + + var rootNode = yamlStream.Documents[0].RootNode as YamlMappingNode; + if (rootNode == null) + { + return services; + } + + // Find the "services" node + var servicesKey = new YamlScalarNode("services"); + if (!rootNode.Children.TryGetValue(servicesKey, out var servicesNode) || servicesNode is not YamlMappingNode servicesMapping) + { + return services; + } + + // Parse each service + foreach (var serviceEntry in servicesMapping.Children) + { + if (serviceEntry.Key is not YamlScalarNode serviceNameNode) + { + continue; + } + + var serviceName = serviceNameNode.Value ?? string.Empty; + if (string.IsNullOrEmpty(serviceName)) + { + continue; + } + + if (serviceEntry.Value is not YamlMappingNode serviceNode) + { + continue; + } + + var parsedService = ParseService(serviceNode); + services[serviceName] = parsedService; + } + + return services; + } + + private static ParsedService ParseService(YamlMappingNode serviceNode) + { + var service = new ParsedService(); + + foreach (var property in serviceNode.Children) + { + if (property.Key is not YamlScalarNode keyNode) + { + continue; + } + + var key = keyNode.Value; + switch (key) + { + case "image": + if (property.Value is YamlScalarNode imageNode) + { + service.Image = imageNode.Value; + } + break; + + case "build": + service.Build = ParseBuild(property.Value); + break; + + case "environment": + service.Environment = ParseEnvironmentFromYaml(property.Value); + break; + + case "ports": + service.Ports = ParsePortsFromYaml(property.Value); + break; + + case "volumes": + service.Volumes = ParseVolumesFromYaml(property.Value); + break; + + case "command": + service.Command = ParseCommandOrEntrypoint(property.Value); + break; + + case "entrypoint": + service.Entrypoint = ParseCommandOrEntrypoint(property.Value); + break; + + case "depends_on": + service.DependsOn = ParseDependsOnFromYaml(property.Value); + break; + } + } + + // Collect unique placeholders from environment variables + foreach (var envVar in service.Environment.Values) + { + if (!envVar.IsLiteral) + { + foreach (var placeholder in envVar.Placeholders) + { + // Add to service placeholders dictionary if not already present + if (!service.Placeholders.ContainsKey(placeholder.Name)) + { + service.Placeholders[placeholder.Name] = placeholder; + } + } + } + } + + return service; + } + + private static ParsedBuild? ParseBuild(YamlNode node) + { + if (node is YamlScalarNode scalarNode) + { + // Short syntax: build: ./dir + return new ParsedBuild { Context = scalarNode.Value }; + } + + if (node is not YamlMappingNode mappingNode) + { + return null; + } + + var build = new ParsedBuild(); + + foreach (var property in mappingNode.Children) + { + if (property.Key is not YamlScalarNode keyNode) + { + continue; + } + + var key = keyNode.Value; + switch (key) + { + case "context": + if (property.Value is YamlScalarNode contextNode) + { + build.Context = contextNode.Value; + } + break; + + case "dockerfile": + if (property.Value is YamlScalarNode dockerfileNode) + { + build.Dockerfile = dockerfileNode.Value; + } + break; + + case "target": + if (property.Value is YamlScalarNode targetNode) + { + build.Target = targetNode.Value; + } + break; + + case "args": + build.Args = ParseBuildArgs(property.Value); + break; + } + } + + return build; + } + + private static Dictionary ParseBuildArgs(YamlNode node) + { + var args = new Dictionary(StringComparer.Ordinal); + + if (node is YamlMappingNode mappingNode) + { + foreach (var arg in mappingNode.Children) + { + if (arg.Key is YamlScalarNode keyNode && arg.Value is YamlScalarNode valueNode) + { + args[keyNode.Value ?? string.Empty] = valueNode.Value ?? string.Empty; + } + } + } + else if (node is YamlSequenceNode sequenceNode) + { + // Array format: args: ["KEY=value"] + foreach (var item in sequenceNode.Children) + { + if (item is YamlScalarNode scalarNode && scalarNode.Value != null) + { + var parts = scalarNode.Value.Split('=', 2); + if (parts.Length == 2) + { + args[parts[0]] = parts[1]; + } + else if (parts.Length == 1) + { + args[parts[0]] = string.Empty; + } + } + } + } + + return args; + } + + private static Dictionary ParseEnvironmentFromYaml(YamlNode node) + { + var result = new Dictionary(StringComparer.Ordinal); + + if (node is YamlMappingNode mappingNode) + { + // Dictionary format: {KEY: value, KEY2: value2} + foreach (var env in mappingNode.Children) + { + if (env.Key is YamlScalarNode keyNode && env.Value is YamlScalarNode valueNode) + { + var key = keyNode.Value ?? string.Empty; + var value = valueNode.Value ?? string.Empty; + + result[key] = ParseEnvironmentValue(value); + } + } + } + else if (node is YamlSequenceNode sequenceNode) + { + // Array format: ["KEY=value", "KEY2=value2"] + foreach (var item in sequenceNode.Children) + { + if (item is YamlScalarNode scalarNode && scalarNode.Value != null) + { + var parts = scalarNode.Value.Split('=', 2); + if (parts.Length == 2) + { + result[parts[0]] = ParseEnvironmentValue(parts[1]); + } + else if (parts.Length == 1) + { + // Variable without value (e.g., "DEBUG") - include it as empty literal + result[parts[0]] = new ParsedEnvironmentVariable { LiteralValue = string.Empty }; + } + } + } + } + + return result; + } + + /// + /// Parses an environment variable value that may contain Docker Compose placeholders. + /// + /// The raw environment variable value. + /// A ParsedEnvironmentVariable containing either a literal value or placeholder information. + private static ParsedEnvironmentVariable ParseEnvironmentValue(string value) + { + if (string.IsNullOrEmpty(value)) + { + return new ParsedEnvironmentVariable { LiteralValue = value }; + } + + var placeholders = new List(); + var formatParts = new List(); + var currentPart = new System.Text.StringBuilder(); + var index = 0; + var placeholderIndex = 0; + + while (index < value.Length) + { + if (value[index] == '$' && index + 1 < value.Length) + { + // Check for escaped placeholder ($$) + if (value[index + 1] == '$') + { + // Escaped - treat as literal $ + currentPart.Append('$'); + index += 2; + continue; + } + + // Check for placeholder (${...}) + if (value[index + 1] == '{') + { + var closeBrace = value.IndexOf('}', index + 2); + if (closeBrace == -1) + { + // Malformed placeholder - treat as literal + currentPart.Append(value[index]); + index++; + continue; + } + + // Extract placeholder content + var placeholderContent = value.Substring(index + 2, closeBrace - index - 2); + + // Treat empty placeholders as literal ${} + if (string.IsNullOrWhiteSpace(placeholderContent)) + { + currentPart.Append("${}"); + index = closeBrace + 1; + continue; + } + + var placeholder = ParsePlaceholder(placeholderContent); + + // Add current part to format and start new placeholder + formatParts.Add(currentPart.ToString()); + formatParts.Add($"{{{placeholderIndex}}}"); + placeholders.Add(placeholder); + currentPart.Clear(); + placeholderIndex++; + + index = closeBrace + 1; + continue; + } + } + + currentPart.Append(value[index]); + index++; + } + + // If no placeholders were found, return as literal + if (placeholders.Count == 0) + { + return new ParsedEnvironmentVariable { LiteralValue = currentPart.ToString() }; + } + + // Add final part + formatParts.Add(currentPart.ToString()); + + // Build format string by combining parts + var format = string.Join("", formatParts); + + return new ParsedEnvironmentVariable + { + Format = format, + Placeholders = placeholders + }; + } + + /// + /// Parses a Docker Compose placeholder content (the part between ${ and }). + /// Supports: VAR, VAR:-default, VAR-default, VAR:?error, VAR?error + /// + private static ParsedPlaceholder ParsePlaceholder(string content) + { + // Check for :- syntax (use default if unset or empty) + var colonMinusIndex = content.IndexOf(":-"); + if (colonMinusIndex > 0) + { + return new ParsedPlaceholder + { + Name = content.Substring(0, colonMinusIndex), + DefaultValue = content.Substring(colonMinusIndex + 2), + DefaultType = PlaceholderDefaultType.ColonMinus + }; + } + + // Check for - syntax (use default if unset) + var minusIndex = content.IndexOf('-'); + if (minusIndex > 0 && (minusIndex == content.Length - 1 || content[minusIndex - 1] != ':')) + { + return new ParsedPlaceholder + { + Name = content.Substring(0, minusIndex), + DefaultValue = content.Substring(minusIndex + 1), + DefaultType = PlaceholderDefaultType.Minus + }; + } + + // Check for :? or ? syntax (required with error) - we ignore the error message + var colonQuestionIndex = content.IndexOf(":?"); + if (colonQuestionIndex > 0) + { + return new ParsedPlaceholder + { + Name = content.Substring(0, colonQuestionIndex), + DefaultValue = null, + DefaultType = PlaceholderDefaultType.None + }; + } + + var questionIndex = content.IndexOf('?'); + if (questionIndex > 0) + { + return new ParsedPlaceholder + { + Name = content.Substring(0, questionIndex), + DefaultValue = null, + DefaultType = PlaceholderDefaultType.None + }; + } + + // Simple placeholder ${VAR} + return new ParsedPlaceholder + { + Name = content, + DefaultValue = null, + DefaultType = PlaceholderDefaultType.None + }; + } + + private static List ParsePortsFromYaml(YamlNode node) + { + var result = new List(); + + if (node is not YamlSequenceNode sequenceNode) + { + return result; + } + + foreach (var item in sequenceNode.Children) + { + if (item is YamlScalarNode scalarNode && scalarNode.Value != null) + { + // Short syntax: "8080:80" or "8080:80/tcp" or "127.0.0.1:8080:80" + var port = ParseShortPortSyntax(scalarNode.Value); + result.Add(new ParsedPort + { + Target = port.Target, + Published = port.Published, + Protocol = port.Protocol, + HostIp = port.HostIp, + Name = port.Name, + IsShortSyntax = true + }); + } + else if (item is YamlMappingNode mappingNode) + { + // Long syntax: {target: 80, published: 8080, protocol: tcp, name: web} + int? target = null; + int? published = null; + string? protocol = null; + string? hostIp = null; + string? name = null; + + foreach (var prop in mappingNode.Children) + { + if (prop.Key is not YamlScalarNode keyNode || prop.Value is not YamlScalarNode valueNode) + { + continue; + } + + switch (keyNode.Value) + { + case "target": + if (int.TryParse(valueNode.Value, out var t)) + { + target = t; + } + break; + case "published": + if (int.TryParse(valueNode.Value, out var p)) + { + published = p; + } + break; + case "protocol": + protocol = valueNode.Value; + break; + case "host_ip": + hostIp = valueNode.Value; + break; + case "name": + name = valueNode.Value; + break; + } + } + + result.Add(new ParsedPort + { + Target = target, + Published = published, + Protocol = protocol, // Keep null if not specified + HostIp = hostIp, + Name = name, + IsShortSyntax = false // Long syntax + }); + } + } + + return result; + } + + private static ParsedPort ParseShortPortSyntax(string portSpec) + { + string? protocol = null; + var spec = portSpec; + + // Extract protocol if present (e.g., "8080:80/udp") + if (spec.Contains('/')) + { + var parts = spec.Split('/'); + spec = parts[0]; + protocol = parts[1].ToLowerInvariant(); + } + + string? hostIp = null; + int? published = null; + int? target = null; + + var portParts = spec.Split(':'); + + if (portParts.Length == 1) + { + // Just target port: "3000" + if (int.TryParse(portParts[0], out var t)) + { + target = t; + } + } + else if (portParts.Length == 2) + { + // Published:target: "8080:80" + if (int.TryParse(portParts[0], out var p) && int.TryParse(portParts[1], out var t)) + { + published = p; + target = t; + } + } + else if (portParts.Length == 3) + { + // HostIP:Published:target: "127.0.0.1:8080:80" + hostIp = portParts[0]; + if (int.TryParse(portParts[1], out var p) && int.TryParse(portParts[2], out var t)) + { + published = p; + target = t; + } + } + + return new ParsedPort + { + Target = target, + Published = published, + Protocol = protocol, // Keep null if not specified + HostIp = hostIp + }; + } + + private static List ParseVolumesFromYaml(YamlNode node) + { + var result = new List(); + + if (node is not YamlSequenceNode sequenceNode) + { + return result; + } + + foreach (var item in sequenceNode.Children) + { + if (item is YamlScalarNode scalarNode && scalarNode.Value != null) + { + // Short syntax: "./source:/target:ro" + result.Add(ParseShortVolumeSyntax(scalarNode.Value)); + } + else if (item is YamlMappingNode mappingNode) + { + // Long syntax: {type: bind, source: ./src, target: /app, read_only: true} + string? type = null; + string? source = null; + string? target = null; + bool readOnly = false; + + foreach (var prop in mappingNode.Children) + { + if (prop.Key is not YamlScalarNode keyNode || prop.Value is not YamlScalarNode valueNode) + { + continue; + } + + switch (keyNode.Value) + { + case "type": + type = valueNode.Value; + break; + case "source": + source = valueNode.Value; + break; + case "target": + target = valueNode.Value; + break; + case "read_only": + readOnly = valueNode.Value?.ToLowerInvariant() == "true"; + break; + } + } + + if (target != null) + { + result.Add(new VolumeMount + { + Type = type ?? "volume", + Source = source, + Target = target, + ReadOnly = readOnly + }); + } + } + } + + return result; + } + + private static List ParseCommandOrEntrypoint(YamlNode node) + { + var result = new List(); + + if (node is YamlScalarNode scalarNode && scalarNode.Value != null) + { + // Single string: command: "echo hello" + result.Add(scalarNode.Value); + } + else if (node is YamlSequenceNode sequenceNode) + { + // Array: command: ["echo", "hello"] + foreach (var item in sequenceNode.Children) + { + if (item is YamlScalarNode itemNode && itemNode.Value != null) + { + result.Add(itemNode.Value); + } + } + } + + return result; + } + + private static Dictionary ParseDependsOnFromYaml(YamlNode node) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (node is YamlSequenceNode sequenceNode) + { + // Simple format: ["service1", "service2"] + foreach (var item in sequenceNode.Children) + { + if (item is YamlScalarNode scalarNode && scalarNode.Value != null) + { + result[scalarNode.Value] = new ParsedDependency { Condition = "service_started" }; + } + } + } + else if (node is YamlMappingNode mappingNode) + { + // Long format: {service1: {condition: service_healthy}} + foreach (var dep in mappingNode.Children) + { + if (dep.Key is not YamlScalarNode keyNode || keyNode.Value == null) + { + continue; + } + + var serviceName = keyNode.Value; + var condition = "service_started"; // Default + + if (dep.Value is YamlMappingNode depConfig) + { + var conditionKey = new YamlScalarNode("condition"); + if (depConfig.Children.TryGetValue(conditionKey, out var conditionNode) && + conditionNode is YamlScalarNode conditionScalar) + { + condition = conditionScalar.Value ?? "service_started"; + } + } + + result[serviceName] = new ParsedDependency { Condition = condition }; + } + } + + return result; + } + + private static VolumeMount ParseShortVolumeSyntax(string volumeSpec) + { + var parts = volumeSpec.Split(':'); + string? source = null; + string target; + bool readOnly = false; + string type = "volume"; + + if (parts.Length == 1) + { + // Anonymous volume: "/target" + target = parts[0]; + type = "volume"; + } + else if (parts.Length >= 2) + { + source = parts[0]; + target = parts[1]; + + // Determine type based on source + if (source.StartsWith("./") || source.StartsWith("../") || source.StartsWith("/") || (source.Length > 1 && source[1] == ':')) + { + type = "bind"; + } + + if (parts.Length >= 3) + { + readOnly = parts[2].Contains("ro"); + } + } + else + { + throw new InvalidOperationException($"Invalid volume specification: {volumeSpec}"); + } + + return new VolumeMount + { + Type = type, + Source = source, + Target = target, + ReadOnly = readOnly + }; + } +} + +/// +/// Represents a parsed Docker Compose service. +/// +internal class ParsedService +{ + public string? Image { get; set; } + public ParsedBuild? Build { get; set; } + public Dictionary Environment { get; set; } = new(StringComparer.Ordinal); + public List Ports { get; set; } = []; + public List Volumes { get; set; } = []; + public List Command { get; set; } = []; + public List Entrypoint { get; set; } = []; + public Dictionary DependsOn { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary Placeholders { get; set; } = new(StringComparer.Ordinal); +} + +/// +/// Represents a parsed build configuration. +/// +internal class ParsedBuild +{ + public string? Context { get; set; } + public string? Dockerfile { get; set; } + public string? Target { get; set; } + public Dictionary Args { get; set; } = new(StringComparer.Ordinal); +} + +/// +/// Represents a parsed service dependency. +/// +internal class ParsedDependency +{ + public string Condition { get; set; } = "service_started"; +} + +/// +/// Represents a parsed port mapping from Docker Compose. +/// Spec: https://github.com/compose-spec/compose-spec/blob/master/spec.md#ports +/// +internal class ParsedPort +{ + /// + /// The container port (required). + /// + public int? Target { get; init; } + + /// + /// The host port (optional - if not specified, a random port is assigned). + /// + public int? Published { get; init; } + + /// + /// The protocol (tcp or udp). Null if not explicitly specified in the compose file. + /// + public string? Protocol { get; init; } + + /// + /// The host IP to bind to (optional). + /// + public string? HostIp { get; init; } + + /// + /// Optional human-readable name for the port (from long syntax 'name' field). + /// + public string? Name { get; init; } + + /// + /// Indicates if this port was defined using short syntax (true) or long syntax (false). + /// This affects how tcp protocol is interpreted for scheme determination. + /// + public bool IsShortSyntax { get; init; } +} + +/// +/// Represents a parsed volume mount. +/// +internal record VolumeMount +{ + public string Type { get; init; } = "volume"; + public string? Source { get; init; } + public required string Target { get; init; } + public bool ReadOnly { get; init; } +} + +/// +/// Represents a Docker Compose environment variable placeholder. +/// Spec: https://github.com/compose-spec/compose-spec/blob/master/spec.md#interpolation +/// +internal class ParsedPlaceholder +{ + /// + /// The name of the placeholder variable (e.g., "DATABASE_URL"). + /// + public required string Name { get; init; } + + /// + /// The default value if specified (e.g., "localhost" in ${DB_HOST:-localhost}). + /// Null if no default was specified. + /// + public string? DefaultValue { get; init; } + + /// + /// The type of default syntax used. + /// - ColonMinus (:-) means use default if variable is unset or empty + /// - Minus (-) means use default only if variable is unset + /// - None means no default specified + /// + public PlaceholderDefaultType DefaultType { get; init; } +} + +/// +/// The type of default value syntax used in a placeholder. +/// +internal enum PlaceholderDefaultType +{ + /// + /// No default value specified (e.g., ${VAR}). + /// + None, + + /// + /// Use default if variable is unset or empty (e.g., ${VAR:-default}). + /// + ColonMinus, + + /// + /// Use default only if variable is unset (e.g., ${VAR-default}). + /// + Minus +} + +/// +/// Represents a parsed environment variable value that can be either a literal string +/// or a formatted string with placeholders. +/// +internal class ParsedEnvironmentVariable +{ + /// + /// The literal string value if the environment variable contains no placeholders. + /// + public string? LiteralValue { get; init; } + + /// + /// The format string if the environment variable contains placeholders (e.g., "postgres://{0}:{1}/{2}"). + /// + public string? Format { get; init; } + + /// + /// The ordered list of placeholders referenced in the format string. + /// + public List Placeholders { get; init; } = []; + + /// + /// True if this is a literal value, false if it contains placeholders. + /// + public bool IsLiteral => LiteralValue != null; +} diff --git a/src/Aspire.Hosting.Docker/README.md b/src/Aspire.Hosting.Docker/README.md index f48b67ccd12..3ce5afc1bb8 100644 --- a/src/Aspire.Hosting.Docker/README.md +++ b/src/Aspire.Hosting.Docker/README.md @@ -12,18 +12,48 @@ In your AppHost project, install the Aspire Docker Hosting library with [NuGet]( dotnet add package Aspire.Hosting.Docker ``` -## Usage example +## Usage examples -Then, in the _AppHost.cs_ file of `AppHost`, add the environment: +### Publishing to Docker Compose + +To publish an Aspire application to Docker Compose, add the Docker Compose environment in the _AppHost.cs_ file: ```csharp builder.AddDockerComposeEnvironment("compose"); ``` +Then publish using the Aspire CLI: + ```shell aspire publish -o docker-compose-artifacts ``` +### Importing from Docker Compose + +You can import existing Docker Compose files into your Aspire application model using `AddDockerComposeFile`: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +// Import services from a docker-compose.yml file +builder.AddDockerComposeFile("myservices", "./docker-compose.yml"); + +builder.Build().Run(); +``` + +This will parse the Docker Compose file and create Aspire container resources for each service that has an `image` specified. The following Docker Compose features are supported: + +- **Image**: Container image name and tag +- **Ports**: Port mappings (mapped to Aspire endpoints) +- **Environment**: Environment variables +- **Volumes**: Both bind mounts and named volumes +- **Command**: Container command arguments +- **Entrypoint**: Container entrypoint +- **Build**: Services with build configurations are imported using `AddDockerfile` +- **Depends On**: Service dependencies are mapped to `WaitFor`, `WaitForStart`, or `WaitForCompletion` based on the condition + +**Note**: Other Docker Compose features like networks, health checks, and restart policies are not automatically imported but can be configured manually on the created resources. + ## Feedback & contributing https://github.com/dotnet/aspire diff --git a/src/Aspire.Hosting/Utils/ContainerReferenceParser.cs b/src/Aspire.Hosting/Utils/ContainerReferenceParser.cs index 1cf2083615a..5e14c4b9d30 100644 --- a/src/Aspire.Hosting/Utils/ContainerReferenceParser.cs +++ b/src/Aspire.Hosting/Utils/ContainerReferenceParser.cs @@ -7,8 +7,14 @@ namespace Aspire.Hosting.Utils; /// /// Class to parse container references (e.g. "mcr.microsoft.com/dotnet/sdk:8.0") /// -internal sealed partial class ContainerReferenceParser +public sealed partial class ContainerReferenceParser { + /// + /// Parses a container reference string into its components. + /// + /// The container reference string to parse (e.g., "mcr.microsoft.com/dotnet/sdk:8.0"). + /// A containing the parsed components. + /// Thrown when the input is invalid or cannot be parsed. public static ContainerReference Parse(string input) { if (string.IsNullOrEmpty(input)) @@ -40,6 +46,13 @@ public static ContainerReference Parse(string input) private static partial Regex ImageNameRegex(); } -internal record struct ContainerReference(string? Registry, string Image, string? Tag, string? Digest) +/// +/// Represents a parsed container reference with its registry, image name, tag, and digest components. +/// +/// The registry hostname (e.g., "mcr.microsoft.com"), or null if not specified. +/// The image name (e.g., "dotnet/sdk"). +/// The image tag (e.g., "8.0"), or null if not specified. +/// The image digest, or null if not specified. +public record struct ContainerReference(string? Registry, string Image, string? Tag, string? Digest) { } diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeFileTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeFileTests.cs new file mode 100644 index 00000000000..aadc176c339 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeFileTests.cs @@ -0,0 +1,813 @@ +// 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.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Docker.Tests; + +public class DockerComposeFileTests(ITestOutputHelper output) +{ + [Fact] + public void AddDockerComposeFile_ParsesSimpleService() + { + // Create a temp docker-compose.yml file + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' +services: + redis: + image: redis:7.0 + ports: + - ""6379:6379"" +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Add the docker compose file + var composeResource = builder.AddDockerComposeFile("mycompose", composeFilePath); + + // Verify the compose resource was created + Assert.NotNull(composeResource); + Assert.Equal("mycompose", composeResource.Resource.Name); + Assert.Equal(composeFilePath, composeResource.Resource.ComposeFilePath); + + // Build the app and execute initialization hooks + var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + // Verify that a redis container was created + var redisResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "redis"); + Assert.NotNull(redisResource); + + // Verify the image was set correctly + var imageAnnotation = redisResource.Annotations.OfType().FirstOrDefault(); + Assert.NotNull(imageAnnotation); + Assert.Equal("redis", imageAnnotation.Image); + Assert.Equal("7.0", imageAnnotation.Tag); + + // Verify the endpoint was created + var endpoints = redisResource.Annotations.OfType(); + Assert.NotEmpty(endpoints); + var endpoint = endpoints.First(); + Assert.Equal("port6379", endpoint.Name); + Assert.Equal(6379, endpoint.Port); + Assert.Equal(6379, endpoint.TargetPort); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void AddDockerComposeFile_ParsesMultipleServices() + { + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' +services: + web: + image: nginx:latest + ports: + - ""8080:80"" + db: + image: postgres:14 + environment: + POSTGRES_PASSWORD: secret +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddDockerComposeFile("mycompose", composeFilePath); + + var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + // Verify web service + var webResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "web"); + Assert.NotNull(webResource); + + // Verify db service + var dbResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "db"); + Assert.NotNull(dbResource); + + // Verify environment variable was set on db + var envAnnotations = dbResource.Annotations.OfType(); + Assert.NotEmpty(envAnnotations); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void AddDockerComposeFile_CapturesFileNotFoundError() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Should capture FileNotFoundException but not throw immediately + // Exception will be logged during initialization + var composeResource = builder.AddDockerComposeFile("mycompose", "/nonexistent/docker-compose.yml"); + Assert.NotNull(composeResource); + Assert.Equal("mycompose", composeResource.Resource.Name); + + // Build should succeed - the error is logged during initialization, not thrown + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify the compose resource exists but no services were imported + var composeFileResource = appModel.Resources.OfType().FirstOrDefault(); + Assert.NotNull(composeFileResource); + + // No container services should be imported + var containers = appModel.Resources.OfType(); + Assert.Empty(containers); + } + + [Fact] + public void AddDockerComposeFile_ImportsServicesWithBuildConfiguration() + { + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' +services: + app: + build: + context: . + ports: + - ""3000:3000"" + cache: + image: redis:latest +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddDockerComposeFile("mycompose", composeFilePath); + + var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + // Verify that both services were created + var cacheResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "cache"); + Assert.NotNull(cacheResource); + + // app service should now be imported via AddDockerfile since it has build configuration + var appResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "app"); + Assert.NotNull(appResource); + + // Verify app has build annotation + var buildAnnotation = appResource.Annotations.OfType().FirstOrDefault(); + Assert.NotNull(buildAnnotation); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void AddDockerComposeFile_ParsesVolumeMounts() + { + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' +services: + app: + image: myapp:latest + volumes: + - type: bind + source: ./data + target: /app/data + read_only: true + - type: volume + source: appdata + target: /var/lib/app +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddDockerComposeFile("mycompose", composeFilePath); + + var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var appResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "app"); + Assert.NotNull(appResource); + + var mounts = appResource.Annotations.OfType(); + Assert.NotEmpty(mounts); + Assert.Equal(2, mounts.Count()); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void AddDockerComposeFile_ComprehensiveExample() + { + // Use the test-docker-compose.yml file from the test directory + var composeFilePath = Path.Combine(Directory.GetCurrentDirectory(), "test-docker-compose.yml"); + + if (!File.Exists(composeFilePath)) + { + output.WriteLine($"Warning: test-docker-compose.yml not found at {composeFilePath}, skipping test"); + return; + } + + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddDockerComposeFile("testcompose", composeFilePath); + + var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + // Verify all three services were created + var webResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "web"); + Assert.NotNull(webResource); + + var redisResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "redis"); + Assert.NotNull(redisResource); + + var postgresResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "postgres"); + Assert.NotNull(postgresResource); + + // Verify web service has correct image + var webImage = webResource.Annotations.OfType().FirstOrDefault(); + Assert.NotNull(webImage); + Assert.Equal("nginx", webImage.Image); + Assert.Equal("alpine", webImage.Tag); + + // Verify web service has environment variables + var webEnv = webResource.Annotations.OfType(); + Assert.NotEmpty(webEnv); + + // Verify web service has endpoints + var webEndpoints = webResource.Annotations.OfType(); + Assert.NotEmpty(webEndpoints); + + // Verify postgres has environment variables + var postgresEnv = postgresResource.Annotations.OfType(); + Assert.NotEmpty(postgresEnv); + + // Verify postgres has volumes + var postgresVolumes = postgresResource.Annotations.OfType(); + Assert.NotEmpty(postgresVolumes); + } + + [Fact] + public void AddDockerComposeFile_SupportsServicesWithBuildConfiguration() + { + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' +services: + webapp: + build: + context: ./app + dockerfile: Dockerfile + target: production + args: + NODE_ENV: production + API_URL: https://api.example.com + ports: + - ""3000:3000"" + environment: + PORT: ""3000"" +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddDockerComposeFile("mycompose", composeFilePath); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify webapp service was imported with build configuration + var webappResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "webapp"); + Assert.NotNull(webappResource); + + // Verify it has a Dockerfile build annotation + var buildAnnotation = webappResource.Annotations.OfType().FirstOrDefault(); + Assert.NotNull(buildAnnotation); + // Context path will be made absolute by AddDockerfile, so just check it ends with "app" + Assert.EndsWith("app", buildAnnotation.ContextPath.Replace('\\', '/')); + // Dockerfile path is also made absolute + Assert.EndsWith("Dockerfile", buildAnnotation.DockerfilePath?.Replace('\\', '/')); + Assert.Equal("production", buildAnnotation.Stage); + + // Verify build args are added using WithBuildArg + Assert.NotEmpty(buildAnnotation.BuildArguments); + Assert.True(buildAnnotation.BuildArguments.ContainsKey("NODE_ENV")); + Assert.True(buildAnnotation.BuildArguments.ContainsKey("API_URL")); + + // Verify the endpoint was created with proper name + var endpoints = webappResource.Annotations.OfType(); + Assert.NotEmpty(endpoints); + var endpoint = endpoints.First(); + Assert.Equal("port3000", endpoint.Name); + Assert.Equal(3000, endpoint.Port); + } + finally + { + try { tempDir.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void AddDockerComposeFile_HandlesTcpProtocol() + { + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' +services: + tcpservice: + image: myapp:latest + ports: + - ""5000:5000/tcp"" + - ""8080:8080"" +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddDockerComposeFile("mycompose", composeFilePath); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify tcpservice was imported + var tcpResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "tcpservice"); + Assert.NotNull(tcpResource); + + // Verify endpoints were created + var endpoints = tcpResource.Annotations.OfType().ToList(); + Assert.Equal(2, endpoints.Count); + + // Verify TCP endpoint + var tcpEndpoint = endpoints.FirstOrDefault(e => e.Port == 5000); + Assert.NotNull(tcpEndpoint); + Assert.Equal("port5000", tcpEndpoint.Name); + Assert.Equal("tcp", tcpEndpoint.UriScheme); + Assert.Equal(5000, tcpEndpoint.TargetPort); + + // Verify HTTP endpoint (default when no protocol specified) + var httpEndpoint = endpoints.FirstOrDefault(e => e.Port == 8080); + Assert.NotNull(httpEndpoint); + Assert.Equal("port8080", httpEndpoint.Name); + Assert.Equal("http", httpEndpoint.UriScheme); + Assert.Equal(8080, httpEndpoint.TargetPort); + } + finally + { + try { tempDir.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void AddDockerComposeFile_HandlesDependsOn() + { + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' +services: + database: + image: postgres:15 + ports: + - ""5432:5432"" + + cache: + image: redis:7.0 + ports: + - ""6379:6379"" + + app: + image: myapp:latest + depends_on: + database: + condition: service_started + cache: + condition: service_healthy + ports: + - ""8080:80"" +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddDockerComposeFile("mycompose", composeFilePath); + + var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + // Verify all services were created + var databaseResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "database"); + Assert.NotNull(databaseResource); + + var cacheResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "cache"); + Assert.NotNull(cacheResource); + + var appResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "app"); + Assert.NotNull(appResource); + + // Verify app has WaitAnnotations for dependencies + var waitAnnotations = appResource.Annotations.OfType().ToList(); + Assert.NotEmpty(waitAnnotations); + Assert.Equal(2, waitAnnotations.Count); + + // Verify database dependency (service_started -> WaitUntilStarted) + var dbWait = waitAnnotations.FirstOrDefault(w => w.Resource.Name == "database"); + Assert.NotNull(dbWait); + Assert.Equal(WaitType.WaitUntilStarted, dbWait.WaitType); + + // Verify cache dependency (service_healthy -> WaitUntilHealthy) + var cacheWait = waitAnnotations.FirstOrDefault(w => w.Resource.Name == "cache"); + Assert.NotNull(cacheWait); + Assert.Equal(WaitType.WaitUntilHealthy, cacheWait.WaitType); + } + finally + { + try { tempDir.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void GetComposeService_ReturnsServiceBuilder() + { + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' +services: + web: + image: nginx:alpine + ports: + - ""8080:80"" + api: + image: node:18-alpine + ports: + - ""3000:3000"" +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var composeResource = builder.AddDockerComposeFile("mycompose", composeFilePath); + + // Get specific services using GetComposeService + var webService = composeResource.GetComposeService("web"); + var apiService = composeResource.GetComposeService("api"); + + // Verify we got the correct services + Assert.NotNull(webService); + Assert.Equal("web", webService.Resource.Name); + Assert.NotNull(apiService); + Assert.Equal("api", apiService.Resource.Name); + + // Further configure the services + webService.WithEnvironment("NGINX_HOST", "example.com"); + apiService.WithEnvironment("NODE_ENV", "production"); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify the additional environment variables were added + var webResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "web"); + Assert.NotNull(webResource); + var webEnvVars = webResource.Annotations.OfType(); + Assert.NotEmpty(webEnvVars); + + var apiResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "api"); + Assert.NotNull(apiResource); + var apiEnvVars = apiResource.Annotations.OfType(); + Assert.NotEmpty(apiEnvVars); + } + finally + { + try { tempDir.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void GetComposeService_ThrowsWhenServiceNotFound() + { + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' +services: + web: + image: nginx:alpine +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var composeResource = builder.AddDockerComposeFile("mycompose", composeFilePath); + + // Try to get a service that doesn't exist + var exception = Assert.Throws(() => + composeResource.GetComposeService("nonexistent")); + + Assert.Contains("Service 'nonexistent' not found", exception.Message); + Assert.Contains("Available services: web", exception.Message); + } + finally + { + try { tempDir.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void ParsesArrayFormatEnvironmentVariables() + { + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' +services: + web: + image: nginx:alpine + environment: + - NGINX_HOST=localhost + - NGINX_PORT=80 +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var composeResource = builder.AddDockerComposeFile("mycompose", composeFilePath); + var webService = composeResource.GetComposeService("web"); + + Assert.NotNull(webService); + var containerResource = webService.Resource as ContainerResource; + Assert.NotNull(containerResource); + + // Check that environment variables were parsed from array format + Assert.True(containerResource.TryGetAnnotationsIncludingAncestorsOfType(out var envAnnotations)); + Assert.NotEmpty(envAnnotations); + } + finally + { + try { tempDir.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void AddDockerComposeFile_ParsesComplexRealWorldExample() + { + // Test the exact format reported by the user + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' + +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - ""3000:3000"" + environment: + - VITE_API_URL=http://localhost:8000 + volumes: + - ./frontend:/app + - /app/node_modules + depends_on: + - backend + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - ""8000:8000"" + environment: + - DATABASE_URL=postgresql://user:password@db:5432/appdb + volumes: + - ./backend:/app + depends_on: + - db + + db: + image: postgres:15 + environment: + - POSTGRES_USER=user + - POSTGRES_PASSWORD=password + - POSTGRES_DB=appdb + ports: + - ""5432:5432"" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - ""6379:6379"" + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Add the docker compose file + var composeResource = builder.AddDockerComposeFile("mycompose", composeFilePath); + + // Build the app + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify all services were imported + var containerResources = appModel.Resources.OfType().ToList(); + Assert.Equal(4, containerResources.Count); // frontend, backend, db, redis + + // Verify frontend service + var frontend = containerResources.FirstOrDefault(r => r.Name == "frontend"); + Assert.NotNull(frontend); + // Should be a Dockerfile resource since it has build config + var frontendEndpoints = frontend.Annotations.OfType().ToList(); + Assert.Single(frontendEndpoints); + Assert.Equal(3000, frontendEndpoints[0].Port); + + // Verify backend service + var backend = containerResources.FirstOrDefault(r => r.Name == "backend"); + Assert.NotNull(backend); + var backendEndpoints = backend.Annotations.OfType().ToList(); + Assert.Single(backendEndpoints); + Assert.Equal(8000, backendEndpoints[0].Port); + + // Verify db service + var db = containerResources.FirstOrDefault(r => r.Name == "db"); + Assert.NotNull(db); + var dbEndpoints = db.Annotations.OfType().ToList(); + Assert.Single(dbEndpoints); + Assert.Equal(5432, dbEndpoints[0].Port); + + // Verify redis service + var redis = containerResources.FirstOrDefault(r => r.Name == "redis"); + Assert.NotNull(redis); + var redisEndpoints = redis.Annotations.OfType().ToList(); + Assert.Single(redisEndpoints); + Assert.Equal(6379, redisEndpoints[0].Port); + + // Verify volumes were parsed (check on frontend which has 2 volumes) + var frontendMounts = frontend.Annotations.OfType().ToList(); + Assert.Equal(2, frontendMounts.Count); + + // Verify environment variables were parsed (check db which has 3 env vars in array format) + Assert.True(db.TryGetAnnotationsIncludingAncestorsOfType(out var dbEnvAnnotations)); + Assert.NotEmpty(dbEnvAnnotations); + } + finally + { + try { tempDir.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void AddDockerComposeFile_ParsesLongSyntaxPorts() + { + // Create a temp docker-compose.yml file with long syntax ports + var tempDir = Directory.CreateTempSubdirectory(".docker-compose-file-test"); + output.WriteLine($"Temp directory: {tempDir.FullName}"); + + var composeFilePath = Path.Combine(tempDir.FullName, "docker-compose.yml"); + File.WriteAllText(composeFilePath, @" +version: '3.8' +services: + web: + image: nginx:alpine + ports: + - target: 80 + published: 8080 + protocol: tcp + - target: 443 + published: 8443 + protocol: tcp + host_ip: 127.0.0.1 + - ""9000:9000"" +"); + + try + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Add the docker compose file + var composeResource = builder.AddDockerComposeFile("mycompose", composeFilePath); + + // Build the app to trigger initialization + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify that web container was created + var webResource = appModel.Resources.OfType() + .FirstOrDefault(r => r.Name == "web"); + Assert.NotNull(webResource); + + // Verify endpoints were created (should have 3 ports) + var endpoints = webResource.Annotations.OfType().ToList(); + Assert.Equal(3, endpoints.Count); + + // Verify first port (long syntax: 8080:80/tcp) + var endpoint1 = endpoints.FirstOrDefault(e => e.Name == "port8080"); + Assert.NotNull(endpoint1); + Assert.Equal(80, endpoint1.TargetPort); + Assert.Equal(8080, endpoint1.Port); + Assert.Equal("http", endpoint1.UriScheme); // tcp defaults to http + + // Verify second port (long syntax with host_ip: 127.0.0.1:8443:443/tcp) + var endpoint2 = endpoints.FirstOrDefault(e => e.Name == "port8443"); + Assert.NotNull(endpoint2); + Assert.Equal(443, endpoint2.TargetPort); + Assert.Equal(8443, endpoint2.Port); + + // Verify third port (short syntax: 9000:9000) + var endpoint3 = endpoints.FirstOrDefault(e => e.Name == "port9000"); + Assert.NotNull(endpoint3); + Assert.Equal(9000, endpoint3.TargetPort); + Assert.Equal(9000, endpoint3.Port); + } + finally + { + try { tempDir.Delete(recursive: true); } catch { } + } + } + +} diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeParserTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeParserTests.cs new file mode 100644 index 00000000000..e951bc1bf40 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeParserTests.cs @@ -0,0 +1,1060 @@ +// 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.Docker.Tests; + +/// +/// Tests for DockerComposeParser that verify parsing of various Docker Compose specification formats. +/// Based on the Compose Specification: https://github.com/compose-spec/compose-spec/blob/master/spec.md +/// +public class DockerComposeParserTests +{ + [Fact] + public void ParseComposeFile_EmptyYaml_ReturnsEmptyDictionary() + { + var yaml = ""; + var result = DockerComposeParser.ParseComposeFile(yaml); + Assert.Empty(result); + } + + [Fact] + public void ParseComposeFile_NoServices_ReturnsEmptyDictionary() + { + var yaml = @" +version: '3.8' +networks: + mynetwork: +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + Assert.Empty(result); + } + + [Fact] + public void ParseComposeFile_SingleServiceWithImage_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + web: + image: nginx:alpine +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result); + Assert.True(result.ContainsKey("web")); + Assert.Equal("nginx:alpine", result["web"].Image); + } + + #region Environment Variables Tests + // Spec: Environment variables can be defined using a dictionary or an array + + [Fact] + public void ParseEnvironment_DictionaryFormat_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + environment: + DEBUG: 'true' + LOG_LEVEL: info +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Equal(2, result["app"].Environment.Count); + Assert.True(result["app"].Environment["DEBUG"].IsLiteral); + Assert.Equal("true", result["app"].Environment["DEBUG"].LiteralValue); + Assert.True(result["app"].Environment["LOG_LEVEL"].IsLiteral); + Assert.Equal("info", result["app"].Environment["LOG_LEVEL"].LiteralValue); + } + + [Fact] + public void ParseEnvironment_ArrayFormat_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + environment: + - DEBUG=true + - LOG_LEVEL=info +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Equal(2, result["app"].Environment.Count); + Assert.True(result["app"].Environment["DEBUG"].IsLiteral); + Assert.Equal("true", result["app"].Environment["DEBUG"].LiteralValue); + Assert.True(result["app"].Environment["LOG_LEVEL"].IsLiteral); + Assert.Equal("info", result["app"].Environment["LOG_LEVEL"].LiteralValue); + } + + [Fact] + public void ParseEnvironment_ArrayFormatWithoutValue_SetsEmptyString() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + environment: + - DEBUG + - LOG_LEVEL=info +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Equal(2, result["app"].Environment.Count); + Assert.True(result["app"].Environment["DEBUG"].IsLiteral); + Assert.Equal(string.Empty, result["app"].Environment["DEBUG"].LiteralValue); + Assert.True(result["app"].Environment["LOG_LEVEL"].IsLiteral); + Assert.Equal("info", result["app"].Environment["LOG_LEVEL"].LiteralValue); + } + + [Fact] + public void ParseEnvironment_WithPlaceholders_CreatesFormattedString() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + environment: + - DATABASE_URL=${DATABASE_URL} + - PORT=8080 + - DB_HOST=${DB_HOST:-localhost} + - API_KEY=${API_KEY:?required} + - DEBUG=true +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + // All environment variables should be included + Assert.Equal(5, result["app"].Environment.Count); + + // Literal values + Assert.True(result["app"].Environment["PORT"].IsLiteral); + Assert.Equal("8080", result["app"].Environment["PORT"].LiteralValue); + Assert.True(result["app"].Environment["DEBUG"].IsLiteral); + Assert.Equal("true", result["app"].Environment["DEBUG"].LiteralValue); + + // Placeholder values + Assert.False(result["app"].Environment["DATABASE_URL"].IsLiteral); + Assert.Equal("{0}", result["app"].Environment["DATABASE_URL"].Format); + Assert.Single(result["app"].Environment["DATABASE_URL"].Placeholders); + Assert.Equal("DATABASE_URL", result["app"].Environment["DATABASE_URL"].Placeholders[0].Name); + Assert.Null(result["app"].Environment["DATABASE_URL"].Placeholders[0].DefaultValue); + + Assert.False(result["app"].Environment["DB_HOST"].IsLiteral); + Assert.Equal("{0}", result["app"].Environment["DB_HOST"].Format); + Assert.Single(result["app"].Environment["DB_HOST"].Placeholders); + Assert.Equal("DB_HOST", result["app"].Environment["DB_HOST"].Placeholders[0].Name); + Assert.Equal("localhost", result["app"].Environment["DB_HOST"].Placeholders[0].DefaultValue); + Assert.Equal(PlaceholderDefaultType.ColonMinus, result["app"].Environment["DB_HOST"].Placeholders[0].DefaultType); + + Assert.False(result["app"].Environment["API_KEY"].IsLiteral); + Assert.Equal("{0}", result["app"].Environment["API_KEY"].Format); + Assert.Single(result["app"].Environment["API_KEY"].Placeholders); + Assert.Equal("API_KEY", result["app"].Environment["API_KEY"].Placeholders[0].Name); + + // Check placeholders dictionary + Assert.Equal(3, result["app"].Placeholders.Count); + Assert.Contains("DATABASE_URL", result["app"].Placeholders.Keys); + Assert.Contains("DB_HOST", result["app"].Placeholders.Keys); + Assert.Contains("API_KEY", result["app"].Placeholders.Keys); + } + + [Fact] + public void ParseEnvironment_WithEscapedPlaceholders_IncludesLiteralValues() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + environment: + - LITERAL=$${VARIABLE} + - NORMAL=value +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + // Escaped placeholders ($$) should be treated as literal values + Assert.Equal(2, result["app"].Environment.Count); + Assert.True(result["app"].Environment["LITERAL"].IsLiteral); + Assert.Equal("${VARIABLE}", result["app"].Environment["LITERAL"].LiteralValue); + Assert.True(result["app"].Environment["NORMAL"].IsLiteral); + Assert.Equal("value", result["app"].Environment["NORMAL"].LiteralValue); + } + + [Fact] + public void ParseEnvironment_DictionaryWithPlaceholders_CreatesFormattedString() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + environment: + DATABASE_URL: ${DATABASE_URL} + PORT: 8080 + CONFIG_PATH: ${CONFIG_PATH:-/etc/config} + DEBUG: true +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + // All environment variables should be included + Assert.Equal(4, result["app"].Environment.Count); + + // Literal values + Assert.True(result["app"].Environment["PORT"].IsLiteral); + Assert.Equal("8080", result["app"].Environment["PORT"].LiteralValue); + Assert.True(result["app"].Environment["DEBUG"].IsLiteral); + Assert.Equal("true", result["app"].Environment["DEBUG"].LiteralValue); + + // Placeholder values + Assert.False(result["app"].Environment["DATABASE_URL"].IsLiteral); + Assert.Single(result["app"].Environment["DATABASE_URL"].Placeholders); + Assert.Equal("DATABASE_URL", result["app"].Environment["DATABASE_URL"].Placeholders[0].Name); + + Assert.False(result["app"].Environment["CONFIG_PATH"].IsLiteral); + Assert.Single(result["app"].Environment["CONFIG_PATH"].Placeholders); + Assert.Equal("CONFIG_PATH", result["app"].Environment["CONFIG_PATH"].Placeholders[0].Name); + Assert.Equal("/etc/config", result["app"].Environment["CONFIG_PATH"].Placeholders[0].DefaultValue); + } + + [Fact] + public void ParseEnvironment_WithPartialPlaceholders_CreatesFormattedString() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + environment: + - URL=http://${HOST}:8080/api + - PURE=literal +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + // Both variables should be included + Assert.Equal(2, result["app"].Environment.Count); + + // URL contains placeholder in the middle + Assert.False(result["app"].Environment["URL"].IsLiteral); + Assert.Equal("http://{0}:8080/api", result["app"].Environment["URL"].Format); + Assert.Single(result["app"].Environment["URL"].Placeholders); + Assert.Equal("HOST", result["app"].Environment["URL"].Placeholders[0].Name); + + // PURE is literal + Assert.True(result["app"].Environment["PURE"].IsLiteral); + Assert.Equal("literal", result["app"].Environment["PURE"].LiteralValue); + } + + #endregion + + #region Placeholder Parsing Tests + // Comprehensive tests for parsing Docker Compose placeholder expressions + // Spec: https://github.com/compose-spec/compose-spec/blob/master/spec.md#interpolation + + [Theory] + [InlineData("${VAR}", "VAR", null)] + [InlineData("${DATABASE_URL}", "DATABASE_URL", null)] + [InlineData("${MY_VAR_123}", "MY_VAR_123", null)] + public void ParsePlaceholder_SimpleSyntax_ParsesCorrectly(string input, string expectedName, string? expectedDefault) + { + var yaml = $@" +version: '3.8' +services: + app: + image: test + environment: + - TEST={input} +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + var placeholder = result["app"].Environment["TEST"].Placeholders[0]; + + Assert.Equal(expectedName, placeholder.Name); + Assert.Equal(expectedDefault, placeholder.DefaultValue); + Assert.Equal(PlaceholderDefaultType.None, placeholder.DefaultType); + } + + [Theory] + [InlineData("${VAR:-default}", "VAR", "default")] + [InlineData("${DATABASE_URL:-postgres://localhost:5432}", "DATABASE_URL", "postgres://localhost:5432")] + [InlineData("${PORT:-8080}", "PORT", "8080")] + [InlineData("${EMPTY:-}", "EMPTY", "")] + [InlineData("${PATH:-/var/lib/data}", "PATH", "/var/lib/data")] + public void ParsePlaceholder_ColonMinusSyntax_ParsesDefaultValue(string input, string expectedName, string expectedDefault) + { + var yaml = $@" +version: '3.8' +services: + app: + image: test + environment: + - TEST={input} +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + var placeholder = result["app"].Environment["TEST"].Placeholders[0]; + + Assert.Equal(expectedName, placeholder.Name); + Assert.Equal(expectedDefault, placeholder.DefaultValue); + Assert.Equal(PlaceholderDefaultType.ColonMinus, placeholder.DefaultType); + } + + [Theory] + [InlineData("${VAR-default}", "VAR", "default")] + [InlineData("${DATABASE-postgres://localhost}", "DATABASE", "postgres://localhost")] + [InlineData("${CONFIG_FILE-config.yaml}", "CONFIG_FILE", "config.yaml")] + public void ParsePlaceholder_MinusSyntax_ParsesDefaultValue(string input, string expectedName, string expectedDefault) + { + var yaml = $@" +version: '3.8' +services: + app: + image: test + environment: + - TEST={input} +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + var placeholder = result["app"].Environment["TEST"].Placeholders[0]; + + Assert.Equal(expectedName, placeholder.Name); + Assert.Equal(expectedDefault, placeholder.DefaultValue); + Assert.Equal(PlaceholderDefaultType.Minus, placeholder.DefaultType); + } + + [Theory] + [InlineData("${VAR:?error message}")] + [InlineData("${VAR?error}")] + [InlineData("${REQUIRED:?This variable is required}")] + [InlineData("${API_KEY?}")] + public void ParsePlaceholder_RequiredSyntax_ParsesAsNoDefault(string input) + { + var yaml = $@" +version: '3.8' +services: + app: + image: test + environment: + - TEST={input} +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + var envVar = result["app"].Environment["TEST"]; + + Assert.False(envVar.IsLiteral); + Assert.Single(envVar.Placeholders); + // Required placeholders are treated as having no default + Assert.Null(envVar.Placeholders[0].DefaultValue); + Assert.Equal(PlaceholderDefaultType.None, envVar.Placeholders[0].DefaultType); + } + + [Fact] + public void ParsePlaceholder_EscapedDollarSign_TreatsAsLiteral() + { + var yaml = @" +version: '3.8' +services: + app: + image: test + environment: + - TEST=$${VARIABLE} + - MIXED=before$${ESCAPED}after +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + // Escaped $$ should become literal ${VARIABLE} + Assert.True(result["app"].Environment["TEST"].IsLiteral); + Assert.Equal("${VARIABLE}", result["app"].Environment["TEST"].LiteralValue); + + Assert.True(result["app"].Environment["MIXED"].IsLiteral); + Assert.Equal("before${ESCAPED}after", result["app"].Environment["MIXED"].LiteralValue); + } + + [Fact] + public void ParsePlaceholder_MultiplePlaceholdersInValue_ParsesAll() + { + var yaml = @" +version: '3.8' +services: + app: + image: test + environment: + - CONN=postgres://${DB_USER:-admin}:${DB_PASS}@${DB_HOST:-localhost}:${DB_PORT:-5432}/${DB_NAME} +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + var envVar = result["app"].Environment["CONN"]; + + Assert.False(envVar.IsLiteral); + Assert.Equal("postgres://{0}:{1}@{2}:{3}/{4}", envVar.Format); + Assert.Equal(5, envVar.Placeholders.Count); + + Assert.Equal("DB_USER", envVar.Placeholders[0].Name); + Assert.Equal("admin", envVar.Placeholders[0].DefaultValue); + Assert.Equal(PlaceholderDefaultType.ColonMinus, envVar.Placeholders[0].DefaultType); + + Assert.Equal("DB_PASS", envVar.Placeholders[1].Name); + Assert.Null(envVar.Placeholders[1].DefaultValue); + + Assert.Equal("DB_HOST", envVar.Placeholders[2].Name); + Assert.Equal("localhost", envVar.Placeholders[2].DefaultValue); + + Assert.Equal("DB_PORT", envVar.Placeholders[3].Name); + Assert.Equal("5432", envVar.Placeholders[3].DefaultValue); + + Assert.Equal("DB_NAME", envVar.Placeholders[4].Name); + Assert.Null(envVar.Placeholders[4].DefaultValue); + } + + [Fact] + public void ParsePlaceholder_MixedLiteralAndPlaceholder_PreservesLiterals() + { + var yaml = @" +version: '3.8' +services: + app: + image: test + environment: + - URL=https://${HOST}:${PORT}/api/v1 + - PREFIX=app_${ENVIRONMENT}_suffix +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + var url = result["app"].Environment["URL"]; + Assert.False(url.IsLiteral); + Assert.Equal("https://{0}:{1}/api/v1", url.Format); + Assert.Equal(2, url.Placeholders.Count); + Assert.Equal("HOST", url.Placeholders[0].Name); + Assert.Equal("PORT", url.Placeholders[1].Name); + + var prefix = result["app"].Environment["PREFIX"]; + Assert.False(prefix.IsLiteral); + Assert.Equal("app_{0}_suffix", prefix.Format); + Assert.Single(prefix.Placeholders); + Assert.Equal("ENVIRONMENT", prefix.Placeholders[0].Name); + } + + [Fact] + public void ParsePlaceholder_MalformedPlaceholder_TreatsAsLiteral() + { + var yaml = @" +version: '3.8' +services: + app: + image: test + environment: + - MALFORMED=${NO_CLOSE_BRACE + - JUST_DOLLAR=$VAR +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + // Malformed placeholders should be treated as literal text + Assert.True(result["app"].Environment["MALFORMED"].IsLiteral); + Assert.Equal("${NO_CLOSE_BRACE", result["app"].Environment["MALFORMED"].LiteralValue); + + Assert.True(result["app"].Environment["JUST_DOLLAR"].IsLiteral); + Assert.Equal("$VAR", result["app"].Environment["JUST_DOLLAR"].LiteralValue); + } + + [Fact] + public void ParsePlaceholder_EmptyPlaceholder_TreatsAsLiteral() + { + var yaml = @" +version: '3.8' +services: + app: + image: test + environment: + - EMPTY=${} +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + // Empty placeholder should be treated as literal + Assert.True(result["app"].Environment["EMPTY"].IsLiteral); + Assert.Equal("${}", result["app"].Environment["EMPTY"].LiteralValue); + } + + [Fact] + public void ParsePlaceholder_UniquePlaceholdersAcrossMultipleEnvVars_TracksAll() + { + var yaml = @" +version: '3.8' +services: + app: + image: test + environment: + - VAR1=${PLACEHOLDER1} + - VAR2=${PLACEHOLDER2:-default} + - VAR3=${PLACEHOLDER1} + - VAR4=literal + - VAR5=${PLACEHOLDER3}:${PLACEHOLDER1} +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + // Should have 3 unique placeholders even though PLACEHOLDER1 appears 3 times + Assert.Equal(3, result["app"].Placeholders.Count); + Assert.Contains("PLACEHOLDER1", result["app"].Placeholders.Keys); + Assert.Contains("PLACEHOLDER2", result["app"].Placeholders.Keys); + Assert.Contains("PLACEHOLDER3", result["app"].Placeholders.Keys); + + // Check that PLACEHOLDER2 has its default value + Assert.Equal("default", result["app"].Placeholders["PLACEHOLDER2"].DefaultValue); + Assert.Equal(PlaceholderDefaultType.ColonMinus, result["app"].Placeholders["PLACEHOLDER2"].DefaultType); + } + + #endregion + + #region Port Mapping Tests + // Spec: Ports can be defined using short syntax (string) or long syntax (mapping) + + [Fact] + public void ParsePorts_ShortSyntax_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + web: + image: nginx + ports: + - ""8080:80"" + - ""443:443/tcp"" + - ""53:53/udp"" +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Equal(3, result["web"].Ports.Count); + + // First port: "8080:80" - no protocol specified + Assert.Equal(80, result["web"].Ports[0].Target); + Assert.Equal(8080, result["web"].Ports[0].Published); + Assert.Null(result["web"].Ports[0].Protocol); // Not explicitly specified + + // Second port: "443:443/tcp" - tcp explicitly specified + Assert.Equal(443, result["web"].Ports[1].Target); + Assert.Equal(443, result["web"].Ports[1].Published); + Assert.Equal("tcp", result["web"].Ports[1].Protocol); + + // Third port: "53:53/udp" - udp explicitly specified + Assert.Equal(53, result["web"].Ports[2].Target); + Assert.Equal(53, result["web"].Ports[2].Published); + Assert.Equal("udp", result["web"].Ports[2].Protocol); + } + + [Fact] + public void ParsePorts_ShortSyntaxWithHostIp_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + web: + image: nginx + ports: + - ""127.0.0.1:8080:80"" +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["web"].Ports); + Assert.Equal(80, result["web"].Ports[0].Target); + Assert.Equal(8080, result["web"].Ports[0].Published); + Assert.Equal("127.0.0.1", result["web"].Ports[0].HostIp); + Assert.Null(result["web"].Ports[0].Protocol); // Not explicitly specified + } + + [Fact] + public void ParsePorts_LongSyntax_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + web: + image: nginx + ports: + - target: 80 + published: 8080 + protocol: tcp +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["web"].Ports); + Assert.Equal(80, result["web"].Ports[0].Target); + Assert.Equal(8080, result["web"].Ports[0].Published); + Assert.Equal("tcp", result["web"].Ports[0].Protocol); + } + + [Fact] + public void ParsePorts_LongSyntaxWithUdp_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + dns: + image: dns-server + ports: + - target: 53 + published: 5353 + protocol: udp +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["dns"].Ports); + Assert.Equal(53, result["dns"].Ports[0].Target); + Assert.Equal(5353, result["dns"].Ports[0].Published); + Assert.Equal("udp", result["dns"].Ports[0].Protocol); + } + + [Fact] + public void ParsePorts_LongSyntaxWithHostIp_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + web: + image: nginx + ports: + - target: 80 + published: 8080 + host_ip: 127.0.0.1 +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["web"].Ports); + Assert.Equal(80, result["web"].Ports[0].Target); + Assert.Equal(8080, result["web"].Ports[0].Published); + Assert.Equal("127.0.0.1", result["web"].Ports[0].HostIp); + Assert.Null(result["web"].Ports[0].Protocol); // Protocol not explicitly specified in long syntax + } + + [Fact] + public void ParsePorts_ContainerPortOnly_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + web: + image: nginx + ports: + - ""80"" +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["web"].Ports); + Assert.Equal(80, result["web"].Ports[0].Target); + Assert.Null(result["web"].Ports[0].Published); // Not specified, will be randomly assigned + Assert.Null(result["web"].Ports[0].Protocol); // Not explicitly specified + } + + [Fact] + public void ParsePorts_LongSyntaxWithName_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + web: + image: nginx + ports: + - name: web + target: 80 + published: 8080 + - name: web-secured + target: 443 + published: 8443 + protocol: tcp +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Equal(2, result["web"].Ports.Count); + + // First port with name + Assert.Equal(80, result["web"].Ports[0].Target); + Assert.Equal(8080, result["web"].Ports[0].Published); + Assert.Equal("web", result["web"].Ports[0].Name); + + // Second port with name + Assert.Equal(443, result["web"].Ports[1].Target); + Assert.Equal(8443, result["web"].Ports[1].Published); + Assert.Equal("web-secured", result["web"].Ports[1].Name); + } + + #endregion + + #region Volume Tests + // Spec: Volumes can be defined using short syntax (string) or long syntax (mapping) + + [Fact] + public void ParseVolumes_ShortSyntaxBindMount_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + volumes: + - ./data:/app/data +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["app"].Volumes); + var volume = result["app"].Volumes[0]; + Assert.Equal("./data", volume.Source); + Assert.Equal("/app/data", volume.Target); + Assert.Equal("bind", volume.Type); + Assert.False(volume.ReadOnly); + } + + [Fact] + public void ParseVolumes_ShortSyntaxReadOnly_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + volumes: + - ./config:/app/config:ro +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["app"].Volumes); + var volume = result["app"].Volumes[0]; + Assert.Equal("./config", volume.Source); + Assert.Equal("/app/config", volume.Target); + Assert.True(volume.ReadOnly); + } + + [Fact] + public void ParseVolumes_ShortSyntaxNamedVolume_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + db: + image: postgres + volumes: + - dbdata:/var/lib/postgresql/data +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["db"].Volumes); + var volume = result["db"].Volumes[0]; + Assert.Equal("dbdata", volume.Source); + Assert.Equal("/var/lib/postgresql/data", volume.Target); + Assert.Equal("volume", volume.Type); + } + + [Fact] + public void ParseVolumes_ShortSyntaxAnonymous_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + volumes: + - /app/node_modules +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["app"].Volumes); + var volume = result["app"].Volumes[0]; + Assert.Null(volume.Source); + Assert.Equal("/app/node_modules", volume.Target); + Assert.Equal("volume", volume.Type); + } + + [Fact] + public void ParseVolumes_LongSyntax_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + volumes: + - type: bind + source: ./data + target: /app/data + read_only: true +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["app"].Volumes); + var volume = result["app"].Volumes[0]; + Assert.Equal("bind", volume.Type); + Assert.Equal("./data", volume.Source); + Assert.Equal("/app/data", volume.Target); + Assert.True(volume.ReadOnly); + } + + #endregion + + #region Build Configuration Tests + // Spec: Build can be a string (context path) or an object with additional options + + [Fact] + public void ParseBuild_ShortSyntax_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + build: ./app +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.NotNull(result["app"].Build); + Assert.Equal("./app", result["app"].Build!.Context); + } + + [Fact] + public void ParseBuild_LongSyntax_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + build: + context: ./app + dockerfile: Dockerfile.prod + target: production +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + var build = result["app"].Build; + Assert.NotNull(build); + Assert.Equal("./app", build.Context); + Assert.Equal("Dockerfile.prod", build.Dockerfile); + Assert.Equal("production", build.Target); + } + + [Fact] + public void ParseBuild_WithArgs_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + build: + context: . + args: + NODE_VERSION: '18' + BUILD_ENV: production +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + var build = result["app"].Build; + Assert.NotNull(build); + Assert.Equal(2, build.Args.Count); + Assert.Equal("18", build.Args["NODE_VERSION"]); + Assert.Equal("production", build.Args["BUILD_ENV"]); + } + + [Fact] + public void ParseBuild_WithArgsArray_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + build: + context: . + args: + - NODE_VERSION=18 + - BUILD_ENV=production +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + var build = result["app"].Build; + Assert.NotNull(build); + Assert.Equal(2, build.Args.Count); + Assert.Equal("18", build.Args["NODE_VERSION"]); + Assert.Equal("production", build.Args["BUILD_ENV"]); + } + + #endregion + + #region Command and Entrypoint Tests + // Spec: Command and entrypoint can be a string or an array + + [Fact] + public void ParseCommand_String_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + command: bundle exec thin -p 3000 +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["app"].Command); + Assert.Equal("bundle exec thin -p 3000", result["app"].Command[0]); + } + + [Fact] + public void ParseCommand_Array_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + command: [""bundle"", ""exec"", ""thin"", ""-p"", ""3000""] +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Equal(5, result["app"].Command.Count); + Assert.Equal("bundle", result["app"].Command[0]); + Assert.Equal("3000", result["app"].Command[4]); + } + + [Fact] + public void ParseEntrypoint_String_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + entrypoint: /app/start.sh +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Single(result["app"].Entrypoint); + Assert.Equal("/app/start.sh", result["app"].Entrypoint[0]); + } + + [Fact] + public void ParseEntrypoint_Array_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + app: + image: myapp + entrypoint: [""/app/start.sh"", ""--verbose""] +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Equal(2, result["app"].Entrypoint.Count); + Assert.Equal("/app/start.sh", result["app"].Entrypoint[0]); + Assert.Equal("--verbose", result["app"].Entrypoint[1]); + } + + #endregion + + #region Dependencies Tests + // Spec: depends_on can be an array of service names or an object with conditions + + [Fact] + public void ParseDependsOn_SimpleArray_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + web: + image: nginx + depends_on: + - db + - cache +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Equal(2, result["web"].DependsOn.Count); + Assert.True(result["web"].DependsOn.ContainsKey("db")); + Assert.True(result["web"].DependsOn.ContainsKey("cache")); + Assert.Equal("service_started", result["web"].DependsOn["db"].Condition); + } + + [Fact] + public void ParseDependsOn_WithConditions_ParsesCorrectly() + { + var yaml = @" +version: '3.8' +services: + web: + image: nginx + depends_on: + db: + condition: service_healthy + cache: + condition: service_started +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Equal(2, result["web"].DependsOn.Count); + Assert.Equal("service_healthy", result["web"].DependsOn["db"].Condition); + Assert.Equal("service_started", result["web"].DependsOn["cache"].Condition); + } + + #endregion + + #region Multiple Services Tests + + [Fact] + public void ParseComposeFile_MultipleServices_ParsesAllCorrectly() + { + var yaml = @" +version: '3.8' +services: + web: + image: nginx:alpine + ports: + - ""80:80"" + + api: + build: ./api + environment: + NODE_ENV: production + depends_on: + - db + + db: + image: postgres:15 + volumes: + - dbdata:/var/lib/postgresql/data +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Equal(3, result.Count); + + // Verify web service + Assert.Equal("nginx:alpine", result["web"].Image); + Assert.Single(result["web"].Ports); + + // Verify api service + Assert.NotNull(result["api"].Build); + Assert.Equal("./api", result["api"].Build!.Context); + Assert.Single(result["api"].Environment); + Assert.Single(result["api"].DependsOn); + + // Verify db service + Assert.Equal("postgres:15", result["db"].Image); + Assert.Single(result["db"].Volumes); + } + + #endregion + + #region Service Name Case Sensitivity Tests + + [Fact] + public void ParseComposeFile_ServiceNames_AreCaseInsensitive() + { + var yaml = @" +version: '3.8' +services: + WEB: + image: nginx + Api: + image: node +"; + var result = DockerComposeParser.ParseComposeFile(yaml); + + Assert.Equal(2, result.Count); + Assert.True(result.ContainsKey("WEB")); + Assert.True(result.ContainsKey("web")); // Case insensitive + Assert.True(result.ContainsKey("Api")); + Assert.True(result.ContainsKey("api")); // Case insensitive + } + + #endregion +} diff --git a/tests/Aspire.Hosting.Docker.Tests/test-docker-compose.yml b/tests/Aspire.Hosting.Docker.Tests/test-docker-compose.yml new file mode 100644 index 00000000000..82f00e9c646 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/test-docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + - VITE_API_URL=http://localhost:8000 + volumes: + - ./frontend:/app + - /app/node_modules + depends_on: + - backend + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql://user:password@db:5432/appdb + volumes: + - ./backend:/app + depends_on: + - db + + db: + image: postgres:15 + environment: + - POSTGRES_USER=user + - POSTGRES_PASSWORD=password + - POSTGRES_DB=appdb + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: