Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ When expanding YAML, ComposeSharp uses the process environment first and then th

`ComposeFileLoader.LoadMerged` accepts several files and applies an incremental, field-level merge: later scalars replace earlier values, mappings merge recursively, and lists append with focused replacement rules for service resources, `command`, and `entrypoint`. It is not Docker Compose's complete merge algorithm; see [the merge semantics](docs/merge-semantics.md) for the exact supported rules and unsupported YAML tags.

`ComposeProjectContext.Profiles` selects services consistently for project loading and operations that load the Compose file. Services without `profiles` are always selected; a profiled service is selected when any of its profiles is active. An operation that explicitly names a service can select it even when its profile is not active.

## What the engine does today

| Area | Current behavior |
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ builder.Services.AddComposeSharp();
- `GenerateAsync` 返回读取到的项目摘要,并不会生成新的 Compose 文件。
- `PublishAsync` 只为服务镜像打 tag,不会把镜像推送到 registry。
- `LoadMerged` 按字段逐步合并后置文件:标量由后置值覆盖,映射递归合并,列表追加;服务资源、`command` 和 `entrypoint` 有明确的替换规则。它仍不是 Docker Compose 的完整合并算法;精确规则和不支持的 YAML 标签见[合并语义](docs/merge-semantics.md)。
- `ComposeProjectContext.Profiles` 会在加载项目以及加载 Compose 文件的操作中统一选择服务:未配置 `profiles` 的服务始终会被选择;配置了 profile 的服务会在任一 profile 被激活时被选择。显式指定服务的操作即使未激活该服务的 profile,也可以选择它。
- `depends_on` 已被读取并能体现在依赖图中,但尚未实现完整的启动排序和健康就绪调度。

