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