From ac87554d75463dfb6c47a1016ec295e5115976cb Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Wed, 1 Jul 2026 17:00:47 +0100 Subject: [PATCH 1/3] fix(service): add Compose profiles support Adds `profiles` to Service plus a repeatable --profile flag and COMPOSE_PROFILES env var support, gating default service selection per the Compose spec (explicit targets and dependencies bypass the gate). up/build/down share one selection algorithm (Service.selectServices) so all three behave consistently for the same profile-gated graph; down now prints a note listing anything skipped due to an inactive profile. --- .../Codable Structs/Service.swift | 72 ++++++- .../Commands/ComposeBuild.swift | 14 ++ .../Commands/ComposeDown.swift | 17 +- .../Commands/ComposeFileOptions.swift | 22 +- .../Commands/ComposeUp.swift | 36 +--- .../ProfilesTests.swift | 200 ++++++++++++++++++ .../ServiceDependencyTests.swift | 2 +- 7 files changed, 329 insertions(+), 34 deletions(-) create mode 100644 Tests/Container-Compose-StaticTests/ProfilesTests.swift diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 2ed08c0c..e6784195 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -119,6 +119,13 @@ public struct Service: Codable, Hashable { /// machine's IP as seen from inside the container. public let extra_hosts: [String]? + /// Profile names that gate this service (per the Compose spec `profiles` key). + /// A service with no profiles (nil/empty) is always eligible. A service with + /// profiles is only eligible when at least one of them is active — unless the + /// service is named explicitly on the command line, or is a dependency of an + /// eligible service, both of which bypass the profile gate per the Compose spec. + public let profiles: [String]? + /// Other services that depend on this service public var dependedBy: [String] = [] @@ -126,7 +133,7 @@ public struct Service: Codable, Hashable { enum CodingKeys: String, CodingKey { case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform, - mem_limit, extra_hosts + mem_limit, extra_hosts, profiles } /// Public memberwise initializer for testing @@ -160,6 +167,7 @@ public struct Service: Codable, Hashable { tty: Bool? = nil, mem_limit: String? = nil, extra_hosts: [String]? = nil, + profiles: [String]? = nil, dependedBy: [String] = [] ) { self.image = image @@ -191,6 +199,7 @@ public struct Service: Codable, Hashable { self.tty = tty self.mem_limit = mem_limit self.extra_hosts = extra_hosts + self.profiles = profiles self.dependedBy = dependedBy } @@ -332,6 +341,21 @@ public struct Service: Codable, Hashable { } else { extra_hosts = nil } + + // `profiles` is a plain list of strings per the Compose spec (no shorthand + // single-string form). + profiles = try container.decodeIfPresent([String].self, forKey: .profiles) + } + + /// True when this service should be included by default given the currently + /// active profiles. Per the Compose spec: a service with no `profiles` is + /// always eligible; a service with `profiles` is eligible only when at least + /// one of them is active. This gate is bypassed entirely for services named + /// explicitly on the command line and for dependencies of an eligible service + /// (see `selectServices(from:requestedServices:activeProfiles:)`). + public func isProfileEligible(activeProfiles: Set) -> Bool { + guard let profiles, !profiles.isEmpty else { return true } + return !Set(profiles).isDisjoint(with: activeProfiles) } /// Translates the list-form of `environment:` into the same `[String: String]` @@ -397,4 +421,50 @@ public struct Service: Codable, Hashable { return sorted } + + /// Selects the services `up`, `build`, and `down` should act on by default, + /// applying both explicit service-name filtering and Compose `profiles` gating. + /// + /// Per the Compose spec, `profiles` gating is bypassed in two cases: + /// - a service named explicitly in `requestedServices` + /// - a service reached only as a `depends_on` dependency of an eligible + /// service (its own `profiles` are ignored) + /// When `requestedServices` is empty, the seed set is every service that + /// is profile-eligible for `activeProfiles` (unprofiled, or one of its + /// profiles is active); dependencies of that seed are then pulled in + /// regardless of their own profile. + static func selectServices( + from services: [(serviceName: String, service: Service)], + requestedServices: [String], + activeProfiles: Set = [] + ) -> [(serviceName: String, service: Service)] { + let servicesByName = Dictionary(uniqueKeysWithValues: services.map { ($0.serviceName, $0.service) }) + + let seedNames: [String] + if !requestedServices.isEmpty { + seedNames = requestedServices + } else { + seedNames = services + .filter { $0.service.isProfileEligible(activeProfiles: activeProfiles) } + .map(\.serviceName) + } + + var selected = Set() + + func include(_ serviceName: String) { + guard let service = servicesByName[serviceName], selected.insert(serviceName).inserted else { + return + } + + for dependency in service.depends_on ?? [] { + include(dependency) + } + } + + for serviceName in seedNames { + include(serviceName) + } + + return services.filter { selected.contains($0.serviceName) } + } } diff --git a/Sources/Container-Compose/Commands/ComposeBuild.swift b/Sources/Container-Compose/Commands/ComposeBuild.swift index 5abde194..79294214 100644 --- a/Sources/Container-Compose/Commands/ComposeBuild.swift +++ b/Sources/Container-Compose/Commands/ComposeBuild.swift @@ -108,6 +108,20 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable { if !services.isEmpty { servicesToBuild = servicesToBuild.filter { services.contains($0.serviceName) } + } else { + // Match `up`'s default selection exactly: profile-eligible services plus + // their dependencies (which bypass the profile gate). Without this, a + // profile-gated service that's only pulled in as a dependency of an + // eligible service would start via `up` but never get built here. + let allServices: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in + guard let service else { return nil } + return (name, service) + } + let selectedNames = Set( + Service.selectServices(from: allServices, requestedServices: [], activeProfiles: composeFileOptions.activeProfiles) + .map(\.serviceName) + ) + servicesToBuild = servicesToBuild.filter { selectedNames.contains($0.serviceName) } } if servicesToBuild.isEmpty { diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index b41a4e97..2955702a 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -107,11 +107,26 @@ public struct ComposeDown: AsyncParsableCommand { }) services = try Service.topoSortConfiguredServices(services) - // Filter for specified services + // Filter for specified services and active Compose profiles if !self.services.isEmpty { services = services.filter({ serviceName, service in self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) }) + } else { + // Match `up`'s default selection: profile-eligible services plus their + // dependencies (which bypass the profile gate). Anything excluded here + // was left running by a previous `up --profile ...` that isn't repeated + // on this `down` — warn instead of silently leaving it running. + let selected = Service.selectServices(from: services, requestedServices: [], activeProfiles: composeFileOptions.activeProfiles) + let selectedNames = Set(selected.map(\.serviceName)) + let skipped = services.map(\.serviceName).filter { !selectedNames.contains($0) } + if !skipped.isEmpty { + print( + "Note: not stopping '\(skipped.sorted().joined(separator: "', '"))' — gated by an inactive Compose profile. " + + "Pass --profile (or name the service explicitly) to also stop them." + ) + } + services = selected } try await stopOldStuff(services, remove: false) diff --git a/Sources/Container-Compose/Commands/ComposeFileOptions.swift b/Sources/Container-Compose/Commands/ComposeFileOptions.swift index e27782af..51089a98 100644 --- a/Sources/Container-Compose/Commands/ComposeFileOptions.swift +++ b/Sources/Container-Compose/Commands/ComposeFileOptions.swift @@ -15,10 +15,30 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import Foundation public struct ComposeFileOptions: ParsableArguments, Sendable { public init() {} - + @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") public var composeFilename: String? + + @Option( + name: .long, + help: "Specify a profile to enable. Can be repeated. Services without a 'profiles' key are always enabled; profiled services are enabled only when one of their profiles is active." + ) + public var profile: [String] = [] + + /// Active profiles from repeated `--profile` flags merged with the + /// comma-separated `COMPOSE_PROFILES` environment variable (the Compose + /// spec's documented equivalent, e.g. `COMPOSE_PROFILES=debug,frontend`). + public var activeProfiles: Set { + var result = Set(profile) + if let envProfiles = ProcessInfo.processInfo.environment["COMPOSE_PROFILES"] { + result.formUnion( + envProfiles.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + ) + } + return result + } } diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index a17c719e..3f59f6fe 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -209,8 +209,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { }) services = try Service.topoSortConfiguredServices(services) - // Filter for specified services - services = Self.servicesSelectedForUp(services, requestedServices: self.services) + // Filter for specified services and active Compose profiles + services = Service.selectServices( + from: services, + requestedServices: self.services, + activeProfiles: composeFileOptions.activeProfiles + ) // Stop Services. Pass every name a previous run might have used (legacy // dashed, dotted DNS-mode, and explicit container_name) so the cleanup @@ -359,34 +363,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ])) != nil } - static func servicesSelectedForUp( - _ services: [(serviceName: String, service: Service)], - requestedServices: [String] - ) -> [(serviceName: String, service: Service)] { - guard !requestedServices.isEmpty else { - return services - } - - let servicesByName = Dictionary(uniqueKeysWithValues: services.map { ($0.serviceName, $0.service) }) - var selected = Set() - - func include(_ serviceName: String) { - guard let service = servicesByName[serviceName], selected.insert(serviceName).inserted else { - return - } - - for dependency in service.depends_on ?? [] { - include(dependency) - } - } - - for serviceName in requestedServices { - include(serviceName) - } - - return services.filter { selected.contains($0.serviceName) } - } - static func validateStoppedServiceExitCode(_ exitCode: Int32, serviceName: String) throws { guard exitCode == 0 else { throw ComposeError.containerRunFailed(serviceName, exitCode) diff --git a/Tests/Container-Compose-StaticTests/ProfilesTests.swift b/Tests/Container-Compose-StaticTests/ProfilesTests.swift new file mode 100644 index 00000000..2f9ce3d4 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ProfilesTests.swift @@ -0,0 +1,200 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Testing +import Foundation +@testable import Yams +@testable import ContainerComposeCore + +// .serialized: two tests below mutate the global COMPOSE_PROFILES process +// environment variable via setenv/unsetenv. Swift Testing parallelizes tests +// within a suite by default, so without this trait those tests can race each +// other (or any future test reading COMPOSE_PROFILES), causing intermittent +// failures. +@Suite("Compose profiles support", .serialized) +struct ProfilesTests { + + private func decodeService(_ serviceYaml: String) throws -> Service { + let yaml = """ + services: + svc: + \(serviceYaml.split(separator: "\n", omittingEmptySubsequences: false) + .map { " \($0)" }.joined(separator: "\n")) + """ + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + guard let service = compose.services["svc"].flatMap({ $0 }) else { + Issue.record("service 'svc' missing or nil") + throw TestError.missingService + } + return service + } + + enum TestError: Error { case missingService } + + // MARK: - Decoding + + @Test("profiles list form parses") + func profilesListParses() throws { + let svc = try decodeService(""" + image: alpine + profiles: + - debug + - frontend + """) + #expect(svc.profiles == ["debug", "frontend"]) + } + + @Test("profiles absent when not set") + func profilesAbsentWhenNotSet() throws { + let svc = try decodeService(""" + image: alpine + """) + #expect(svc.profiles == nil) + } + + // MARK: - isProfileEligible + + @Test("service with no profiles is always eligible") + func noProfilesAlwaysEligible() { + let svc = Service(image: "alpine", profiles: nil) + #expect(svc.isProfileEligible(activeProfiles: [])) + #expect(svc.isProfileEligible(activeProfiles: ["debug"])) + } + + @Test("service with empty profiles list is always eligible") + func emptyProfilesAlwaysEligible() { + let svc = Service(image: "alpine", profiles: []) + #expect(svc.isProfileEligible(activeProfiles: [])) + } + + @Test("profiled service is ineligible when its profile is not active") + func profiledServiceIneligibleWhenInactive() { + let svc = Service(image: "alpine", profiles: ["debug"]) + #expect(!svc.isProfileEligible(activeProfiles: [])) + #expect(!svc.isProfileEligible(activeProfiles: ["frontend"])) + } + + @Test("profiled service is eligible when one of its profiles is active") + func profiledServiceEligibleWhenActive() { + let svc = Service(image: "alpine", profiles: ["debug", "frontend"]) + #expect(svc.isProfileEligible(activeProfiles: ["frontend"])) + } + + // MARK: - Service.selectServices — default (no requested services) + + @Test("default selection excludes profiled services when no profile is active") + func defaultSelectionExcludesProfiledServices() throws { + let web = Service(image: "nginx", profiles: nil) + let debugTools = Service(image: "busybox", profiles: ["debug"]) + let services: [(serviceName: String, service: Service)] = [ + ("web", web), + ("debug-tools", debugTools), + ] + + let selected = Service.selectServices(from: services, requestedServices: []) + + #expect(selected.map(\.serviceName) == ["web"]) + } + + @Test("default selection includes profiled services when their profile is active") + func defaultSelectionIncludesActiveProfiledServices() throws { + let web = Service(image: "nginx", profiles: nil) + let debugTools = Service(image: "busybox", profiles: ["debug"]) + let services: [(serviceName: String, service: Service)] = [ + ("web", web), + ("debug-tools", debugTools), + ] + + let selected = Service.selectServices(from: services, requestedServices: [], activeProfiles: ["debug"]) + + #expect(Set(selected.map(\.serviceName)) == ["web", "debug-tools"]) + } + + // MARK: - Service.selectServices — explicit request bypasses the gate + + @Test("explicitly requested service starts even if its profile is inactive") + func explicitRequestBypassesProfileGate() throws { + let debugTools = Service(image: "busybox", profiles: ["debug"]) + let services: [(serviceName: String, service: Service)] = [ + ("debug-tools", debugTools), + ] + + let selected = Service.selectServices(from: services, requestedServices: ["debug-tools"], activeProfiles: []) + + #expect(selected.map(\.serviceName) == ["debug-tools"]) + } + + // MARK: - Service.selectServices — dependency bypasses the gate + + @Test("dependency of an eligible service starts even if its own profile is inactive") + func dependencyBypassesProfileGate() throws { + let db = Service(image: "postgres", profiles: ["debug"]) + let api = Service(image: "myapi", depends_on: ["db"], profiles: nil) + let services: [(serviceName: String, service: Service)] = [ + ("db", db), + ("api", api), + ] + + let selected = Service.selectServices(from: services, requestedServices: [], activeProfiles: []) + + #expect(Set(selected.map(\.serviceName)) == ["db", "api"]) + } + + // MARK: - CLI parsing + + @Test("ComposeUp command accepts repeated --profile flags") + func composeUpCommandAcceptsRepeatedProfileFlags() throws { + let cmd = try ComposeUp.parse(["--profile", "debug", "--profile", "frontend"]) + #expect(cmd.composeFileOptions.profile == ["debug", "frontend"]) + } + + @Test("ComposeUp command defaults profile to empty") + func composeUpCommandDefaultsProfileToEmpty() throws { + let cmd = try ComposeUp.parse([]) + #expect(cmd.composeFileOptions.profile.isEmpty) + } + + @Test("ComposeBuild command accepts --profile flag") + func composeBuildCommandAcceptsProfileFlag() throws { + let cmd = try ComposeBuild.parse(["--profile", "debug"]) + #expect(cmd.composeFileOptions.profile == ["debug"]) + } + + @Test("ComposeDown command accepts --profile flag") + func composeDownCommandAcceptsProfileFlag() throws { + let cmd = try ComposeDown.parse(["--profile", "debug"]) + #expect(cmd.composeFileOptions.profile == ["debug"]) + } + + // MARK: - COMPOSE_PROFILES environment variable + + @Test("activeProfiles merges --profile flags with COMPOSE_PROFILES env var") + func activeProfilesMergesEnvVar() throws { + setenv("COMPOSE_PROFILES", "backend, debug", 1) + defer { unsetenv("COMPOSE_PROFILES") } + + let cmd = try ComposeUp.parse(["--profile", "frontend"]) + #expect(cmd.composeFileOptions.activeProfiles == ["frontend", "backend", "debug"]) + } + + @Test("activeProfiles is empty when neither --profile nor COMPOSE_PROFILES is set") + func activeProfilesEmptyWhenUnset() throws { + unsetenv("COMPOSE_PROFILES") + + let cmd = try ComposeUp.parse([]) + #expect(cmd.composeFileOptions.activeProfiles.isEmpty) + } +} diff --git a/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift b/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift index 9aabac86..9229a7d5 100644 --- a/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift +++ b/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift @@ -82,7 +82,7 @@ struct ServiceDependencyTests { ("cache", cache), ] let sorted = try Service.topoSortConfiguredServices(services) - let selected = ComposeUp.servicesSelectedForUp(sorted, requestedServices: ["worker"]) + let selected = Service.selectServices(from: sorted, requestedServices: ["worker"]) #expect(selected.map(\.serviceName) == ["db", "api", "worker"]) } From 3f3f7e955a9c0603d213012d5e1eea45863edf34 Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Thu, 2 Jul 2026 08:55:37 +0100 Subject: [PATCH 2/3] fix(build): expand depends_on for explicit build targets too ComposeBuild's explicit-service-name path only built the named service, unlike up/down which already pull in depends_on (up via Service.selectServices, down via its dependedBy check). Route both the explicit and default cases through Service.selectServices so build/up/down agree on the same dependency graph. --- .../Commands/ComposeBuild.swift | 34 ++++++++----------- .../Commands/ComposeDown.swift | 4 +-- .../ComposeBuildTests.swift | 33 ++++++++++++++++++ 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeBuild.swift b/Sources/Container-Compose/Commands/ComposeBuild.swift index 79294214..2d3e5def 100644 --- a/Sources/Container-Compose/Commands/ComposeBuild.swift +++ b/Sources/Container-Compose/Commands/ComposeBuild.swift @@ -101,27 +101,23 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable { projectName = deriveProjectName(cwd: cwd) } - var servicesToBuild: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in - guard let service, service.build != nil else { return nil } + // Route both the explicit-service-name and default cases through the same + // selection `up`/`down` use: an explicit name (or the default profile-eligible + // set) pulls in its `depends_on` graph regardless of that dependency's own + // build/profile status. Without this, a dependency only reachable via + // `depends_on` — whether profile-gated or just not named explicitly — would + // be started by `up` but never get built here. + let allServices: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in + guard let service else { return nil } return (name, service) } - - if !services.isEmpty { - servicesToBuild = servicesToBuild.filter { services.contains($0.serviceName) } - } else { - // Match `up`'s default selection exactly: profile-eligible services plus - // their dependencies (which bypass the profile gate). Without this, a - // profile-gated service that's only pulled in as a dependency of an - // eligible service would start via `up` but never get built here. - let allServices: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in - guard let service else { return nil } - return (name, service) - } - let selectedNames = Set( - Service.selectServices(from: allServices, requestedServices: [], activeProfiles: composeFileOptions.activeProfiles) - .map(\.serviceName) - ) - servicesToBuild = servicesToBuild.filter { selectedNames.contains($0.serviceName) } + let selectedNames = Set( + Service.selectServices(from: allServices, requestedServices: services, activeProfiles: composeFileOptions.activeProfiles) + .map(\.serviceName) + ) + let servicesToBuild: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in + guard let service, service.build != nil, selectedNames.contains(name) else { return nil } + return (name, service) } if servicesToBuild.isEmpty { diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index 2955702a..97647f02 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -115,8 +115,8 @@ public struct ComposeDown: AsyncParsableCommand { } else { // Match `up`'s default selection: profile-eligible services plus their // dependencies (which bypass the profile gate). Anything excluded here - // was left running by a previous `up --profile ...` that isn't repeated - // on this `down` — warn instead of silently leaving it running. + // may still be running from a previous `up --profile ...` that isn't + // repeated on this `down` — warn instead of silently skipping it. let selected = Service.selectServices(from: services, requestedServices: [], activeProfiles: composeFileOptions.activeProfiles) let selectedNames = Set(selected.map(\.serviceName)) let skipped = services.map(\.serviceName).filter { !selectedNames.contains($0) } diff --git a/Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift b/Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift index 0fd3a4ec..cb906d54 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift @@ -107,6 +107,39 @@ struct ComposeBuildTests { #expect(try await !imageExists(named: "excluded:latest")) } + @Test("Build with explicit service filter also builds its depends_on dependency") + func buildWithServiceFilterAlsoBuildsDependency() async throws { + let yaml = """ + services: + app: + depends_on: + - worker + build: + context: . + dockerfile: Dockerfile + worker: + build: + context: . + dockerfile: Dockerfile + unrelated: + build: + context: . + dockerfile: Dockerfile + """ + + let project = try writeBuildProject(yaml: yaml) + + var composeBuild = try ComposeBuild.parse([ + "--cwd", project.base.path(percentEncoded: false), + "app", + ]) + try await composeBuild.run() + + #expect(try await imageExists(named: "app:latest")) + #expect(try await imageExists(named: "worker:latest")) + #expect(try await !imageExists(named: "unrelated:latest")) + } + @Test("Build passes build args to Dockerfile") func buildPassesBuildArgsToDockerfile() async throws { let dockerfile = """ From ddeb51de59389f1e98e4c07f644dec92f15db42c Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Thu, 2 Jul 2026 09:20:14 +0100 Subject: [PATCH 3/3] fix(tests): stop ContainerDependentTrait crashing every dynamic suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Application.SystemStart.run() resolves container-apiserver relative to CommandLine.executablePath, which is correct when invoked as the real container CLI but resolves to the test binary itself when called in-process from ContainerDependentTrait — "No such file or directory" on every single test in every .containerDependent suite. Shell out to the real installed container binary instead, matching how the rest of this codebase talks to container/container-compose. This unblocked every dynamic suite (they'd never actually run before), which surfaced two stale assertions in testWordPressCompose expecting truncated mount destinations ("/var/www/", "/var/lib/") instead of the compose file's actual declared paths ("/var/www/html", "/var/lib/mysql"). Also fixes WaitForeverCpuTests, which was failing on every CI run (and locally) regardless of branch: it measured getrusage(RUSAGE_SELF), which is process-wide, so as the static test suite grew large enough that something else is almost always running concurrently in the same process during its 200ms measurement window, that ambient CPU got misattributed to the regression check. Now takes an idle baseline measurement immediately before the real one and asserts on the incremental cost only, cancelling out concurrent-test noise instead of assuming it's near-zero. --- .../ComposeUpTests.swift | 20 +++++++--- .../WaitForeverCpuTests.swift | 39 +++++++++++++------ 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift index 6de494b5..cbef767d 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift @@ -70,7 +70,7 @@ struct ComposeUpTests { #expect(wpEnv["WORDPRESS_DB_NAME"] == "wordpress") // Check Volume - #expect(wordpressContainer.configuration.mounts.map(\.destination) == ["/var/www/"]) + #expect(wordpressContainer.configuration.mounts.map(\.destination) == ["/var/www/html"]) // Assert correct db container information @@ -85,7 +85,7 @@ struct ComposeUpTests { #expect(dbEnv["MYSQL_PASSWORD"] == "wordpress") // Check Volume - #expect(dbContainer.configuration.mounts.map(\.destination) == ["/var/lib/"]) + #expect(dbContainer.configuration.mounts.map(\.destination) == ["/var/lib/mysql"]) print("") try? await stopInstance(location: tempLocation.deletingLastPathComponent()) @@ -426,9 +426,19 @@ struct ComposeUpTests { struct ContainerDependentTrait: TestScoping, TestTrait, SuiteTrait { func provideScope(for test: Test, testCase: Test.Case?, performing function: () async throws -> Void) async throws { - // Start Server - try await Application.SystemStart.parse(["--enable-kernel-install"]).run(); #warning("This is crashing") - + // `Application.SystemStart.run()` resolves the `container-apiserver` binary + // relative to `CommandLine.executablePath` — correct when invoked as the real + // `container` CLI, but when called in-process from this test binary that + // path resolves to the test runner itself, which has no `container-apiserver` + // sibling ("No such file or directory"). Shell out to the real installed + // `container` binary instead, the same way the rest of this codebase talks + // to `container`/`container-compose`. + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["container", "system", "start", "--enable-kernel-install"] + try process.run() + process.waitUntilExit() + // Run Test try await function() } diff --git a/Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift b/Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift index ce99641e..3371c9cc 100644 --- a/Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift +++ b/Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift @@ -31,19 +31,25 @@ struct WaitForeverCpuTests { /// Regression for #27: `waitForever()` must suspend, not busy-loop. /// - /// Method: take a `getrusage(RUSAGE_SELF)` snapshot, spawn a child Task - /// that calls `waitForever()`, sleep for 200ms wall-clock, take a second - /// snapshot, and compare user-CPU consumed. + /// Method: `getrusage(RUSAGE_SELF)` is process-wide, not per-task — it counts + /// every thread in the process, including whatever other tests Swift Testing + /// is running concurrently in this same process (this suite is `.serialized` + /// internally, but that doesn't stop *other* suites from overlapping it). So + /// rather than an absolute threshold, take a baseline measurement over an + /// idle 200ms window immediately before the real one, then assert on the + /// *incremental* cost the `waitForever` task adds on top of that baseline. + /// This cancels out ambient noise from concurrent tests instead of assuming + /// it's near-zero, which stopped holding once the suite grew large enough + /// that something is almost always running during any given 200ms window. /// /// On the bug (`for await _ in AsyncStream(unfolding: {})`), the - /// child task pins one core, so user CPU consumed during the 200ms window - /// is around 200,000 µs (one full core). With the fix - /// (`withUnsafeContinuation { _ in }`), the child task suspends and - /// consumes essentially nothing. + /// child task pins one core, so it adds roughly 200,000 µs of user CPU on + /// top of baseline during the 200ms window. With the fix + /// (`withUnsafeContinuation { _ in }`), the child task suspends and adds + /// essentially nothing. /// - /// Threshold of 50,000 µs gives ~4× headroom over a noisy CI baseline - /// (test-runner overhead, parallel tasks) while still reliably catching - /// a single core's worth of busy-loop work. + /// Threshold of 50,000 µs of *incremental* cost gives ~4× headroom while + /// still reliably catching a single core's worth of busy-loop work. /// /// Side effect: this test leaks one suspended task per invocation /// (`waitForever` is `-> Never` and the suspended task can't be cancelled @@ -52,6 +58,14 @@ struct WaitForeverCpuTests { @Test("waitForever does not pin a CPU core (regression for #27)") func waitForeverDoesNotPinCpu() async throws { let composeUp = ComposeUp() + + // Baseline: ambient CPU consumed by the whole process over an idle + // 200ms window (no waitForever task running), to calibrate against + // whatever else is concurrently running in this test process. + let baselineBefore = userCpuMicroseconds() + try await Task.sleep(nanoseconds: 200_000_000) // 200ms + let baselineConsumed = userCpuMicroseconds() - baselineBefore + let before = userCpuMicroseconds() // Detached so cancellation propagation from the test doesn't reach it @@ -65,8 +79,9 @@ struct WaitForeverCpuTests { let after = userCpuMicroseconds() let consumed = after - before + let incremental = consumed - baselineConsumed - #expect(consumed < 50_000, - "waitForever consumed \(consumed) µs of user CPU in 200ms — likely busy-looping (regression for #27)") + #expect(incremental < 50_000, + "waitForever added \(incremental) µs of user CPU over a \(baselineConsumed) µs baseline in 200ms — likely busy-looping (regression for #27)") } }