默认端点在 Windows 是 `npipe://./pipe/docker_engine`,Unix 是 `unix:///var/run/docker.sock`;也可以通过 `SocketPath` 显式指定。
Expand Down
43 changes: 20 additions & 23 deletions src/ComposeSharp.Engine/ComposeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ public sealed class ComposeService : IComposeService
public async Task BuildAsync(ComposeProjectContext context, ComposeBuildOptions? options = null, CancellationToken cancellationToken = default)
{
var project = LoadProjectInternal(context);
var targetServices = options?.Services is { Count: > 0 }
? project.Services.Where(s => options.Services.Contains(s.Name) && s.Build is not null).ToList()
: project.Services.Where(s => s.Build is not null).ToList();
var targetServices = ProfileServiceSelector
.Select(project, context.Profiles, options?.Services)
.Where(service => service.Build is not null)
.ToList();

foreach (var service in targetServices)
{
Expand Down Expand Up @@ -52,7 +53,7 @@ public async Task UpAsync(ComposeProjectContext context, ComposeUpOptions? optio

await _networks.EnsureProjectInfrastructureAsync(client, context.ProjectName, project, cancellationToken);

var targetServices = GetOrderedServices(project, options?.Services);
var targetServices = GetOrderedServices(project, context.Profiles, options?.Services);

foreach (var service in targetServices)
{
Expand Down Expand Up @@ -113,7 +114,7 @@ public async Task CreateAsync(ComposeProjectContext context, ComposeCreateOption
using var client = _clientFactory.CreateClient(context.SocketPath);
await _networks.EnsureProjectInfrastructureAsync(client, context.ProjectName, project, cancellationToken);

var targetServices = GetOrderedServices(project, options?.Services);
var targetServices = GetOrderedServices(project, context.Profiles, options?.Services);
foreach (var service in targetServices)
{
var replicas = 1;
Expand Down Expand Up @@ -158,16 +159,15 @@ public async Task PullAsync(ComposeProjectContext context, ComposePullOptions? o
{
var project = LoadProjectInternal(context);
using var client = _clientFactory.CreateClient(context.SocketPath);
await _images.PullImagesAsync(client, project, context.RegistryAuth, options?.Services, cancellationToken);
var targetServices = ProfileServiceSelector.Select(project, context.Profiles, options?.Services);
await _images.PullImagesAsync(client, targetServices, context.RegistryAuth, cancellationToken);
}

public async Task PushAsync(ComposeProjectContext context, ComposePushOptions? options = null, CancellationToken cancellationToken = default)
{
var project = LoadProjectInternal(context);
using var client = _clientFactory.CreateClient(context.SocketPath);
var targetServices = options?.Services is { Count: > 0 }
? project.Services.Where(s => options.Services.Contains(s.Name))
: project.Services;
var targetServices = ProfileServiceSelector.Select(project, context.Profiles, options?.Services);

foreach (var service in targetServices)
{
Expand All @@ -188,7 +188,7 @@ public async Task KillAsync(ComposeProjectContext context, ComposeKillOptions? o
public async Task<string> RunAsync(ComposeProjectContext context, string serviceName, ComposeRunOptions? options = null, CancellationToken cancellationToken = default)
{
var project = LoadProjectInternal(context);
var service = project.Services.FirstOrDefault(s => s.Name == serviceName)
var service = ProfileServiceSelector.Select(project, context.Profiles, [serviceName]).SingleOrDefault()
?? throw new InvalidOperationException($"Service '{serviceName}' not found.");

using var client = _clientFactory.CreateClient(context.SocketPath);
Expand Down Expand Up @@ -426,7 +426,7 @@ public async Task<IReadOnlyList<ServiceStatus>> ScaleAsync(ComposeProjectContext
var result = new List<ServiceStatus>();
foreach (var (serviceName, replicas) in options.Services)
{
var service = project.Services.FirstOrDefault(s => s.Name == serviceName)
var service = ProfileServiceSelector.Select(project, context.Profiles, [serviceName]).SingleOrDefault()
?? throw new InvalidOperationException($"Service '{serviceName}' not found.");

await foreach (var _ in _containers.ReconcileServiceAsync(
Expand Down Expand Up @@ -470,9 +470,7 @@ public async Task<WaitResult> WaitAsync(ComposeProjectContext context, ComposeWa
public async IAsyncEnumerable<WatchEvent> WatchAsync(ComposeProjectContext context, ComposeWatchOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var project = LoadProjectInternal(context);
var targetServices = options?.Services is { Count: > 0 }
? project.Services.Where(s => options.Services.Contains(s.Name)).ToList()
: project.Services;
var targetServices = ProfileServiceSelector.Select(project, context.Profiles, options?.Services);

foreach (var service in targetServices)
{
Expand Down Expand Up @@ -534,7 +532,7 @@ public Task<string> VizAsync(ComposeProjectContext context, CancellationToken ca
var sb = new StringBuilder();
sb.AppendLine($"digraph {context.ProjectName} {{");
sb.AppendLine(" rankdir=LR;");
foreach (var service in project.Services)
foreach (var service in ProfileServiceSelector.Select(project, context.Profiles))
{
sb.AppendLine($" \"{service.Name}\" [label=\"{service.Name}\\n{service.Image ?? "build"}\"];");
foreach (var dep in service.DependsOn)
Expand Down Expand Up @@ -577,7 +575,7 @@ public ComposeProjectConfig LoadProject(ComposeProjectContext context)
Name = context.ProjectName,
WorkingDirectory = context.WorkingDirectory,
ConfigFiles = [context.ComposeFileName],
Services = project.Services.Select(s => s.Name).ToList(),
Services = ProfileServiceSelector.Select(project, context.Profiles).Select(service => service.Name).ToList(),
Networks = project.Networks.ToList(),
Volumes = project.Volumes.ToList(),
Secrets = project.Secrets.ToList(),
Expand All @@ -590,7 +588,7 @@ public async Task PublishAsync(ComposeProjectContext context, string repository,
var project = LoadProjectInternal(context);
using var client = _clientFactory.CreateClient(context.SocketPath);

foreach (var service in project.Services)
foreach (var service in ProfileServiceSelector.Select(project, context.Profiles))
{
if (service.Image is not null)
{
Expand All @@ -608,13 +606,12 @@ private ComposeProject LoadProjectInternal(ComposeProjectContext context)
return _loader.Load(context.WorkingDirectory, context.ComposeFileName);
}

private static IReadOnlyList<ServiceDefinition> GetOrderedServices(ComposeProject project, IReadOnlyList<string>? services)
private static IReadOnlyList<ServiceDefinition> GetOrderedServices(
ComposeProject project,
IReadOnlyList<string>? profiles,
IReadOnlyList<string>? services)
{
var filtered = services is { Count: > 0 }
? project.Services.Where(s => services.Contains(s.Name)).ToList()
: project.Services.ToList();

return OrderServices(filtered);
return OrderServices(ProfileServiceSelector.Select(project, profiles, services));
}

private static IReadOnlyList<ServiceDefinition> OrderServices(IReadOnlyList<ServiceDefinition> services)
Expand Down
8 changes: 2 additions & 6 deletions src/ComposeSharp.Engine/Internal/ImageManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,9 @@ await client.Images.CreateImageAsync(
ct);
}

public async Task PullImagesAsync(DockerClient client, ComposeProject project, DockerRegistryAuth? auth, IReadOnlyList<string>? services, CancellationToken ct)
public async Task PullImagesAsync(DockerClient client, IReadOnlyList<ServiceDefinition> services, DockerRegistryAuth? auth, CancellationToken ct)
{
var targetServices = services is { Count: > 0 }
? project.Services.Where(s => services.Contains(s.Name)).ToList()
: project.Services;

foreach (var service in targetServices)
foreach (var service in services)
{
if (service.Image is not null)
await PullImageAsync(client, auth, service.Image, ct);
Expand Down
31 changes: 31 additions & 0 deletions src/ComposeSharp.Engine/Internal/ProfileServiceSelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using ComposeSharp.Loader.Models;

namespace ComposeSharp.Engine.Internal;

internal static class ProfileServiceSelector
{
public static IReadOnlyList<ServiceDefinition> Select(
ComposeProject project,
IReadOnlyList<string>? profiles,
IReadOnlyList<string>? explicitServices = null)
{
if (explicitServices is { Count: > 0 })
{
return project.Services
.Where(service => explicitServices.Contains(service.Name))
.ToList();
}

if (profiles is not { Count: > 0 })
{
return project.Services
.Where(service => service.Profiles.Count == 0)
.ToList();
}

var activeProfiles = new HashSet<string>(profiles, StringComparer.Ordinal);
return project.Services
.Where(service => service.Profiles.Count == 0 || service.Profiles.Any(activeProfiles.Contains))
.ToList();
}
}
3 changes: 3 additions & 0 deletions src/ComposeSharp.Engine/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("ComposeSharp.Tests")]
107 changes: 107 additions & 0 deletions tests/ComposeSharp.Tests/ProfileServiceSelectorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using ComposeSharp.Api;
using ComposeSharp.Engine;
using ComposeSharp.Engine.Internal;
using ComposeSharp.Loader.Models;

namespace ComposeSharp.Tests;

public sealed class ProfileServiceSelectorTests
{
[Fact]
public async Task PullImagesAsync_EmptyServiceSelectionDoesNotAttemptPull()
{
await new ImageManager().PullImagesAsync(null!, [], auth: null, CancellationToken.None);
}

[Fact]
public void LoadProject_AppliesProfilesFromContext()
{
var directory = Path.Combine(Path.GetTempPath(), $"compose-profiles-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
File.WriteAllText(Path.Combine(directory, "compose.yaml"), """
services:
app:
image: example/app
debug:
image: example/debug
profiles: [debug]
tests:
image: example/tests
profiles: [tests]
""");

var service = new ComposeService();
var defaultConfig = service.LoadProject(new ComposeProjectContext
{
ProjectName = "profiles",
WorkingDirectory = directory,
ComposeFileName = "compose.yaml"
});
var selectedConfig = service.LoadProject(new ComposeProjectContext
{
ProjectName = "profiles",
WorkingDirectory = directory,
ComposeFileName = "compose.yaml",
Profiles = ["debug", "tests"]
});

Assert.Equal(["app"], defaultConfig.Services);
Assert.Equal(["app", "debug", "tests"], selectedConfig.Services);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}

[Fact]
public void Select_ExcludesProfiledServices_WhenNoProfilesAreActive()
{
var services = ProfileServiceSelector.Select(CreateProject(), profiles: null);

Assert.Equal(["app"], services.Select(service => service.Name));
}

[Fact]
public void Select_IncludesServicesMatchingAnyActiveProfile()
{
var services = ProfileServiceSelector.Select(CreateProject(), ["debug", "metrics"]);

Assert.Equal(["app", "debug", "metrics"], services.Select(service => service.Name));
}

[Fact]
public void Select_ExplicitServiceBypassesProfileFiltering()
{
var services = ProfileServiceSelector.Select(CreateProject(), profiles: null, explicitServices: ["debug"]);

Assert.Equal(["debug"], services.Select(service => service.Name));
}

private static ComposeProject CreateProject() => new(
WorkingDirectory: ".",
Services: [
CreateService("app", []),
CreateService("debug", ["debug"]),
CreateService("metrics", ["metrics", "debug"]),
CreateService("tests", ["tests"])
],
Volumes: [],
Networks: [],
Secrets: [],
Configs: [],
Extensions: new Dictionary<string, string>());

private static ServiceDefinition CreateService(string name, IReadOnlyList<string> profiles) => new(
Name: name, Image: null, Build: null, ContainerName: null, Command: [], Entrypoint: [], Environment: [], Ports: [], Volumes: [],
Restart: null, Healthcheck: null, DependsOn: [], Networks: [], ExtraHosts: [], Privileged: false, NetworkMode: null, Ipc: null,
ShmSize: null, Profiles: profiles, Deploy: null, Secrets: [], Configs: [], Labels: new Dictionary<string, string>(), Logging: null,
Hostname: null, Domainname: null, User: null, WorkingDir: null, Tty: false, StdinOpen: false, StopSignal: null, StopGracePeriod: null,
ReadOnly: false, Tmpfs: [], CapAdd: [], CapDrop: [], Devices: [], Sysctls: new Dictionary<string, string>(), SecurityOpt: [], Init: null,
Platform: null, PullPolicy: null, Dns: [], DnsSearch: [], Pid: null, MacAddress: null, CgroupParent: null, ExtendsService: null,
ExtendsFile: null, Develop: null, EnvFile: [], Links: [], CpuShares: null, CpuQuota: null, Cpuset: null, Memory: null,
MemorySwap: null, MemoryReservation: null, OomKillDisable: null, OomScoreAdj: null, GroupAdd: [], RestartMaxRetries: null,
Annotations: new Dictionary<string, string>());
}