diff --git a/README.md b/README.md index 837a3f6..001bf25 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.zh-CN.md b/README.zh-CN.md index fbf9123..4afbbda 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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` 显式指定。 diff --git a/src/ComposeSharp.Engine/ComposeService.cs b/src/ComposeSharp.Engine/ComposeService.cs index 6969e23..232b19f 100644 --- a/src/ComposeSharp.Engine/ComposeService.cs +++ b/src/ComposeSharp.Engine/ComposeService.cs @@ -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) { @@ -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) { @@ -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; @@ -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) { @@ -188,7 +188,7 @@ public async Task KillAsync(ComposeProjectContext context, ComposeKillOptions? o public async Task 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); @@ -426,7 +426,7 @@ public async Task> ScaleAsync(ComposeProjectContext var result = new List(); 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( @@ -470,9 +470,7 @@ public async Task WaitAsync(ComposeProjectContext context, ComposeWa public async IAsyncEnumerable 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) { @@ -534,7 +532,7 @@ public Task 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) @@ -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(), @@ -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) { @@ -608,13 +606,12 @@ private ComposeProject LoadProjectInternal(ComposeProjectContext context) return _loader.Load(context.WorkingDirectory, context.ComposeFileName); } - private static IReadOnlyList GetOrderedServices(ComposeProject project, IReadOnlyList? services) + private static IReadOnlyList GetOrderedServices( + ComposeProject project, + IReadOnlyList? profiles, + IReadOnlyList? 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 OrderServices(IReadOnlyList services) diff --git a/src/ComposeSharp.Engine/Internal/ImageManager.cs b/src/ComposeSharp.Engine/Internal/ImageManager.cs index 8d808c2..988a8de 100644 --- a/src/ComposeSharp.Engine/Internal/ImageManager.cs +++ b/src/ComposeSharp.Engine/Internal/ImageManager.cs @@ -19,13 +19,9 @@ await client.Images.CreateImageAsync( ct); } - public async Task PullImagesAsync(DockerClient client, ComposeProject project, DockerRegistryAuth? auth, IReadOnlyList? services, CancellationToken ct) + public async Task PullImagesAsync(DockerClient client, IReadOnlyList 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); diff --git a/src/ComposeSharp.Engine/Internal/ProfileServiceSelector.cs b/src/ComposeSharp.Engine/Internal/ProfileServiceSelector.cs new file mode 100644 index 0000000..3013279 --- /dev/null +++ b/src/ComposeSharp.Engine/Internal/ProfileServiceSelector.cs @@ -0,0 +1,31 @@ +using ComposeSharp.Loader.Models; + +namespace ComposeSharp.Engine.Internal; + +internal static class ProfileServiceSelector +{ + public static IReadOnlyList Select( + ComposeProject project, + IReadOnlyList? profiles, + IReadOnlyList? 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(profiles, StringComparer.Ordinal); + return project.Services + .Where(service => service.Profiles.Count == 0 || service.Profiles.Any(activeProfiles.Contains)) + .ToList(); + } +} diff --git a/src/ComposeSharp.Engine/Properties/AssemblyInfo.cs b/src/ComposeSharp.Engine/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f1ec737 --- /dev/null +++ b/src/ComposeSharp.Engine/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ComposeSharp.Tests")] diff --git a/tests/ComposeSharp.Tests/ProfileServiceSelectorTests.cs b/tests/ComposeSharp.Tests/ProfileServiceSelectorTests.cs new file mode 100644 index 0000000..55c9fc6 --- /dev/null +++ b/tests/ComposeSharp.Tests/ProfileServiceSelectorTests.cs @@ -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()); + + private static ServiceDefinition CreateService(string name, IReadOnlyList 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(), 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(), 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()); +}