From 7fd87412247831d7081c0a15968e3add4f734af4 Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 13 Apr 2026 01:04:17 +0300 Subject: [PATCH] Add Docker Compose file hosting integration --- CommunityToolkit.Aspire.slnx | 6 + Directory.Packages.props | 1 + .../.infra/compose.monitoring.yml | 14 + .../.infra/compose.yml | 38 +++ ...lkit.Aspire.Hosting.Compose.AppHost.csproj | 27 ++ .../Program.cs | 20 ++ .../Properties/launchSettings.json | 29 ++ .../appsettings.json | 9 + ...it.Aspire.Hosting.Compose.Generator.csproj | 26 ++ .../ComposeClassEmitter.cs | 109 ++++++++ .../ComposeServiceNameExtractor.cs | 137 ++++++++++ .../ComposeSourceGenerator.cs | 63 +++++ .../Diagnostics.cs | 17 ++ ...unityToolkit.Aspire.Hosting.Compose.csproj | 28 ++ .../ComposeConstants.cs | 77 ++++++ .../ComposeHealthCheck.cs | 41 +++ .../ComposeParseException.cs | 13 + .../ComposeReferencePathAttribute.cs | 20 ++ .../ComposeResourceBuilderExtensions.cs | 103 ++++++++ .../ComposeResourceCollection.cs | 56 ++++ .../Mapping/CommandMapper.cs | 44 ++++ .../Mapping/DependsOnMapper.cs | 57 ++++ .../Mapping/EnvironmentMapper.cs | 44 ++++ .../Mapping/HealthcheckMapper.cs | 70 +++++ .../Mapping/PortMapper.cs | 59 +++++ .../Mapping/ServiceToResourceMapper.cs | 73 ++++++ .../Mapping/VolumeMapper.cs | 49 ++++ .../Parsing/ComposeParser.cs | 147 +++++++++++ .../Parsing/Contracts/ComposeFile.cs | 34 +++ .../Parsing/Contracts/ComposeHealthcheck.cs | 45 ++++ .../Parsing/Contracts/ComposeNetwork.cs | 21 ++ .../Parsing/Contracts/ComposeService.cs | 81 ++++++ .../Parsing/Contracts/ComposeVolume.cs | 21 ++ .../README.md | 62 +++++ ...munityToolkit.Aspire.Hosting.Compose.props | 5 + ...nityToolkit.Aspire.Hosting.Compose.targets | 8 + ...oolkit.Aspire.Hosting.Compose.Tests.csproj | 12 + .../ComposeVersionTests.cs | 221 ++++++++++++++++ .../HealthcheckTests.cs | 87 ++++++ .../IntegrationTests.cs | 176 +++++++++++++ .../MapperAdvancedTests.cs | 238 +++++++++++++++++ .../MapperTests.cs | 99 +++++++ .../ParserTests.cs | 119 +++++++++ .../SourceGeneratorTests.cs | 247 ++++++++++++++++++ .../composes/basic.yml | 26 ++ .../composes/container-name.yml | 6 + .../composes/depends-on-conditions.yml | 27 ++ .../composes/entrypoint-and-command.yml | 14 + .../composes/environment-list.yml | 9 + .../composes/healthcheck.yml | 25 ++ .../composes/ip-bound-ports.yml | 6 + .../composes/missing-dependency.yml | 5 + .../composes/modern-no-version.yml | 29 ++ .../composes/multiple-ports.yml | 8 + .../composes/registry-port-image.yml | 9 + .../composes/udp-ports.yml | 6 + .../composes/v1-command-only.yml | 4 + .../composes/v1-format.yml | 13 + .../composes/v2-format.yml | 21 ++ .../composes/v3-format.yml | 39 +++ .../composes/volumes-advanced.yml | 8 + 61 files changed, 3108 insertions(+) create mode 100644 examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/.infra/compose.monitoring.yml create mode 100644 examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/.infra/compose.yml create mode 100644 examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/CommunityToolkit.Aspire.Hosting.Compose.AppHost.csproj create mode 100644 examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/Program.cs create mode 100644 examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/Properties/launchSettings.json create mode 100644 examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/appsettings.json create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose.Generator/CommunityToolkit.Aspire.Hosting.Compose.Generator.csproj create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeClassEmitter.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeServiceNameExtractor.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeSourceGenerator.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose.Generator/Diagnostics.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/CommunityToolkit.Aspire.Hosting.Compose.csproj create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/ComposeConstants.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/ComposeHealthCheck.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/ComposeParseException.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/ComposeReferencePathAttribute.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/ComposeResourceBuilderExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/ComposeResourceCollection.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/CommandMapper.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/DependsOnMapper.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/EnvironmentMapper.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/HealthcheckMapper.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/PortMapper.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/ServiceToResourceMapper.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/VolumeMapper.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/ComposeParser.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeFile.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeHealthcheck.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeNetwork.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeService.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeVolume.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/README.md create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/build/CommunityToolkit.Aspire.Hosting.Compose.props create mode 100644 src/CommunityToolkit.Aspire.Hosting.Compose/build/CommunityToolkit.Aspire.Hosting.Compose.targets create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/CommunityToolkit.Aspire.Hosting.Compose.Tests.csproj create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/ComposeVersionTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/HealthcheckTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/IntegrationTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/MapperAdvancedTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/MapperTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/ParserTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/SourceGeneratorTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/basic.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/container-name.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/depends-on-conditions.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/entrypoint-and-command.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/environment-list.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/healthcheck.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/ip-bound-ports.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/missing-dependency.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/modern-no-version.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/multiple-ports.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/registry-port-image.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/udp-ports.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v1-command-only.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v1-format.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v2-format.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v3-format.yml create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/volumes-advanced.yml diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index 1233718eb..6d9b7081a 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -5,6 +5,9 @@ + + + @@ -254,6 +257,8 @@ + + @@ -316,6 +321,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 668a4938a..d4144a74d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -117,6 +117,7 @@ + diff --git a/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/.infra/compose.monitoring.yml b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/.infra/compose.monitoring.yml new file mode 100644 index 000000000..dd427575a --- /dev/null +++ b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/.infra/compose.monitoring.yml @@ -0,0 +1,14 @@ +services: + grafana: + image: grafana/grafana:10.2.0 + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_PASSWORD: admin + + prometheus: + image: prom/prometheus:v2.48.0 + ports: + - "9090:9090" + depends_on: + - grafana diff --git a/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/.infra/compose.yml b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/.infra/compose.yml new file mode 100644 index 000000000..7650e31f6 --- /dev/null +++ b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/.infra/compose.yml @@ -0,0 +1,38 @@ +services: + postgres: + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_USER: admin + POSTGRES_PASSWORD: secret + POSTGRES_DB: appdb + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U admin"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + kafka: + image: confluentinc/cp-kafka:7.5.0 + ports: + - "9092:9092" + environment: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk + depends_on: + - postgres + +volumes: + pgdata: diff --git a/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/CommunityToolkit.Aspire.Hosting.Compose.AppHost.csproj b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/CommunityToolkit.Aspire.Hosting.Compose.AppHost.csproj new file mode 100644 index 000000000..c87d05dd3 --- /dev/null +++ b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/CommunityToolkit.Aspire.Hosting.Compose.AppHost.csproj @@ -0,0 +1,27 @@ + + + + Exe + enable + enable + true + + + + + + + + + + + + + + + + + diff --git a/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/Program.cs b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/Program.cs new file mode 100644 index 000000000..c5aabdb6e --- /dev/null +++ b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/Program.cs @@ -0,0 +1,20 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var infra = builder.AddCompose(); +var monitoring = builder.AddCompose(); + +_ = infra.Postgres; +_ = infra.Redis; +_ = infra.Kafka; +_ = monitoring.Grafana; +_ = monitoring.Prometheus; + +// var infra = builder.AddCompose(".infra/compose.yml"); +// var monitoring = builder.AddCompose(".infra/compose.monitoring.yml"); +// _ = infra["postgres"]; +// _ = infra["redis"]; +// _ = infra["kafka"]; +// _ = monitoring["grafana"]; +// _ = monitoring["prometheus"]; + +builder.Build().Run(); \ No newline at end of file diff --git a/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/Properties/launchSettings.json b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..22a29c7f1 --- /dev/null +++ b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17047;http://localhost:15023", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21036", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22247" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15023", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19100", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20022" + } + } + } +} diff --git a/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/appsettings.json b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/examples/compose/CommunityToolkit.Aspire.Hosting.Compose.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/CommunityToolkit.Aspire.Hosting.Compose.Generator.csproj b/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/CommunityToolkit.Aspire.Hosting.Compose.Generator.csproj new file mode 100644 index 000000000..08019ae98 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/CommunityToolkit.Aspire.Hosting.Compose.Generator.csproj @@ -0,0 +1,26 @@ + + + + latest + enable + disable + false + true + $(NoWarn);CS1591;RS1035;RS1036;RS2008 + false + + + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeClassEmitter.cs b/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeClassEmitter.cs new file mode 100644 index 000000000..25f7ce2a5 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeClassEmitter.cs @@ -0,0 +1,109 @@ +using System.Globalization; +using System.Text; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Generator; + +/// +/// Generates C# source code for compose file wrapper classes. +/// +internal static class ComposeClassEmitter +{ + /// + /// Emits a wrapper class with typed properties for each compose service. + /// + public static string EmitClass(string className, string composePath, string[] serviceNames) + { + string sanitizedClassName = SanitizeIdentifier(className); + StringBuilder sb = new(); + + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("namespace Compose"); + sb.AppendLine("{"); + + sb.AppendLine(" /// "); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + " /// Typed wrapper for compose file: {0}", + EscapeXml(composePath))); + sb.AppendLine(" /// "); + + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + " [global::CommunityToolkit.Aspire.Hosting.Compose.ComposeReferencePath(@\"{0}\")]", + composePath.Replace("\"", "\"\""))); + sb.AppendLine(" [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]"); + + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + " public sealed class {0}", sanitizedClassName)); + sb.AppendLine(" {"); + + sb.AppendLine(" private readonly global::CommunityToolkit.Aspire.Hosting.Compose.ComposeResourceCollection _inner;"); + sb.AppendLine(); + + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + " /// Creates a new wrapping the given collection.", + sanitizedClassName)); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + " public {0}(global::CommunityToolkit.Aspire.Hosting.Compose.ComposeResourceCollection inner) => _inner = inner;", + sanitizedClassName)); + sb.AppendLine(); + + foreach (string name in serviceNames) + { + string propName = SanitizeIdentifier(name); + sb.AppendLine(" /// "); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + " /// Gets the resource builder for the {0} service.", + EscapeXml(name))); + sb.AppendLine(" /// "); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + " public global::Aspire.Hosting.ApplicationModel.IResourceBuilder {0} => _inner[\"{1}\"];", + propName, name.Replace("\"", "\\\""))); + sb.AppendLine(); + } + + sb.AppendLine(" /// Gets a resource by service name."); + sb.AppendLine(" public global::Aspire.Hosting.ApplicationModel.IResourceBuilder this[string serviceName] => _inner[serviceName];"); + + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + /// + /// Converts a name like "my-postgres" or "my_infra" to a valid C# PascalCase identifier. + /// + internal static string SanitizeIdentifier(string name) + { + if (string.IsNullOrEmpty(name)) return "Unknown"; + + StringBuilder sb = new(); + bool capitalizeNext = true; + + foreach (char ch in name) + { + if (ch is '-' or '_' or '.' or ' ') + { + capitalizeNext = true; + continue; + } + + if (sb.Length == 0 && char.IsDigit(ch)) + sb.Append('_'); + + sb.Append(capitalizeNext ? char.ToUpperInvariant(ch) : ch); + capitalizeNext = false; + } + + return sb.Length == 0 ? "Unknown" : sb.ToString(); + } + + private static string EscapeXml(string text) + { + return text + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">"); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeServiceNameExtractor.cs b/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeServiceNameExtractor.cs new file mode 100644 index 000000000..87eab4c4b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeServiceNameExtractor.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Generator; + +/// +/// Minimal YAML parser that extracts service names from Docker Compose files. +/// Avoids YamlDotNet dependency for source generator compatibility. +/// +internal static class ComposeServiceNameExtractor +{ + private static readonly HashSet KnownTopLevelKeys = new(StringComparer.OrdinalIgnoreCase) + { + "version", "services", "volumes", "networks", "configs", "secrets", "extensions", "name" + }; + + /// + /// Extracts service names from compose YAML content. + /// + public static List Extract(string yamlContent) + { + if (string.IsNullOrWhiteSpace(yamlContent)) + return []; + + string[] lines = yamlContent.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + int servicesIndex = FindTopLevelKey(lines, "services"); + return servicesIndex >= 0 + ? ExtractChildKeys(lines, servicesIndex) + : ExtractV1Services(lines); + } + + private static int FindTopLevelKey(string[] lines, string key) + { + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + + if (IsSkippableLine(line)) + continue; + + string trimmed = line.TrimEnd(); + + if (trimmed.Equals(key + ":", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith(key + ":", StringComparison.OrdinalIgnoreCase)) + return i; + } + + return -1; + } + + private static List ExtractChildKeys(string[] lines, int parentIndex) + { + List services = []; + int childIndent = -1; + + for (int i = parentIndex + 1; i < lines.Length; i++) + { + if (IsEmptyOrComment(lines[i])) + continue; + + int indent = GetIndentation(lines[i]); + + if (indent == 0) + break; + + if (childIndent < 0) + childIndent = indent; + + if (indent != childIndent) + continue; + + string? name = ExtractKeyName(lines[i]); + + if (name is not null) + services.Add(name); + } + + return services; + } + + private static List ExtractV1Services(string[] lines) + { + List services = []; + services.AddRange((from t in lines where !IsEmptyOrComment(t) where !IsIndented(t) select ExtractKeyName(t)).Where(name => !KnownTopLevelKeys.Contains(name))); + return services; + } + + private static bool IsSkippableLine(string line) => + line.Length == 0 || line[0] is '#' or ' ' or '\t'; + + private static bool IsEmptyOrComment(string line) => + string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#"); + + private static bool IsIndented(string line) => + line[0] is ' ' or '\t'; + + private static int GetIndentation(string line) + { + int count = 0; + + foreach (char ch in line) + { + switch (ch) + { + case ' ': count++; break; + case '\t': count += 2; break; + default: return count; + } + } + + return count; + } + + private static string? ExtractKeyName(string line) + { + string trimmed = line.TrimStart(); + int colonIndex = trimmed.IndexOf(':'); + + if (colonIndex <= 0) + return null; + + string key = trimmed[..colonIndex].Trim(); + + if (key.StartsWith("-") || key.StartsWith("{") || key.StartsWith("[")) + return null; + + return StripQuotes(key); + } + + private static string StripQuotes(string value) => + value switch + { + ['"', _, ..] when value[^1] == '"' => value.Substring(1, value.Length - 2), + ['\'', _, ..] when value[^1] == '\'' => value.Substring(1, value.Length - 2), + _ => value + }; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeSourceGenerator.cs b/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeSourceGenerator.cs new file mode 100644 index 000000000..fa3c59861 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/ComposeSourceGenerator.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using System; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Generator; + +/// +/// Incremental source generator that reads Docker Compose files registered via +/// <ComposeReference> MSBuild items and generates strongly-typed +/// wrapper classes with service properties. +/// +[Generator] +public sealed class ComposeSourceGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValuesProvider composeFiles = context.AdditionalTextsProvider + .Combine(context.AnalyzerConfigOptionsProvider) + .Select(static (pair, ct) => + { + (AdditionalText? file, AnalyzerConfigOptionsProvider? optionsProvider) = pair; + AnalyzerConfigOptions options = optionsProvider.GetOptions(file); + + if (!options.TryGetValue("build_metadata.AdditionalFiles.ComposeReferenceName", out string? name) || string.IsNullOrEmpty(name)) + return default; + + string path = file.Path; + + if (!path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase) && !path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase)) + return default; + + string? content = file.GetText(ct)?.ToString(); + return new ComposeFile(name, path, content); + }) + .Where(static info => info.Name is not null); + + context.RegisterSourceOutput(composeFiles, static (ctx, info) => + { + if (info.Content is null) + { + ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.ComposeFileNotFound, Location.None, info.Path)); + + string emptySource = ComposeClassEmitter.EmitClass(info.Name!, info.Path!, []); + ctx.AddSource($"Compose.{info.Name}.g.cs", SourceText.From(emptySource, Encoding.UTF8)); + return; + } + + List serviceNames = ComposeServiceNameExtractor.Extract(info.Content); + string source = ComposeClassEmitter.EmitClass(info.Name!, info.Path!, serviceNames.ToArray()); + ctx.AddSource($"Compose.{info.Name}.g.cs", SourceText.From(source, Encoding.UTF8)); + }); + } + + internal readonly struct ComposeFile(string name, string path, string? content) + { + public readonly string? Name = name; + public readonly string? Path = path; + public readonly string? Content = content; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/Diagnostics.cs b/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/Diagnostics.cs new file mode 100644 index 000000000..da0d9c795 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose.Generator/Diagnostics.cs @@ -0,0 +1,17 @@ +using Microsoft.CodeAnalysis; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Generator; + +/// +/// Diagnostic descriptors for the compose source generator. +/// +internal static class Diagnostics +{ + public static readonly DiagnosticDescriptor ComposeFileNotFound = new( + id: "COMPOSE001", + title: "Compose file not found", + messageFormat: "Compose file '{0}' not found. The generated class will have no service constants.", + category: "CommunityToolkit.Aspire.Hosting.Compose", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/CommunityToolkit.Aspire.Hosting.Compose.csproj b/src/CommunityToolkit.Aspire.Hosting.Compose/CommunityToolkit.Aspire.Hosting.Compose.csproj new file mode 100644 index 000000000..9739396c5 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/CommunityToolkit.Aspire.Hosting.Compose.csproj @@ -0,0 +1,28 @@ + + + + An Aspire hosting integration that imports existing Docker Compose files into the Aspire resource graph. + hosting compose docker docker-compose yaml + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeConstants.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeConstants.cs new file mode 100644 index 000000000..ba337e70e --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeConstants.cs @@ -0,0 +1,77 @@ +namespace CommunityToolkit.Aspire.Hosting.Compose; + +/// +/// Compose specification constants. +/// +internal static class ComposeConstants +{ + internal static class TopLevel + { + public const string Services = "services"; + public const string Volumes = "volumes"; + public const string Networks = "networks"; + public const string Version = "version"; + public const string Configs = "configs"; + public const string Secrets = "secrets"; + public const string Extensions = "extensions"; + public const string Name = "name"; + } + + internal static class Service + { + public const string Image = "image"; + public const string Build = "build"; + public const string ContainerName = "container_name"; + public const string Hostname = "hostname"; + public const string Ports = "ports"; + public const string Environment = "environment"; + public const string DependsOn = "depends_on"; + public const string Command = "command"; + public const string Entrypoint = "entrypoint"; + public const string Healthcheck = "healthcheck"; + public const string Restart = "restart"; + } + + internal static class Health + { + public const string Test = "test"; + public const string Interval = "interval"; + public const string Timeout = "timeout"; + public const string Retries = "retries"; + public const string StartPeriod = "start_period"; + public const string Disable = "disable"; + } + + internal static class Resource + { + public const string Driver = "driver"; + public const string External = "external"; + } + + internal static class Condition + { + public const string Key = "condition"; + public const string ServiceStarted = "service_started"; + public const string ServiceHealthy = "service_healthy"; + public const string ServiceCompletedSuccessfully = "service_completed_successfully"; + } + + internal static class Protocol + { + public const string Tcp = "tcp"; + public const string Udp = "udp"; + public const string Http = "http"; + public const string Https = "https"; + } + + internal static class Volume + { + public const string ReadOnly = "ro"; + } + + internal static class Defaults + { + public const string ScratchImage = "scratch"; + public const string LatestTag = "latest"; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeHealthCheck.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeHealthCheck.cs new file mode 100644 index 000000000..7d5985745 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeHealthCheck.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CommunityToolkit.Aspire.Hosting.Compose; + +/// +/// Health check that executes a command inside a running Docker container. +/// +internal sealed class ComposeHealthCheck(string containerName, string command) : IHealthCheck +{ + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + using Process process = new() + { + StartInfo = new ProcessStartInfo + { + FileName = "docker", + ArgumentList = { "exec", containerName, "sh", "-c", command }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + await process.WaitForExitAsync(cancellationToken); + + return process.ExitCode == 0 + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy($"Health check command exited with code {process.ExitCode}"); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy($"Health check failed: {ex.Message}", ex); + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeParseException.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeParseException.cs new file mode 100644 index 000000000..cd81862a6 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeParseException.cs @@ -0,0 +1,13 @@ +namespace CommunityToolkit.Aspire.Hosting.Compose; + +/// +/// Exception thrown when a Docker Compose file cannot be parsed. +/// +public sealed class ComposeParseException(string message, string? filePath = null, Exception? innerException = null) + : Exception(message, innerException) +{ + /// + /// Gets the path of the compose file that failed to parse, if available. + /// + public string? FilePath { get; } = filePath; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeReferencePathAttribute.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeReferencePathAttribute.cs new file mode 100644 index 000000000..1968ff46b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeReferencePathAttribute.cs @@ -0,0 +1,20 @@ +namespace CommunityToolkit.Aspire.Hosting.Compose; + +/// +/// Indicates the path to the Docker Compose file associated with a source-generated class. +/// Applied by the compose source generator to generated Compose.* classes. +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class ComposeReferencePathAttribute : Attribute +{ + /// + /// Gets the path to the compose file. + /// + public string Path { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The path to the compose file. + public ComposeReferencePathAttribute(string path) => Path = path; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeResourceBuilderExtensions.cs new file mode 100644 index 000000000..e96f30b6a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeResourceBuilderExtensions.cs @@ -0,0 +1,103 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Compose; +using CommunityToolkit.Aspire.Hosting.Compose.Mapping; +using CommunityToolkit.Aspire.Hosting.Compose.Parsing; +using CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Reflection; + +#pragma warning disable ASPIREATS001 + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Docker Compose files to the Aspire application model. +/// +public static class ComposeResourceBuilderExtensions +{ + /// The . + extension(IDistributedApplicationBuilder builder) + { + /// + /// Adds a Docker Compose file using a source-generated type. + /// Returns a typed wrapper with properties for each compose service. + /// + /// + /// A source-generated class from the Compose namespace (e.g., Compose.Infra). + /// + /// A typed wrapper with properties for each service (e.g., infra.Postgres). + /// + /// Thrown when the type does not have a ComposeReferencePathAttribute or valid constructor. + /// + /// + /// + /// var infra = builder.AddCompose<Compose.Infra>(); + /// + /// builder.AddProject<Projects.MyApi>("api") + /// .WaitFor(infra.Postgres) + /// .WaitFor(infra.Redis); + /// + /// + [AspireExport("addComposeTyped", Description = "Adds a Docker Compose file using a source-generated type")] + public TCompose AddCompose() where TCompose : class + { + ArgumentNullException.ThrowIfNull(builder); + Type composeType = typeof(TCompose); + ComposeReferencePathAttribute pathAttr = composeType.GetCustomAttribute() + ?? throw new InvalidOperationException($"Type '{composeType.FullName}' does not have a ComposeReferencePathAttribute. " + "Ensure it was generated from a MSBuild item."); + ComposeResourceCollection collection = builder.AddCompose(pathAttr.Path); + + return Activator.CreateInstance(composeType, collection) as TCompose ?? throw new InvalidOperationException( $"Failed to create instance of '{composeType.FullName}'. " + $"Ensure it has a public constructor accepting {nameof(ComposeResourceCollection)}."); + } + + /// + /// Adds a Docker Compose file to the Aspire application model. + /// Each service defined in the compose file becomes a container resource. + /// + /// The path to the Docker Compose file (absolute or relative to the AppHost project directory). + /// A providing access to the created resources. + /// Thrown when the compose file does not exist. + /// Thrown when the YAML content is invalid. + /// + /// + /// var infra = builder.AddCompose(".infra/compose.yml"); + /// + /// builder.AddProject<Projects.MyApi>("api") + /// .WaitFor(infra["postgres"]) + /// .WaitFor(infra["redis"]); + /// + /// + [AspireExport("addCompose", Description = "Adds a Docker Compose file to the Aspire application model")] + public ComposeResourceCollection AddCompose(string composePath) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(composePath); + ILoggerFactory? loggerFactory = builder.Services.BuildServiceProvider().GetService(); + ILogger? logger = loggerFactory?.CreateLogger("CommunityToolkit.Aspire.Hosting.Compose"); + string resolvedPath = ResolveComposePath(builder, composePath); + ComposeFile compose = ComposeParser.Parse(resolvedPath); + Dictionary> resources = ServiceToResourceMapper.MapServices(builder, compose, resolvedPath, logger); + + return new ComposeResourceCollection(resolvedPath, resources); + } + + /// + /// Adds multiple Docker Compose files to the Aspire application model. + /// + /// The paths to the Docker Compose files. + /// An array of instances, one per file. + public ComposeResourceCollection[] AddCompose(params string[] composePaths) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(composePaths); + + return [.. composePaths.Select(builder.AddCompose)]; + } + } + + private static string ResolveComposePath(IDistributedApplicationBuilder builder, string composePath) => + Path.IsPathRooted(composePath) + ? composePath + : Path.GetFullPath(Path.Combine(builder.AppHostDirectory, composePath)); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeResourceCollection.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeResourceCollection.cs new file mode 100644 index 000000000..203fa4173 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/ComposeResourceCollection.cs @@ -0,0 +1,56 @@ +using System.Collections; +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Compose; + +/// +/// Represents a collection of Aspire resources created from a Docker Compose file. +/// Provides indexed access to resources by their compose service name. +/// +public sealed class ComposeResourceCollection : IEnumerable>> +{ + private readonly Dictionary> _resources; + + /// + /// Gets the path to the source compose file. + /// + public string ComposePath { get; } + + /// + /// Gets the names of all services in this compose file. + /// + public IReadOnlyCollection ServiceNames => _resources.Keys; + + /// + /// Gets the number of services in this compose file. + /// + public int Count => _resources.Count; + + internal ComposeResourceCollection(string composePath, Dictionary> resources) + { + ComposePath = composePath; + _resources = resources; + } + + /// + /// Gets the Aspire resource builder for the specified compose service name. + /// + /// The name of the service as defined in the compose file. + /// The for the service. + /// Thrown when the service name is not found. + public IResourceBuilder this[string serviceName] => + _resources.TryGetValue(serviceName, out IResourceBuilder? resource) + ? resource + : throw new KeyNotFoundException($"Service '{serviceName}' not found in compose file '{ComposePath}'. " + $"Available services: {string.Join(", ", _resources.Keys)}"); + + /// + /// Tries to get the resource builder for the specified service name. + /// + public bool TryGetResource(string serviceName, out IResourceBuilder? resource) => _resources.TryGetValue(serviceName, out resource); + + /// + public IEnumerator>> GetEnumerator() => _resources.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/CommandMapper.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/CommandMapper.cs new file mode 100644 index 000000000..a8fa8336f --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/CommandMapper.cs @@ -0,0 +1,44 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Mapping; + +/// +/// Maps compose command and entrypoint to Aspire args and entrypoint. +/// +internal static class CommandMapper +{ + public static void Map(IResourceBuilder resourceBuilder, ComposeService service) + { + MapCommand(resourceBuilder, service); + MapEntrypoint(resourceBuilder, service); + } + + private static void MapCommand(IResourceBuilder resourceBuilder, ComposeService service) + { + if (service.Command is not { } command) + return; + + string[] args = ServiceToResourceMapper.ParseStringOrList(command); + + if (args.Length > 0) + resourceBuilder.WithArgs(args); + } + + private static void MapEntrypoint(IResourceBuilder resourceBuilder, ComposeService service) + { + if (service.Entrypoint is not { } entrypoint) + return; + + string[] parts = ServiceToResourceMapper.ParseStringOrList(entrypoint); + + if (parts.Length == 0) + return; + + resourceBuilder.WithEntrypoint(parts[0]); + + if (parts.Length > 1) + resourceBuilder.WithArgs(parts[1..]); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/DependsOnMapper.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/DependsOnMapper.cs new file mode 100644 index 000000000..37af31018 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/DependsOnMapper.cs @@ -0,0 +1,57 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Mapping; + +/// +/// Maps compose depends_on to Aspire WaitFor/WaitForCompletion. +/// +internal static class DependsOnMapper +{ + public static void Apply(ComposeFile composeFile, Dictionary> resources) + { + foreach ((string serviceName, ComposeService service) in composeFile.Services) + { + if (service.DependsOn is not { } dependsOn || !resources.TryGetValue(serviceName, out IResourceBuilder? resource)) + continue; + + foreach ((string depName, string condition) in Parse(dependsOn)) + { + if (!resources.TryGetValue(depName, out IResourceBuilder? dependency)) + throw new InvalidOperationException($"Service '{serviceName}' depends on '{depName}', but '{depName}' is not defined in the compose file."); + + _ = condition switch + { + ComposeConstants.Condition.ServiceCompletedSuccessfully => resource.WaitForCompletion(dependency), + _ => resource.WaitFor(dependency) + }; + } + } + } + + internal static Dictionary Parse(object dependsOn) + { + Dictionary result = new(StringComparer.OrdinalIgnoreCase); + + switch (dependsOn) + { + case List list: + foreach (object item in list) + result[item.ToString()!] = ComposeConstants.Condition.ServiceStarted; + break; + + case Dictionary dict: + foreach ((object key, object value) in dict) + { + result[key.ToString()!] = value is Dictionary condDict + && condDict.TryGetValue(ComposeConstants.Condition.Key, out object? condObj) + ? condObj.ToString()! + : ComposeConstants.Condition.ServiceStarted; + } + break; + } + + return result; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/EnvironmentMapper.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/EnvironmentMapper.cs new file mode 100644 index 000000000..0928e6f2d --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/EnvironmentMapper.cs @@ -0,0 +1,44 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Mapping; + +/// +/// Maps compose environment variables to Aspire environment. +/// +internal static class EnvironmentMapper +{ + public static void Map(IResourceBuilder resourceBuilder, ComposeService service) + { + if (service.Environment is not { } env) + return; + + foreach ((string key, string value) in Parse(env)) + resourceBuilder.WithEnvironment(key, value); + } + + internal static Dictionary Parse(object environment) + { + Dictionary result = new(StringComparer.OrdinalIgnoreCase); + + switch (environment) + { + case Dictionary dict: + foreach (KeyValuePair kvp in dict) + result[kvp.Key.ToString()!] = kvp.Value?.ToString() ?? string.Empty; + break; + + case List list: + foreach (object item in list) + { + string str = item.ToString()!; + int eqIndex = str.IndexOf('='); + result[eqIndex > 0 ? str[..eqIndex] : str] = eqIndex > 0 ? str[(eqIndex + 1)..] : string.Empty; + } + break; + } + + return result; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/HealthcheckMapper.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/HealthcheckMapper.cs new file mode 100644 index 000000000..8ec482863 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/HealthcheckMapper.cs @@ -0,0 +1,70 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Mapping; + +/// +/// Maps compose healthcheck definitions to Aspire health checks. +/// +internal static class HealthcheckMapper +{ + public static void Map(IResourceBuilder resourceBuilder, ComposeService service, string serviceName, IDistributedApplicationBuilder builder) + { + if (service.Healthcheck is not { } healthcheck) + return; + + if (healthcheck.Disable == true) + return; + + string[] testCommand = healthcheck.Test switch + { + string str => ServiceToResourceMapper.ParseStringOrList(str), + List list => [.. list.Select(item => item.ToString()!)], + _ => [] + }; + + if (testCommand.Length == 0) + return; + + string healthCheckName = $"compose-{serviceName}"; + + bool isCmdShell = testCommand[0] is "CMD-SHELL"; + string command = isCmdShell + ? string.Join(" ", testCommand[1..]) + : string.Join(" ", testCommand.Where(t => t is not "CMD" and not "NONE")); + + if (string.IsNullOrEmpty(command)) + return; + + TimeSpan interval = ParseDuration(healthcheck.Interval) ?? TimeSpan.FromSeconds(30); + TimeSpan timeout = ParseDuration(healthcheck.Timeout) ?? TimeSpan.FromSeconds(30); + + builder.Services.AddHealthChecks().Add(new HealthCheckRegistration(healthCheckName, new ComposeHealthCheck(serviceName, command), failureStatus: HealthStatus.Unhealthy, tags: [$"compose:{serviceName}"]) + { + Period = interval, + Timeout = timeout + }); + + resourceBuilder.WithHealthCheck(healthCheckName); + } + + internal static TimeSpan? ParseDuration(string? duration) + { + if (string.IsNullOrEmpty(duration)) + return null; + + if (duration.EndsWith("ms") && double.TryParse(duration[..^2], out double ms)) + return TimeSpan.FromMilliseconds(ms); + + if (duration.EndsWith('s') && double.TryParse(duration[..^1], out double seconds)) + return TimeSpan.FromSeconds(seconds); + + if (duration.EndsWith('m') && double.TryParse(duration[..^1], out double minutes)) + return TimeSpan.FromMinutes(minutes); + + return null; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/PortMapper.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/PortMapper.cs new file mode 100644 index 000000000..b782fe894 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/PortMapper.cs @@ -0,0 +1,59 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Mapping; + +/// +/// Maps compose port definitions to Aspire endpoints. +/// +internal static class PortMapper +{ + public static void Map(IResourceBuilder resourceBuilder, ComposeService service, string serviceName, ILogger? logger) + { + if (service.Ports is not { } ports) + return; + + int endpointIndex = 0; + + foreach (string portMapping in ports) + { + if (ParsePortMapping(portMapping) is not { } parsed) + { + logger?.LogWarning("Could not parse port mapping '{PortMapping}'. Skipping.", portMapping); + continue; + } + + (int? hostPort, int containerPort, string protocol) = parsed; + string scheme = protocol == ComposeConstants.Protocol.Udp ? ComposeConstants.Protocol.Udp : containerPort is 443 or 8443 ? ComposeConstants.Protocol.Https : ComposeConstants.Protocol.Http; + string endpointName = $"{serviceName}-{protocol}-{containerPort}-{endpointIndex++}"; + resourceBuilder.WithEndpoint(port: hostPort, targetPort: containerPort, name: endpointName, scheme: scheme); + } + } + + internal static (int? HostPort, int ContainerPort, string Protocol)? ParsePortMapping(string mapping) + { + string protocol = ComposeConstants.Protocol.Tcp; + string portPart = mapping; + + if (mapping.IndexOf('/') is var slashIndex and >= 0) + { + protocol = mapping[(slashIndex + 1)..]; + portPart = mapping[..slashIndex]; + } + + string[] parts = portPart.Split(':'); + + return parts.Length switch + { + 1 when int.TryParse(parts[0], out int p) => (null, p, protocol), + 2 when int.TryParse(parts[0], out int hp) && int.TryParse(parts[1], out int cp) => (hp, cp, protocol), + 3 when int.TryParse(parts[2], out int cp) => (ParseOptionalInt(parts[1]), cp, protocol), + _ => null + }; + } + + private static int? ParseOptionalInt(string value) => + string.IsNullOrEmpty(value) ? null : int.TryParse(value, out int result) ? result : null; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/ServiceToResourceMapper.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/ServiceToResourceMapper.cs new file mode 100644 index 000000000..ed4197441 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/ServiceToResourceMapper.cs @@ -0,0 +1,73 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Mapping; + +/// +/// Maps Docker Compose services to Aspire container resources. +/// +internal static class ServiceToResourceMapper +{ + /// + /// Maps all services from a parsed compose file to Aspire resources. + /// + public static Dictionary> MapServices(IDistributedApplicationBuilder builder, ComposeFile composeFile, string composePath, ILogger? logger = null) + { + Dictionary> resources = new(StringComparer.OrdinalIgnoreCase); + string composeDir = Path.GetDirectoryName(Path.GetFullPath(composePath)) ?? "."; + + foreach ((string serviceName, ComposeService service) in composeFile.Services) + { + if (service.Image is null && service.Build is null) + { + logger?.LogWarning("Compose service '{ServiceName}' has no image or build configuration. Skipping.", serviceName); + continue; + } + + resources[serviceName] = MapService(builder, serviceName, service, composeDir, logger); + } + + DependsOnMapper.Apply(composeFile, resources); + + return resources; + } + + private static IResourceBuilder MapService(IDistributedApplicationBuilder builder, string serviceName, ComposeService service, string composeDir, ILogger? logger) + { + (string image, string tag) = ParseImageReference(service.Image ?? string.Empty); + IResourceBuilder resourceBuilder = builder.AddContainer(serviceName, image, tag); + + PortMapper.Map(resourceBuilder, service, serviceName, logger); + EnvironmentMapper.Map(resourceBuilder, service); + VolumeMapper.Map(resourceBuilder, service, composeDir); + CommandMapper.Map(resourceBuilder, service); + HealthcheckMapper.Map(resourceBuilder, service, serviceName, builder); + + if (service.ContainerName is { } containerName) + resourceBuilder.WithAnnotation(new ContainerNameAnnotation { Name = containerName }); + + return resourceBuilder; + } + + internal static (string Image, string Tag) ParseImageReference(string imageRef) + { + if (string.IsNullOrEmpty(imageRef)) + return (ComposeConstants.Defaults.ScratchImage, ComposeConstants.Defaults.LatestTag); + + int lastColon = imageRef.LastIndexOf(':'); + + return lastColon > 0 && !imageRef[(lastColon + 1)..].Contains('/') + ? (imageRef[..lastColon], imageRef[(lastColon + 1)..]) + : (imageRef, ComposeConstants.Defaults.LatestTag); + } + + internal static string[] ParseStringOrList(object value) => + value switch + { + string str => str.Split(' ', StringSplitOptions.RemoveEmptyEntries), + List list => [.. list.Select(item => item.ToString()!)], + _ => [] + }; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/VolumeMapper.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/VolumeMapper.cs new file mode 100644 index 000000000..f5e12b045 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Mapping/VolumeMapper.cs @@ -0,0 +1,49 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Mapping; + +/// +/// Maps compose volume definitions to Aspire volumes and bind mounts. +/// +internal static class VolumeMapper +{ + public static void Map(IResourceBuilder resourceBuilder, ComposeService service, string composeDir) + { + if (service.Volumes is not { } volumes) + return; + + foreach (string volumeMapping in volumes) + { + string[] parts = volumeMapping.Split(':'); + + if (parts.Length < 2) + { + resourceBuilder.WithVolume(volumeMapping); + continue; + } + + string source = parts[0]; + string target = parts[1]; + bool isReadOnly = parts.Length > 2 && parts[2] == ComposeConstants.Volume.ReadOnly; + bool isPath = source.Length > 0 && source[0] switch + { + '.' or '/' => true, + _ => source.Contains(Path.DirectorySeparatorChar) || source.Contains(Path.AltDirectorySeparatorChar) + }; + + if (isPath) + { + string absoluteSource = Path.IsPathRooted(source) + ? source + : Path.GetFullPath(Path.Combine(composeDir, source)); + resourceBuilder.WithBindMount(absoluteSource, target, isReadOnly); + } + else + { + resourceBuilder.WithVolume(source, target, isReadOnly); + } + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/ComposeParser.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/ComposeParser.cs new file mode 100644 index 000000000..65b6f6d96 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/ComposeParser.cs @@ -0,0 +1,147 @@ +using CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Parsing; + +/// +/// Parses Docker Compose YAML files into models. +/// Supports all compose format versions: v1 (legacy), v2.x, v3.x, and modern Compose Spec. +/// +internal static class ComposeParser +{ + private static readonly HashSet KnownTopLevelKeys = new(StringComparer.OrdinalIgnoreCase) + { + ComposeConstants.TopLevel.Version, ComposeConstants.TopLevel.Services, ComposeConstants.TopLevel.Volumes, ComposeConstants.TopLevel.Networks, + ComposeConstants.TopLevel.Configs, ComposeConstants.TopLevel.Secrets, ComposeConstants.TopLevel.Extensions, ComposeConstants.TopLevel.Name + }; + + private static readonly IDeserializer Deserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + /// + /// Parses a compose file from the specified path. + /// + /// The absolute or relative path to the compose YAML file. + /// The parsed . + /// Thrown when the compose file does not exist. + /// Thrown when the YAML content is invalid. + public static ComposeFile Parse(string filePath) + { + ArgumentException.ThrowIfNullOrEmpty(filePath); + string fullPath = Path.GetFullPath(filePath); + + if (!File.Exists(fullPath)) + throw new FileNotFoundException($"Compose file not found: '{fullPath}'. Ensure the file exists and the path is correct.", fullPath); + + string yaml = File.ReadAllText(fullPath); + + return ParseYaml(yaml, fullPath); + } + + /// + /// Parses compose YAML content from a string. + /// + /// The YAML content to parse. + /// Optional source path for error messages. + /// The parsed . + /// Thrown when the YAML content is invalid. + internal static ComposeFile ParseYaml(string yamlContent, string? sourcePath = null) + { + ArgumentException.ThrowIfNullOrEmpty(yamlContent); + + try + { + Dictionary? rawDict = Deserializer.Deserialize>(yamlContent); + + if (rawDict is null || rawDict.Count == 0) + throw new ComposeParseException("Compose file is empty or contains no valid YAML content.", sourcePath); + + if (IsV1Format(rawDict)) + return ParseV1Format(rawDict, sourcePath); + + ComposeFile model = Deserializer.Deserialize(yamlContent); + + if (model.Services is null || model.Services.Count == 0) + throw new ComposeParseException("Compose file does not contain any services.", sourcePath); + + return model; + } + catch (YamlException ex) + { + throw new ComposeParseException($"Invalid YAML in compose file{(sourcePath is not null ? $" '{sourcePath}'" : string.Empty)}: {ex.Message}", sourcePath, ex); + } + } + + private static bool IsV1Format(Dictionary rawDict) + { + if (rawDict.ContainsKey(ComposeConstants.TopLevel.Services)) return false; + + foreach ((string key, object? value) in rawDict) + { + if (KnownTopLevelKeys.Contains(key)) + continue; + + if (value is Dictionary) + return true; + } + + return false; + } + + private static ComposeFile ParseV1Format(Dictionary rawDict, string? sourcePath) + { + Dictionary servicesYaml = new(); + string? version = null; + Dictionary? volumes = null; + Dictionary? networks = null; + + foreach ((string key, object? value) in rawDict) + { + switch (key.ToLowerInvariant()) + { + case ComposeConstants.TopLevel.Version: + version = value?.ToString(); + break; + case ComposeConstants.TopLevel.Volumes: + volumes = value as Dictionary; + break; + case ComposeConstants.TopLevel.Networks: + networks = value as Dictionary; + break; + default: + if (!KnownTopLevelKeys.Contains(key)) + servicesYaml[key] = value; + break; + } + } + + if (servicesYaml.Count == 0) + throw new ComposeParseException("Compose file (v1 format) does not contain any services.", sourcePath); + + Dictionary wrappedDict = new() { [ComposeConstants.TopLevel.Services] = servicesYaml }; + + if (version is not null) + wrappedDict[ComposeConstants.TopLevel.Version] = version; + + if (volumes is not null) + wrappedDict[ComposeConstants.TopLevel.Volumes] = volumes; + + if (networks is not null) + wrappedDict[ComposeConstants.TopLevel.Networks] = networks; + + ISerializer serializer = new SerializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); + string wrappedYaml = serializer.Serialize(wrappedDict); + ComposeFile model = Deserializer.Deserialize(wrappedYaml); + + if (model.Services is null || model.Services.Count == 0) + throw new ComposeParseException("Compose file (v1 format) does not contain any valid services.", sourcePath); + + return model; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeFile.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeFile.cs new file mode 100644 index 000000000..dc4c2dff3 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeFile.cs @@ -0,0 +1,34 @@ +using YamlDotNet.Serialization; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; + +/// +/// Represents a parsed Docker Compose file. +/// Supports all compose format versions: v1 (legacy), v2.x, v3.x, and modern Compose Spec. +/// +internal sealed class ComposeFile +{ + /// + /// Gets or sets the compose file version (v2/v3). Optional in modern Compose Spec format. + /// + [YamlMember(Alias = ComposeConstants.TopLevel.Version)] + public string? Version { get; set; } + + /// + /// Gets or sets the services defined in the compose file. + /// + [YamlMember(Alias = ComposeConstants.TopLevel.Services)] + public Dictionary Services { get; set; } = []; + + /// + /// Gets or sets the named volumes defined in the compose file. + /// + [YamlMember(Alias = ComposeConstants.TopLevel.Volumes)] + public Dictionary? Volumes { get; set; } + + /// + /// Gets or sets the networks defined in the compose file. + /// + [YamlMember(Alias = ComposeConstants.TopLevel.Networks)] + public Dictionary? Networks { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeHealthcheck.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeHealthcheck.cs new file mode 100644 index 000000000..c88848e84 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeHealthcheck.cs @@ -0,0 +1,45 @@ +using YamlDotNet.Serialization; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; + +/// +/// Represents a healthcheck configuration in a Docker Compose service. +/// +internal sealed class ComposeHealthcheck +{ + /// + /// Gets or sets the test command. + /// + [YamlMember(Alias = ComposeConstants.Health.Test)] + public object? Test { get; set; } + + /// + /// Gets or sets the interval between health checks. + /// + [YamlMember(Alias = ComposeConstants.Health.Interval)] + public string? Interval { get; set; } + + /// + /// Gets or sets the timeout for each check. + /// + [YamlMember(Alias = ComposeConstants.Health.Timeout)] + public string? Timeout { get; set; } + + /// + /// Gets or sets the number of retries. + /// + [YamlMember(Alias = ComposeConstants.Health.Retries)] + public int? Retries { get; set; } + + /// + /// Gets or sets the start period. + /// + [YamlMember(Alias = ComposeConstants.Health.StartPeriod)] + public string? StartPeriod { get; set; } + + /// + /// Gets or sets whether the healthcheck is disabled. + /// + [YamlMember(Alias = ComposeConstants.Health.Disable)] + public bool? Disable { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeNetwork.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeNetwork.cs new file mode 100644 index 000000000..ef793e2fa --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeNetwork.cs @@ -0,0 +1,21 @@ +using YamlDotNet.Serialization; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; + +/// +/// Represents a network definition in a Docker Compose file. +/// +internal sealed class ComposeNetwork +{ + /// + /// Gets or sets the network driver. + /// + [YamlMember(Alias = ComposeConstants.Resource.Driver)] + public string? Driver { get; set; } + + /// + /// Gets or sets whether this is an external network. + /// + [YamlMember(Alias = ComposeConstants.Resource.External)] + public bool? External { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeService.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeService.cs new file mode 100644 index 000000000..0073b3d3b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeService.cs @@ -0,0 +1,81 @@ +using YamlDotNet.Serialization; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; + +/// +/// Represents a service defined in a Docker Compose file. +/// +internal sealed class ComposeService +{ + /// + /// Gets or sets the container image. + /// + [YamlMember(Alias = ComposeConstants.Service.Image)] + public string? Image { get; set; } + + /// + /// Gets or sets the container name override. + /// + [YamlMember(Alias = ComposeConstants.Service.ContainerName)] + public string? ContainerName { get; set; } + + /// + /// Gets or sets the hostname. + /// + [YamlMember(Alias = ComposeConstants.Service.Hostname)] + public string? Hostname { get; set; } + + /// + /// Gets or sets the port mappings. + /// + [YamlMember(Alias = ComposeConstants.Service.Ports)] + public List? Ports { get; set; } + + /// + /// Gets or sets the environment variables. Supports both map and list syntax. + /// + [YamlMember(Alias = ComposeConstants.Service.Environment)] + public object? Environment { get; set; } + + /// + /// Gets or sets the volume mounts. + /// + [YamlMember(Alias = ComposeConstants.TopLevel.Volumes)] + public List? Volumes { get; set; } + + /// + /// Gets or sets the service dependencies. + /// + [YamlMember(Alias = ComposeConstants.Service.DependsOn)] + public object? DependsOn { get; set; } + + /// + /// Gets or sets the command to run. Can be a string or list. + /// + [YamlMember(Alias = ComposeConstants.Service.Command)] + public object? Command { get; set; } + + /// + /// Gets or sets the entrypoint. Can be a string or list. + /// + [YamlMember(Alias = ComposeConstants.Service.Entrypoint)] + public object? Entrypoint { get; set; } + + /// + /// Gets or sets the healthcheck configuration. + /// + [YamlMember(Alias = ComposeConstants.Service.Healthcheck)] + public ComposeHealthcheck? Healthcheck { get; set; } + + /// + /// Gets or sets the restart policy. + /// + [YamlMember(Alias = ComposeConstants.Service.Restart)] + public string? Restart { get; set; } + + /// + /// Gets or sets the build configuration. + /// + [YamlMember(Alias = ComposeConstants.Service.Build)] + public object? Build { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeVolume.cs b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeVolume.cs new file mode 100644 index 000000000..a4464d10b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/Parsing/Contracts/ComposeVolume.cs @@ -0,0 +1,21 @@ +using YamlDotNet.Serialization; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; + +/// +/// Represents a named volume definition in a Docker Compose file. +/// +internal sealed class ComposeVolume +{ + /// + /// Gets or sets the volume driver. + /// + [YamlMember(Alias = ComposeConstants.Resource.Driver)] + public string? Driver { get; set; } + + /// + /// Gets or sets whether this is an external volume. + /// + [YamlMember(Alias = ComposeConstants.Resource.External)] + public bool? External { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/README.md b/src/CommunityToolkit.Aspire.Hosting.Compose/README.md new file mode 100644 index 000000000..5e483ccf8 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/README.md @@ -0,0 +1,62 @@ +# CommunityToolkit.Aspire.Hosting.Compose + +An Aspire hosting integration that imports existing Docker Compose files into the Aspire resource graph. Compose services become first-class citizens with full support for `WaitFor`, dependency graph, and dashboard visibility. + +## Getting Started + +### Install the NuGet package + +```bash +dotnet add package CommunityToolkit.Aspire.Hosting.Compose +``` + +### Runtime usage (string-based) + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var infra = builder.AddCompose(".infra/compose.yml"); + +builder.AddProject("api") + .WaitFor(infra["postgres"]) + .WaitFor(infra["redis"]); + +builder.Build().Run(); +``` + +### Typed usage with source generator + +Register compose files in your AppHost `.csproj`: + +```xml + + + + +``` + +Then use generated types with IntelliSense: + +```csharp +var infra = builder.AddCompose(); +var mon = builder.AddCompose(); + +builder.AddProject("api") + .WaitFor(infra[Compose.Infra.Postgres]) + .WaitFor(mon[Compose.Monitoring.Grafana]); +``` + +### Multiple compose files + +```csharp +var infra = builder.AddCompose(".infra/compose.yml"); +var monitoring = builder.AddCompose(".infra/compose.monitoring.yml"); +``` + +### Supported compose versions + +All Docker Compose file formats are supported: v1 (legacy), v2.x, v3.x, and modern Compose Spec. + +## Additional Information + +- [Aspire Community Toolkit GitHub Repository](https://github.com/CommunityToolkit/Aspire) diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/build/CommunityToolkit.Aspire.Hosting.Compose.props b/src/CommunityToolkit.Aspire.Hosting.Compose/build/CommunityToolkit.Aspire.Hosting.Compose.props new file mode 100644 index 000000000..8f33183b5 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/build/CommunityToolkit.Aspire.Hosting.Compose.props @@ -0,0 +1,5 @@ + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Compose/build/CommunityToolkit.Aspire.Hosting.Compose.targets b/src/CommunityToolkit.Aspire.Hosting.Compose/build/CommunityToolkit.Aspire.Hosting.Compose.targets new file mode 100644 index 000000000..76b794485 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Compose/build/CommunityToolkit.Aspire.Hosting.Compose.targets @@ -0,0 +1,8 @@ + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/CommunityToolkit.Aspire.Hosting.Compose.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/CommunityToolkit.Aspire.Hosting.Compose.Tests.csproj new file mode 100644 index 000000000..978d8e35d --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/CommunityToolkit.Aspire.Hosting.Compose.Tests.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/ComposeVersionTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/ComposeVersionTests.cs new file mode 100644 index 000000000..0ac7baff9 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/ComposeVersionTests.cs @@ -0,0 +1,221 @@ +using CommunityToolkit.Aspire.Hosting.Compose.Parsing; +using Aspire.Hosting; +using CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Tests; + +/// +/// Tests for all Docker Compose file format versions: +/// v1 (legacy, services at top level), v2.x, v3.x, and modern Compose Spec (no version field). +/// +public class ComposeVersionTests +{ + + [Fact] + public void Parse_V1Format_DetectsServicesAtTopLevel() + { + string composePath = GetTestFilePath("v1-format.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.Equal(2, composeFile.Services.Count); + Assert.Contains("postgres", composeFile.Services.Keys); + Assert.Contains("redis", composeFile.Services.Keys); + } + + [Fact] + public void Parse_V1Format_ParsesImageCorrectly() + { + string composePath = GetTestFilePath("v1-format.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.Equal("postgres:14", composeFile.Services["postgres"].Image); + Assert.Equal("redis:6", composeFile.Services["redis"].Image); + } + + [Fact] + public void Parse_V1Format_ParsesPortsAndEnvironment() + { + string composePath = GetTestFilePath("v1-format.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile.Services["postgres"].Ports); + Assert.Single(composeFile.Services["postgres"].Ports!); + Assert.NotNull(composeFile.Services["postgres"].Environment); + } + + [Fact] + public void AddCompose_V1Format_CreatesResources() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("v1-format.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Equal(2, compose.Count); + Assert.Equal("postgres", compose["postgres"].Resource.Name); + Assert.Equal("redis", compose["redis"].Resource.Name); + } + + + [Fact] + public void Parse_V2Format_ParsesVersionField() + { + string composePath = GetTestFilePath("v2-format.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.Equal("2.4", composeFile.Version); + } + + [Fact] + public void Parse_V2Format_ParsesServices() + { + string composePath = GetTestFilePath("v2-format.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.Equal(2, composeFile.Services.Count); + Assert.Contains("postgres", composeFile.Services.Keys); + Assert.Contains("redis", composeFile.Services.Keys); + } + + [Fact] + public void Parse_V2Format_ParsesVolumes() + { + string composePath = GetTestFilePath("v2-format.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile.Volumes); + Assert.Contains("pgdata", composeFile.Volumes.Keys); + } + + [Fact] + public void AddCompose_V2Format_CreatesResources() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("v2-format.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Equal(2, compose.Count); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + ContainerResource postgres = appModel.Resources.OfType().Single(r => r.Name == "postgres"); + Assert.NotNull(postgres); + } + + + [Fact] + public void Parse_V3Format_ParsesVersionField() + { + string composePath = GetTestFilePath("v3-format.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.Equal("3.8", composeFile.Version); + } + + [Fact] + public void Parse_V3Format_ParsesAllServices() + { + string composePath = GetTestFilePath("v3-format.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.Equal(3, composeFile.Services.Count); + Assert.Contains("web", composeFile.Services.Keys); + Assert.Contains("api", composeFile.Services.Keys); + Assert.Contains("db", composeFile.Services.Keys); + } + + [Fact] + public void Parse_V3Format_ParsesDependsOnWithConditions() + { + string composePath = GetTestFilePath("v3-format.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile.Services["api"].DependsOn); + Assert.NotNull(composeFile.Services["db"].Healthcheck); + } + + [Fact] + public void AddCompose_V3Format_CreatesResourcesWithDependencies() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("v3-format.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Equal(3, compose.Count); + Assert.Equal("web", compose["web"].Resource.Name); + Assert.Equal("api", compose["api"].Resource.Name); + Assert.Equal("db", compose["db"].Resource.Name); + } + + + [Fact] + public void Parse_ModernFormat_NoVersionField() + { + string composePath = GetTestFilePath("modern-no-version.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.Null(composeFile.Version); + } + + [Fact] + public void Parse_ModernFormat_ParsesAllServices() + { + string composePath = GetTestFilePath("modern-no-version.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.Equal(3, composeFile.Services.Count); + Assert.Contains("app", composeFile.Services.Keys); + Assert.Contains("db", composeFile.Services.Keys); + Assert.Contains("cache", composeFile.Services.Keys); + } + + [Fact] + public void Parse_ModernFormat_ParsesHealthcheckStartPeriod() + { + string composePath = GetTestFilePath("modern-no-version.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile.Services["db"].Healthcheck); + Assert.Equal("10s", composeFile.Services["db"].Healthcheck!.StartPeriod); + Assert.Equal(10, composeFile.Services["db"].Healthcheck!.Retries); + } + + [Fact] + public void AddCompose_ModernFormat_CreatesResources() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("modern-no-version.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Equal(3, compose.Count); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + Assert.Equal(3, appModel.Resources.OfType().Count()); + } + + + [Fact] + public void AllFormats_ProduceValidAspireResources() + { + string[] files = ["v1-format.yml", "v2-format.yml", "v3-format.yml", "modern-no-version.yml"]; + + foreach (string file in files) + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath(file); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.True(compose.Count > 0, $"File '{file}' should produce at least one resource"); + + Assert.True(compose.TryGetResource("postgres", out _) || compose.TryGetResource("db", out _),$"File '{file}' should contain a postgres/db service"); + } + } + + private static string GetTestFilePath(string fileName) => + Path.Combine(AppContext.BaseDirectory, "composes", fileName); +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/HealthcheckTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/HealthcheckTests.cs new file mode 100644 index 000000000..a91a8672a --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/HealthcheckTests.cs @@ -0,0 +1,87 @@ +using CommunityToolkit.Aspire.Hosting.Compose.Mapping; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Tests; + +public class HealthcheckTests +{ + [Fact] + public void AddCompose_WithHealthcheck_AddsHealthCheckAnnotation() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("healthcheck.yml"); + + builder.AddCompose(composePath); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + ContainerResource db = appModel.Resources.OfType().Single(r => r.Name == "db"); + List healthChecks = db.Annotations.OfType().ToList(); + + Assert.Single(healthChecks); + Assert.Equal("compose-db", healthChecks[0].Key); + } + + [Fact] + public void AddCompose_WithCmdHealthcheck_AddsHealthCheckAnnotation() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("healthcheck.yml"); + + builder.AddCompose(composePath); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + ContainerResource cache = appModel.Resources.OfType().Single(r => r.Name == "cache"); + List healthChecks = cache.Annotations.OfType().ToList(); + + Assert.Single(healthChecks); + Assert.Equal("compose-cache", healthChecks[0].Key); + } + + [Fact] + public void AddCompose_WithDisabledHealthcheck_NoHealthCheckAnnotation() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("healthcheck.yml"); + + builder.AddCompose(composePath); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + ContainerResource worker = appModel.Resources.OfType().Single(r => r.Name == "worker"); + List healthChecks = worker.Annotations.OfType().ToList(); + + Assert.Empty(healthChecks); + } + + [Theory] + [InlineData("10s", 10)] + [InlineData("5s", 5)] + [InlineData("30s", 30)] + [InlineData("1.5s", 1.5)] + [InlineData("2m", 120)] + [InlineData("500ms", 0.5)] + public void ParseDuration_ValidFormats_ReturnsCorrectTimeSpan(string input, double expectedSeconds) + { + TimeSpan? result = HealthcheckMapper.ParseDuration(input); + + Assert.NotNull(result); + Assert.Equal(expectedSeconds, result.Value.TotalSeconds, precision: 1); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("invalid")] + public void ParseDuration_InvalidFormats_ReturnsNull(string? input) + { + TimeSpan? result = HealthcheckMapper.ParseDuration(input); + Assert.Null(result); + } + + private static string GetTestFilePath(string fileName) => Path.Combine(AppContext.BaseDirectory, "composes", fileName); +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/IntegrationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/IntegrationTests.cs new file mode 100644 index 000000000..6ffac4798 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/IntegrationTests.cs @@ -0,0 +1,176 @@ +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Tests; + +public class IntegrationTests +{ + [Fact] + public void AddCompose_BasicFile_CreatesResources() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("basic.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Equal(2, compose.Count); + Assert.Contains("postgres", compose.ServiceNames); + Assert.Contains("redis", compose.ServiceNames); + } + + [Fact] + public void AddCompose_BasicFile_ResourcesAreInAppModel() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("basic.yml"); + + builder.AddCompose(composePath); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + ContainerResource? postgres = appModel.Resources.OfType().SingleOrDefault(r => r.Name == "postgres"); + ContainerResource? redis = appModel.Resources.OfType().SingleOrDefault(r => r.Name == "redis"); + + Assert.NotNull(postgres); + Assert.NotNull(redis); + } + + [Fact] + public void AddCompose_IndexerReturnsResourceBuilder() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("basic.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + IResourceBuilder postgres = compose["postgres"]; + Assert.NotNull(postgres); + Assert.Equal("postgres", postgres.Resource.Name); + } + + [Fact] + public void AddCompose_IndexerThrowsForUnknownService() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("basic.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Throws(() => compose["nonexistent"]); + } + + [Fact] + public void AddCompose_CanUseWaitFor() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("basic.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + IResourceBuilder apiBuilder = builder.AddContainer("api", "myapi", "latest").WaitFor(compose["postgres"]); + + Assert.NotNull(apiBuilder); + } + + [Fact] + public void AddCompose_DependsOnConditions_AppliesWaitFor() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("depends-on-conditions.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Equal(3, compose.Count); + Assert.Contains("db", compose.ServiceNames); + Assert.Contains("migrations", compose.ServiceNames); + Assert.Contains("api", compose.ServiceNames); + } + + [Fact] + public void AddCompose_EnvironmentList_SetsEnvironment() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("environment-list.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Single(compose); + Assert.Contains("app", compose.ServiceNames); + } + + [Fact] + public void AddCompose_MultiplePorts_CreatesEndpoints() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("multiple-ports.yml"); + builder.AddCompose(composePath); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + ContainerResource web = appModel.Resources.OfType().Single(r => r.Name == "web"); + + List endpoints = web.Annotations.OfType().ToList(); + Assert.True(endpoints.Count >= 3, $"Expected at least 3 endpoints, got {endpoints.Count}"); + } + + [Fact] + public void AddCompose_MissingFile_ThrowsFileNotFound() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddCompose("nonexistent.yml")); + } + + [Fact] + public void AddCompose_TryGetResource_ReturnsTrue() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("basic.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.True(compose.TryGetResource("postgres", out IResourceBuilder? resource)); + Assert.NotNull(resource); + } + + [Fact] + public void AddCompose_TryGetResource_ReturnsFalseForUnknown() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("basic.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.False(compose.TryGetResource("nonexistent", out _)); + } + + [Fact] + public void AddCompose_MultipleFiles_ReturnsArrayOfCollections() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string basicPath = GetTestFilePath("basic.yml"); + string portsPath = GetTestFilePath("multiple-ports.yml"); + + ComposeResourceCollection[] collections = builder.AddCompose(basicPath, portsPath); + + Assert.Equal(2, collections.Length); + Assert.Contains("postgres", collections[0].ServiceNames); + Assert.Contains("web", collections[1].ServiceNames); + } + + [Fact] + public void AddCompose_EntrypointAndCommand_SetsArgs() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("entrypoint-and-command.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Equal(2, compose.Count); + Assert.Contains("worker-string", compose.ServiceNames); + Assert.Contains("worker-list", compose.ServiceNames); + } + + private static string GetTestFilePath(string fileName) => Path.Combine(AppContext.BaseDirectory, "composes", fileName); +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/MapperAdvancedTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/MapperAdvancedTests.cs new file mode 100644 index 000000000..0919bb5f0 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/MapperAdvancedTests.cs @@ -0,0 +1,238 @@ +using CommunityToolkit.Aspire.Hosting.Compose.Mapping; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Tests; + +public class MapperAdvancedTests +{ + [Fact] + public void ParseImageReference_EmptyImage_ReturnsScratch() + { + (string image, string tag) = ServiceToResourceMapper.ParseImageReference(string.Empty); + + Assert.Equal("scratch", image); + Assert.Equal("latest", tag); + } + + [Fact] + public void ParseImageReference_ImageWithoutTag_ReturnsLatest() + { + (string image, string tag) = ServiceToResourceMapper.ParseImageReference("nginx"); + + Assert.Equal("nginx", image); + Assert.Equal("latest", tag); + } + + [Fact] + public void ParseImageReference_ImageWithTag_SplitsCorrectly() + { + (string image, string tag) = ServiceToResourceMapper.ParseImageReference("postgres:16"); + + Assert.Equal("postgres", image); + Assert.Equal("16", tag); + } + + [Fact] + public void ParseImageReference_RegistryWithPort_NoTag_ReturnsLatest() + { + (string image, string tag) = ServiceToResourceMapper.ParseImageReference("registry.example.com:5000/myapp"); + + Assert.Equal("registry.example.com:5000/myapp", image); + Assert.Equal("latest", tag); + } + + [Fact] + public void ParseImageReference_RegistryWithPort_WithTag_SplitsCorrectly() + { + (string image, string tag) = ServiceToResourceMapper.ParseImageReference("registry.example.com:5000/myapp:v2"); + + Assert.Equal("registry.example.com:5000/myapp", image); + Assert.Equal("v2", tag); + } + + [Fact] + public void ParsePortMapping_IpBoundWithoutHostPort_ReturnsNullHostPort() + { + (int? hostPort, int containerPort, string protocol)? result = PortMapper.ParsePortMapping("127.0.0.1::9090"); + + Assert.NotNull(result); + Assert.Null(result.Value.hostPort); + Assert.Equal(9090, result.Value.containerPort); + } + + [Fact] + public void ParsePortMapping_IpBoundWithHostPort_ReturnsBoth() + { + (int? hostPort, int containerPort, string protocol)? result = PortMapper.ParsePortMapping("127.0.0.1:8080:80"); + + Assert.NotNull(result); + Assert.Equal(8080, result.Value.hostPort); + Assert.Equal(80, result.Value.containerPort); + } + + [Fact] + public void ParsePortMapping_UdpProtocol_ReturnsUdp() + { + (int? hostPort, int containerPort, string protocol)? result = PortMapper.ParsePortMapping("53:53/udp"); + + Assert.NotNull(result); + Assert.Equal("udp", result.Value.protocol); + } + + [Fact] + public void ParsePortMapping_InvalidPort_ReturnsNull() + { + (int? hostPort, int containerPort, string protocol)? result = PortMapper.ParsePortMapping("invalid:port"); + Assert.Null(result); + } + + [Fact] + public void AddCompose_ContainerName_SetsAnnotation() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("container-name.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + ContainerResource db = appModel.Resources.OfType().Single(r => r.Name == "db"); + ContainerNameAnnotation? annotation = db.Annotations.OfType().SingleOrDefault(); + + Assert.NotNull(annotation); + Assert.Equal("my-custom-postgres", annotation.Name); + } + + [Fact] + public void AddCompose_UdpPorts_CreatesUdpEndpoint() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("udp-ports.yml"); + + builder.AddCompose(composePath); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + ContainerResource dns = appModel.Resources.OfType().Single(r => r.Name == "dns"); + List endpoints = dns.Annotations.OfType().ToList(); + + Assert.True(endpoints.Any(e => e.UriScheme == "udp"), "Should have a UDP endpoint"); + } + + [Fact] + public void AddCompose_VolumesAdvanced_CreatesBindMountsAndNamedVolumes() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("volumes-advanced.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Equal(1, compose.Count); + Assert.Contains("app", compose.ServiceNames); + } + + [Fact] + public void AddCompose_IpBoundWithoutHostPort_DoesNotThrow() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("ip-bound-ports.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Equal(1, compose.Count); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + ContainerResource web = appModel.Resources.OfType().Single(r => r.Name == "web"); + List endpoints = web.Annotations.OfType().ToList(); + + Assert.Equal(2, endpoints.Count); + } + + [Fact] + public void AddCompose_RegistryWithPort_ParsesImageCorrectly() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("registry-port-image.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.Equal(2, compose.Count); + Assert.Contains("app", compose.ServiceNames); + Assert.Contains("tagged", compose.ServiceNames); + } + + [Fact] + public void AddCompose_MissingDependency_ThrowsInvalidOperation() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("missing-dependency.yml"); + + Assert.Throws(() => builder.AddCompose(composePath)); + } + + [Fact] + public void AddCompose_DuplicatePortEndpoints_UniqueNames() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("multiple-ports.yml"); + + builder.AddCompose(composePath); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + ContainerResource web = appModel.Resources.OfType().Single(r => r.Name == "web"); + List endpoints = web.Annotations.OfType().ToList(); + List names = endpoints.Select(e => e.Name).ToList(); + + Assert.Equal(names.Count, names.Distinct().Count()); + } + + [Fact] + public void ParseEnvironment_UnknownType_ReturnsEmpty() + { + Dictionary result = EnvironmentMapper.Parse(42); + Assert.Empty(result); + } + + [Fact] + public void ParseDependsOn_UnknownType_ReturnsEmpty() + { + Dictionary result = DependsOnMapper.Parse(42); + Assert.Empty(result); + } + + [Fact] + public void ParseDependsOn_DictWithoutConditionKey_DefaultsToServiceStarted() + { + Dictionary deps = new() + { + { "db", new Dictionary { { "restart", true } } } + }; + + Dictionary result = DependsOnMapper.Parse(deps); + + Assert.Equal("service_started", result["db"]); + } + + [Fact] + public void ParseStringOrList_UnknownType_ReturnsEmpty() + { + string[] result = ServiceToResourceMapper.ParseStringOrList(42); + Assert.Empty(result); + } + + [Fact] + public void AddCompose_V1CommandOnly_DetectsAsV1() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + string composePath = GetTestFilePath("v1-command-only.yml"); + + ComposeResourceCollection compose = builder.AddCompose(composePath); + + Assert.True(compose.Count >= 1); + Assert.Contains("redis", compose.ServiceNames); + } + + private static string GetTestFilePath(string fileName) => Path.Combine(AppContext.BaseDirectory, "composes", fileName); +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/MapperTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/MapperTests.cs new file mode 100644 index 000000000..494d727ab --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/MapperTests.cs @@ -0,0 +1,99 @@ +using CommunityToolkit.Aspire.Hosting.Compose.Mapping; +namespace CommunityToolkit.Aspire.Hosting.Compose.Tests; + +public class MapperTests +{ + [Fact] + public void ParseEnvironment_DictionaryFormat_ReturnsPairs() + { + Dictionary env = new() + { + { "KEY1", "value1" }, + { "KEY2", "value2" } + }; + + Dictionary result = EnvironmentMapper.Parse(env); + + Assert.Equal(2, result.Count); + Assert.Equal("value1", result["KEY1"]); + Assert.Equal("value2", result["KEY2"]); + } + + [Fact] + public void ParseEnvironment_ListFormat_ReturnsPairs() + { + List env = + [ + "NODE_ENV=production", + "PORT=3000", + "DEBUG=" + ]; + + Dictionary result = EnvironmentMapper.Parse(env); + + Assert.Equal(3, result.Count); + Assert.Equal("production", result["NODE_ENV"]); + Assert.Equal("3000", result["PORT"]); + Assert.Equal("", result["DEBUG"]); + } + + [Fact] + public void ParseDependsOn_ListFormat_ReturnsServiceStarted() + { + List deps = ["postgres", "redis"]; + + Dictionary result = DependsOnMapper.Parse(deps); + + Assert.Equal(2, result.Count); + Assert.Equal("service_started", result["postgres"]); + Assert.Equal("service_started", result["redis"]); + } + + [Fact] + public void ParseDependsOn_DictionaryFormat_ReturnsConditions() + { + Dictionary deps = new() + { + { + "db", new Dictionary + { + { "condition", "service_healthy" } + } + }, + { + "migrations", new Dictionary + { + { "condition", "service_completed_successfully" } + } + } + }; + + Dictionary result = DependsOnMapper.Parse(deps); + + Assert.Equal(2, result.Count); + Assert.Equal("service_healthy", result["db"]); + Assert.Equal("service_completed_successfully", result["migrations"]); + } + + [Fact] + public void ParseStringOrList_String_SplitsBySpaces() + { + string[] result = ServiceToResourceMapper.ParseStringOrList("arg1 arg2 arg3"); + + Assert.Equal(3, result.Length); + Assert.Equal("arg1", result[0]); + Assert.Equal("arg2", result[1]); + Assert.Equal("arg3", result[2]); + } + + [Fact] + public void ParseStringOrList_List_ReturnsItems() + { + List list = ["arg1", "arg2", "arg3"]; + + string[] result = ServiceToResourceMapper.ParseStringOrList(list); + + Assert.Equal(3, result.Length); + Assert.Equal("arg1", result[0]); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/ParserTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/ParserTests.cs new file mode 100644 index 000000000..5a20d1718 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/ParserTests.cs @@ -0,0 +1,119 @@ +using CommunityToolkit.Aspire.Hosting.Compose.Parsing; +using CommunityToolkit.Aspire.Hosting.Compose.Parsing.Contracts; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Tests; + +public class ParserTests +{ + [Fact] + public void Parse_BasicComposeFile_ReturnsServices() + { + string composePath = GetTestFilePath("basic.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile); + Assert.Equal(2, composeFile.Services.Count); + Assert.Contains("postgres", composeFile.Services.Keys); + Assert.Contains("redis", composeFile.Services.Keys); + } + + [Fact] + public void Parse_BasicComposeFile_ParsesImage() + { + string composePath = GetTestFilePath("basic.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.Equal("postgres:16", composeFile.Services["postgres"].Image); + Assert.Equal("redis:7-alpine", composeFile.Services["redis"].Image); + } + + [Fact] + public void Parse_BasicComposeFile_ParsesPorts() + { + string composePath = GetTestFilePath("basic.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile.Services["postgres"].Ports); + Assert.Single(composeFile.Services["postgres"].Ports!); + Assert.Equal("5432:5432", composeFile.Services["postgres"].Ports![0]); + } + + [Fact] + public void Parse_BasicComposeFile_ParsesEnvironment() + { + string composePath = GetTestFilePath("basic.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile.Services["postgres"].Environment); + } + + [Fact] + public void Parse_BasicComposeFile_ParsesVolumes() + { + string composePath = GetTestFilePath("basic.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile.Services["postgres"].Volumes); + Assert.Single(composeFile.Services["postgres"].Volumes!); + Assert.Equal("pgdata:/var/lib/postgresql/data", composeFile.Services["postgres"].Volumes![0]); + } + + [Fact] + public void Parse_BasicComposeFile_ParsesDependsOn() + { + string composePath = GetTestFilePath("basic.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile.Services["redis"].DependsOn); + } + + [Fact] + public void Parse_BasicComposeFile_ParsesHealthcheck() + { + string composePath = GetTestFilePath("basic.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile.Services["postgres"].Healthcheck); + Assert.Equal(5, composeFile.Services["postgres"].Healthcheck!.Retries); + } + + [Fact] + public void Parse_BasicComposeFile_ParsesNamedVolumes() + { + string composePath = GetTestFilePath("basic.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile.Volumes); + Assert.Contains("pgdata", composeFile.Volumes.Keys); + } + + [Fact] + public void Parse_MissingFile_ThrowsFileNotFoundException() => Assert.Throws(() => ComposeParser.Parse("nonexistent-compose.yml")); + + [Fact] + public void ParseYaml_InvalidYaml_ThrowsComposeParseException() => Assert.Throws(() => ComposeParser.ParseYaml("{{invalid yaml!!")); + + [Fact] + public void ParseYaml_EmptyServices_ThrowsComposeParseException() => Assert.Throws(() => ComposeParser.ParseYaml("services:")); + + [Fact] + public void Parse_DependsOnConditions_ParsesConditions() + { + string composePath = GetTestFilePath("depends-on-conditions.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.Equal(3, composeFile.Services.Count); + Assert.NotNull(composeFile.Services["api"].DependsOn); + } + + [Fact] + public void Parse_EnvironmentList_ParsesList() + { + string composePath = GetTestFilePath("environment-list.yml"); + ComposeFile composeFile = ComposeParser.Parse(composePath); + + Assert.NotNull(composeFile.Services["app"].Environment); + } + + private static string GetTestFilePath(string fileName) => Path.Combine(AppContext.BaseDirectory, "composes", fileName); +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/SourceGeneratorTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/SourceGeneratorTests.cs new file mode 100644 index 000000000..338d3ee65 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/SourceGeneratorTests.cs @@ -0,0 +1,247 @@ +using CommunityToolkit.Aspire.Hosting.Compose.Generator; + +namespace CommunityToolkit.Aspire.Hosting.Compose.Tests; + +/// +/// Tests for the compose source generator's service name extractor and class emitter. +/// +public class SourceGeneratorTests +{ + + [Fact] + public void Extract_ModernFormat_FindsServices() + { + const string yaml = """ + services: + postgres: + image: postgres:16 + redis: + image: redis:7 + kafka: + image: confluentinc/cp-kafka:7.5.0 + """; + + List names = ComposeServiceNameExtractor.Extract(yaml); + + Assert.Equal(3, names.Count); + Assert.Contains("postgres", names); + Assert.Contains("redis", names); + Assert.Contains("kafka", names); + } + + [Fact] + public void Extract_V1Format_FindsTopLevelServices() + { + const string yaml = """ + postgres: + image: postgres:14 + ports: + - "5432:5432" + redis: + image: redis:6 + """; + + List names = ComposeServiceNameExtractor.Extract(yaml); + + Assert.Equal(2, names.Count); + Assert.Contains("postgres", names); + Assert.Contains("redis", names); + } + + [Fact] + public void Extract_V2Format_IgnoresVersionField() + { + const string yaml = """ + version: "2.4" + services: + db: + image: postgres:14 + cache: + image: redis:6 + volumes: + data: + """; + + List names = ComposeServiceNameExtractor.Extract(yaml); + + Assert.Equal(2, names.Count); + Assert.Contains("db", names); + Assert.Contains("cache", names); + } + + [Fact] + public void Extract_V3Format_IgnoresVersionField() + { + const string yaml = """ + version: "3.8" + services: + web: + image: nginx:1.25 + api: + image: myapi:latest + """; + + List names = ComposeServiceNameExtractor.Extract(yaml); + + Assert.Equal(2, names.Count); + Assert.Contains("web", names); + Assert.Contains("api", names); + } + + [Fact] + public void Extract_EmptyContent_ReturnsEmpty() + { + List names = ComposeServiceNameExtractor.Extract(string.Empty); + Assert.Empty(names); + } + + [Fact] + public void Extract_CommentsIgnored() + { + const string yaml = """ + # This is a comment + services: + # Another comment + postgres: + image: postgres:16 + """; + + List names = ComposeServiceNameExtractor.Extract(yaml); + + Assert.Single(names); + Assert.Contains("postgres", names); + } + + [Fact] + public void Extract_V1WithVolumesSection_ExcludesVolumes() + { + const string yaml = """ + postgres: + image: postgres:14 + volumes: + pgdata: + networks: + mynet: + """; + + List names = ComposeServiceNameExtractor.Extract(yaml); + + Assert.Single(names); + Assert.Contains("postgres", names); + } + + + [Fact] + public void SanitizeIdentifier_PascalCase() + { + Assert.Equal("Infra", ComposeClassEmitter.SanitizeIdentifier("Infra")); + Assert.Equal("Postgres", ComposeClassEmitter.SanitizeIdentifier("postgres")); + } + + [Fact] + public void SanitizeIdentifier_KebabCase_Converts() + { + Assert.Equal("MyInfra", ComposeClassEmitter.SanitizeIdentifier("my-infra")); + Assert.Equal("MyPostgres", ComposeClassEmitter.SanitizeIdentifier("my-postgres")); + } + + [Fact] + public void SanitizeIdentifier_SnakeCase_Converts() + { + Assert.Equal("MyInfra", ComposeClassEmitter.SanitizeIdentifier("my_infra")); + } + + [Fact] + public void SanitizeIdentifier_StartsWithDigit_Prefixed() + { + Assert.Equal("_3rdParty", ComposeClassEmitter.SanitizeIdentifier("3rd-party")); + } + + [Fact] + public void EmitClass_GeneratesWrapperWithProperties() + { + string source = ComposeClassEmitter.EmitClass( + "Infra", + ".infra/compose.yml", + ["postgres", "redis", "kafka"]); + + Assert.Contains("namespace Compose", source); + Assert.Contains("public sealed class Infra", source); + Assert.Contains("ComposeReferencePath", source); + // Typed properties + Assert.Contains("public global::Aspire.Hosting.ApplicationModel.IResourceBuilder Postgres => _inner[\"postgres\"];", source); + Assert.Contains("public global::Aspire.Hosting.ApplicationModel.IResourceBuilder Redis => _inner[\"redis\"];", source); + Assert.Contains("public global::Aspire.Hosting.ApplicationModel.IResourceBuilder Kafka => _inner[\"kafka\"];", source); + // Constructor + Assert.Contains("ComposeResourceCollection inner", source); + // String indexer for fallback + Assert.Contains("this[string serviceName]", source); + } + + [Fact] + public void EmitClass_EmptyServices_GeneratesClassWithIndexerOnly() + { + string source = ComposeClassEmitter.EmitClass("Empty", "empty.yml", []); + + Assert.Contains("public sealed class Empty", source); + Assert.Contains("this[string serviceName]", source); + Assert.DoesNotContain("=> _inner[\"", source); + } + + [Fact] + public void SanitizeIdentifier_EmptyString_ReturnsUnknown() + { + Assert.Equal("Unknown", ComposeClassEmitter.SanitizeIdentifier(string.Empty)); + } + + [Fact] + public void SanitizeIdentifier_AllDelimiters_ReturnsUnknown() + { + Assert.Equal("Unknown", ComposeClassEmitter.SanitizeIdentifier("---")); + } + + [Fact] + public void SanitizeIdentifier_DotSeparator_Converts() + { + Assert.Equal("MyInfra", ComposeClassEmitter.SanitizeIdentifier("my.infra")); + } + + [Fact] + public void SanitizeIdentifier_SpaceSeparator_Converts() + { + Assert.Equal("MyInfra", ComposeClassEmitter.SanitizeIdentifier("my infra")); + } + + [Fact] + public void Extract_WhitespaceOnly_ReturnsEmpty() + { + List names = ComposeServiceNameExtractor.Extract(" \n\t "); + Assert.Empty(names); + } + + [Fact] + public void Extract_QuotedServiceNames_StripsQuotes() + { + const string yaml = """ + services: + "quoted-service": + image: test:1 + """; + + List names = ComposeServiceNameExtractor.Extract(yaml); + + Assert.Single(names); + Assert.Contains("quoted-service", names); + } + + [Fact] + public void Extract_TabIndentation_HandlesCorrectly() + { + string yaml = "services:\n\tpostgres:\n\t\timage: postgres:16"; + + List names = ComposeServiceNameExtractor.Extract(yaml); + + Assert.Single(names); + Assert.Contains("postgres", names); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/basic.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/basic.yml new file mode 100644 index 000000000..19b32be73 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/basic.yml @@ -0,0 +1,26 @@ +services: + postgres: + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_USER: admin + POSTGRES_PASSWORD: secret + POSTGRES_DB: mydb + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U admin"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + depends_on: + - postgres + +volumes: + pgdata: diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/container-name.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/container-name.yml new file mode 100644 index 000000000..e7dd838e6 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/container-name.yml @@ -0,0 +1,6 @@ +services: + db: + image: postgres:16 + container_name: my-custom-postgres + ports: + - "5432:5432" diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/depends-on-conditions.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/depends-on-conditions.yml new file mode 100644 index 000000000..31598fbbe --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/depends-on-conditions.yml @@ -0,0 +1,27 @@ +services: + db: + image: postgres:16 + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: 5s + timeout: 3s + retries: 5 + + migrations: + image: flyway/flyway:10 + depends_on: + db: + condition: service_healthy + command: migrate + + api: + image: myapi:latest + ports: + - "8080:80" + depends_on: + db: + condition: service_healthy + migrations: + condition: service_completed_successfully diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/entrypoint-and-command.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/entrypoint-and-command.yml new file mode 100644 index 000000000..a9eb44973 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/entrypoint-and-command.yml @@ -0,0 +1,14 @@ +services: + worker-string: + image: python:3.12 + entrypoint: python + command: -c "print('hello')" + + worker-list: + image: python:3.12 + entrypoint: + - python + - -u + command: + - script.py + - --verbose diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/environment-list.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/environment-list.yml new file mode 100644 index 000000000..bda1215f8 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/environment-list.yml @@ -0,0 +1,9 @@ +services: + app: + image: myapp:latest + environment: + - NODE_ENV=production + - PORT=3000 + - DEBUG= + ports: + - "3000:3000" diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/healthcheck.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/healthcheck.yml new file mode 100644 index 000000000..7bb842724 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/healthcheck.yml @@ -0,0 +1,25 @@ +services: + db: + image: postgres:16 + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U admin"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + cache: + image: redis:7 + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + + worker: + image: myworker:latest + healthcheck: + disable: true diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/ip-bound-ports.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/ip-bound-ports.yml new file mode 100644 index 000000000..52880ea2c --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/ip-bound-ports.yml @@ -0,0 +1,6 @@ +services: + web: + image: nginx:1.25 + ports: + - "127.0.0.1:8080:80" + - "127.0.0.1::9090" diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/missing-dependency.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/missing-dependency.yml new file mode 100644 index 000000000..11640b631 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/missing-dependency.yml @@ -0,0 +1,5 @@ +services: + api: + image: myapi:latest + depends_on: + - nonexistent diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/modern-no-version.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/modern-no-version.yml new file mode 100644 index 000000000..63c6c723a --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/modern-no-version.yml @@ -0,0 +1,29 @@ +services: + app: + image: node:20-alpine + ports: + - "3000:3000" + environment: + NODE_ENV: production + depends_on: + db: + condition: service_healthy + restart: true + + db: + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: secret + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + cache: + image: redis:7 + ports: + - "6379:6379" diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/multiple-ports.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/multiple-ports.yml new file mode 100644 index 000000000..0ee743957 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/multiple-ports.yml @@ -0,0 +1,8 @@ +services: + web: + image: nginx:1.25 + ports: + - "80:80" + - "443:443" + - "8080" + - "127.0.0.1:9090:9090" diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/registry-port-image.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/registry-port-image.yml new file mode 100644 index 000000000..a12a38cfb --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/registry-port-image.yml @@ -0,0 +1,9 @@ +services: + app: + image: registry.example.com:5000/myapp + ports: + - "8080:80" + tagged: + image: registry.example.com:5000/myapp:v2 + ports: + - "8081:80" diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/udp-ports.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/udp-ports.yml new file mode 100644 index 000000000..0896dd71a --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/udp-ports.yml @@ -0,0 +1,6 @@ +services: + dns: + image: coredns/coredns:1.11 + ports: + - "53:53/udp" + - "53:53/tcp" diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v1-command-only.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v1-command-only.yml new file mode 100644 index 000000000..e48a58711 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v1-command-only.yml @@ -0,0 +1,4 @@ +worker: + command: echo hello +redis: + image: redis:7 diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v1-format.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v1-format.yml new file mode 100644 index 000000000..26e0c64cb --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v1-format.yml @@ -0,0 +1,13 @@ +postgres: + image: postgres:14 + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: secret + +redis: + image: redis:6 + ports: + - "6379:6379" + links: + - postgres diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v2-format.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v2-format.yml new file mode 100644 index 000000000..be4ea626f --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v2-format.yml @@ -0,0 +1,21 @@ +version: "2.4" + +services: + postgres: + image: postgres:14 + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: secret + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:6-alpine + ports: + - "6379:6379" + depends_on: + - postgres + +volumes: + pgdata: diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v3-format.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v3-format.yml new file mode 100644 index 000000000..a5cdfd01d --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/v3-format.yml @@ -0,0 +1,39 @@ +version: "3.8" + +services: + web: + image: nginx:1.25 + ports: + - "80:80" + depends_on: + - api + + api: + image: myapi:latest + ports: + - "8080:80" + environment: + DATABASE_URL: postgres://admin:secret@db:5432/app + depends_on: + db: + condition: service_healthy + + db: + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_USER: admin + POSTGRES_PASSWORD: secret + POSTGRES_DB: app + healthcheck: + test: ["CMD-SHELL", "pg_isready -U admin"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - dbdata:/var/lib/postgresql/data + +volumes: + dbdata: + driver: local diff --git a/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/volumes-advanced.yml b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/volumes-advanced.yml new file mode 100644 index 000000000..1a65736de --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Compose.Tests/composes/volumes-advanced.yml @@ -0,0 +1,8 @@ +services: + app: + image: myapp:latest + volumes: + - ./data:/app/data + - /tmp/cache:/cache:ro + - named-vol:/var/lib/data + - /var/run/docker.sock