From cfc4612cf5b2f75fc52995fce66c2e330b552da6 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sun, 7 Jun 2026 17:44:52 -0400 Subject: [PATCH 01/10] Add shared ComposeProjectOptions for compose-file/service resolution Introduce a reusable ComposeProjectOptions (compose-file location + decode, project-name derivation, dependency-ordered service resolution, container-name mapping) and a resolved ComposeProject value. New subcommands adopt this instead of re-duplicating the boilerplate that up/down/build/logs each grew. --- .../Container-Compose/ComposeProject.swift | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 Sources/Container-Compose/ComposeProject.swift diff --git a/Sources/Container-Compose/ComposeProject.swift b/Sources/Container-Compose/ComposeProject.swift new file mode 100644 index 0000000..09e6c6a --- /dev/null +++ b/Sources/Container-Compose/ComposeProject.swift @@ -0,0 +1,172 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ComposeProject.swift +// Container-Compose +// +// Shared compose-file/project/service resolution used by the subcommands. +// `up`/`down`/`build`/`logs` each grew their own copies of this logic; new +// commands should use this instead of duplicating it again. +// + +import ArgumentParser +import ContainerCommands +import ContainerAPIClient +import Foundation +import Yams + +/// Common options + resolution for any subcommand that operates on a compose +/// project: locating and decoding the compose file, deriving the project name, +/// and resolving services (in dependency order) to their container names. +/// +/// Adopt it with `@OptionGroup var project: ComposeProjectOptions`, then call +/// `try project.resolve()` (optionally filtered by service names) to get a +/// `ComposeProject`. +public struct ComposeProjectOptions: ParsableArguments { + public init() {} + + @OptionGroup + public var composeFileOptions: ComposeFileOptions + + @OptionGroup + public var process: Flags.Process + + /// Compose filenames searched for, in order, when `--file` is not given. + public static let supportedComposeFilenames = [ + "compose.yml", + "compose.yaml", + "docker-compose.yml", + "docker-compose.yaml", + ] + + private var fileManager: FileManager { .default } + + /// Working directory the command runs in (`--cwd`, else the process cwd). + public var cwd: String { process.cwd ?? fileManager.currentDirectoryPath } + + var cwdURL: URL { URL(fileURLWithPath: cwd) } + + /// Absolute path to the compose file: the explicit `--file` if given, + /// otherwise the first supported filename found in `cwd`, otherwise the + /// default name (so callers produce a consistent "not found" error). + public var composePath: String { + if let composeFilename = composeFileOptions.composeFilename { + return resolvedPath(for: composeFilename, relativeTo: cwdURL) + } + + for filename in Self.supportedComposeFilenames { + let candidate = cwdURL.appending(path: filename).path + if fileManager.fileExists(atPath: candidate) { + return candidate + } + } + + return cwdURL.appending(path: Self.supportedComposeFilenames[0]).path + } + + /// Directory containing the compose file (the base for relative `context`, + /// `env_file`, etc.). + public var composeDirectory: String { + URL(fileURLWithPath: composePath).deletingLastPathComponent().path + } + + /// Reads and decodes the compose file at `composePath`. + /// - Throws: `YamlError.composeFileNotFound` if the file is missing. + public func loadCompose() throws -> DockerCompose { + guard let yamlData = fileManager.contents(atPath: composePath) else { + let path = URL(fileURLWithPath: composePath).deletingLastPathComponent().path + throw YamlError.composeFileNotFound(path) + } + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + return try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + } + + /// Project name used to namespace containers: the compose `name:` field if + /// present, otherwise the sanitized working-directory name. + public func projectName(for compose: DockerCompose) -> String { + compose.name ?? deriveProjectName(cwd: cwd) + } + + /// Resolves the project's services in dependency (topological) order, + /// optionally narrowed to `requested` service names. + /// + /// - Parameter requested: Service names to keep; empty means all services. + /// - Returns: `(serviceName, service)` tuples in start order. + public func orderedServices( + of compose: DockerCompose, + filteringBy requested: [String] = [] + ) throws -> [(serviceName: String, service: Service)] { + var services: [(serviceName: String, service: Service)] = compose.services.compactMap { serviceName, service in + guard let service else { return nil } + return (serviceName, service) + } + services = try Service.topoSortConfiguredServices(services) + + guard !requested.isEmpty else { return services } + return services.filter { requested.contains($0.serviceName) } + } + + /// One-shot convenience: load the compose file and resolve everything a + /// command typically needs. + /// + /// - Parameter requested: Service names to keep; empty means all services. + public func resolve(filteringBy requested: [String] = []) throws -> ComposeProject { + let compose = try loadCompose() + let projectName = projectName(for: compose) + let services = try orderedServices(of: compose, filteringBy: requested) + + // Surface requested names that don't match any service, like compose. + if !requested.isEmpty { + let known = Set(services.map(\.serviceName)) + for name in requested where !known.contains(name) { + print("Warning: No such service: \(name)") + } + } + + let containers = services.map { serviceName, service in + ComposeProject.ServiceTarget( + serviceName: serviceName, + service: service, + containerName: ComposeProject.containerName( + serviceName: serviceName, service: service, projectName: projectName) + ) + } + return ComposeProject(compose: compose, projectName: projectName, services: containers, cwd: cwd) + } +} + +/// A resolved compose project: the decoded file, its project name, and its +/// services (in dependency order) paired with their container names. +public struct ComposeProject { + public let compose: DockerCompose + public let projectName: String + public let services: [ServiceTarget] + public let cwd: String + + /// A service paired with the container name it maps to. + public struct ServiceTarget { + public let serviceName: String + public let service: Service + public let containerName: String + } + + /// Container name for a service: explicit `container_name` if set, else the + /// `"-"` convention used across the tool. + public static func containerName(serviceName: String, service: Service, projectName: String) -> String { + service.container_name ?? "\(projectName)-\(serviceName)" + } +} From 1d3b250ea47c534289bc2d386fae3812548b3b69 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 10 Jul 2026 10:42:06 -0400 Subject: [PATCH 02/10] test(project): cover ComposeProjectOptions file discovery, naming, and ordering Covers composePath (--file precedence, discovery order, default), projectName fallback to the derived cwd name, and orderedServices topological sort/filtering. --- .../ComposeProjectTests.swift | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 Tests/Container-Compose-StaticTests/ComposeProjectTests.swift diff --git a/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift b/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift new file mode 100644 index 0000000..e80a93a --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift @@ -0,0 +1,154 @@ +//===----------------------------------------------------------------------===// +// 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 +import Yams +@testable import ContainerComposeCore + +@Suite("Compose Project Resolution Tests") +struct ComposeProjectTests { + + private func decodeCompose(_ yaml: String) throws -> DockerCompose { + try YAMLDecoder().decode(DockerCompose.self, from: yaml) + } + + // MARK: - projectName(for:) + + @Test("Project name uses compose name: when present") + func projectNameFromComposeName() throws { + let options = try ComposeProjectOptions.parse(["--cwd", "/Users/user/Projects/SomeDir"]) + let compose = try decodeCompose( + """ + name: my-named-project + services: + web: + image: nginx + """) + #expect(options.projectName(for: compose) == "my-named-project") + } + + @Test("Project name falls back to derived cwd name when name: absent") + func projectNameDerivedFromCwd() throws { + let options = try ComposeProjectOptions.parse(["--cwd", "/Users/user/Projects/My.Project"]) + let compose = try decodeCompose( + """ + services: + web: + image: nginx + """) + #expect(options.projectName(for: compose) == deriveProjectName(cwd: "/Users/user/Projects/My.Project")) + #expect(options.projectName(for: compose) == "My_Project") + } + + // MARK: - composePath + + @Test("composePath honors explicit --file over discovery", .tempDir) + func composePathExplicitFile() throws { + let tmp = TempDirTrait.current + // Create a discoverable default file too, to prove --file wins. + let defaultFile = tmp.appending(path: "compose.yml") + FileManager.default.createFile(atPath: defaultFile.path, contents: nil) + + let explicit = tmp.appending(path: "custom.yaml") + FileManager.default.createFile(atPath: explicit.path, contents: nil) + + let options = try ComposeProjectOptions.parse(["--cwd", tmp.path, "-f", explicit.path]) + #expect(options.composePath == explicit.path) + } + + @Test("composePath discovers the first supported filename in cwd", .tempDir) + func composePathDiscoveryOrder() throws { + let tmp = TempDirTrait.current + // Create a lower-priority file first, then the top-priority one. + let yamlFile = tmp.appending(path: "compose.yaml") + let ymlFile = tmp.appending(path: "compose.yml") + FileManager.default.createFile(atPath: yamlFile.path, contents: nil) + FileManager.default.createFile(atPath: ymlFile.path, contents: nil) + + let options = try ComposeProjectOptions.parse(["--cwd", tmp.path]) + // compose.yml has higher priority than compose.yaml. + #expect(options.composePath == ymlFile.path) + } + + @Test("composePath discovers docker-compose.yml when higher-priority names are absent", .tempDir) + func composePathDiscoversDockerCompose() throws { + let tmp = TempDirTrait.current + let dockerCompose = tmp.appending(path: "docker-compose.yml") + FileManager.default.createFile(atPath: dockerCompose.path, contents: nil) + + let options = try ComposeProjectOptions.parse(["--cwd", tmp.path]) + #expect(options.composePath == dockerCompose.path) + } + + @Test("composePath returns default compose.yml path when none exist", .tempDir) + func composePathDefaultWhenNoneExist() throws { + let tmp = TempDirTrait.current + let options = try ComposeProjectOptions.parse(["--cwd", tmp.path]) + let expected = tmp.appending(path: "compose.yml").path + #expect(options.composePath == expected) + #expect(!FileManager.default.fileExists(atPath: expected)) + } + + // MARK: - orderedServices(of:filteringBy:) + + @Test("orderedServices returns dependencies before dependents") + func orderedServicesTopologicalOrder() throws { + let options = try ComposeProjectOptions.parse(["--cwd", "/tmp"]) + let compose = try decodeCompose( + """ + services: + web: + image: nginx + depends_on: + - app + app: + image: myapp + depends_on: + - db + db: + image: postgres + """) + + let ordered = try options.orderedServices(of: compose) + let names = ordered.map(\.serviceName) + #expect(names.count == 3) + + let dbIndex = try #require(names.firstIndex(of: "db")) + let appIndex = try #require(names.firstIndex(of: "app")) + let webIndex = try #require(names.firstIndex(of: "web")) + #expect(dbIndex < appIndex) + #expect(appIndex < webIndex) + } + + @Test("orderedServices filters to the requested service names") + func orderedServicesFiltering() throws { + let options = try ComposeProjectOptions.parse(["--cwd", "/tmp"]) + let compose = try decodeCompose( + """ + services: + web: + image: nginx + app: + image: myapp + db: + image: postgres + """) + + let ordered = try options.orderedServices(of: compose, filteringBy: ["app"]) + #expect(ordered.map(\.serviceName) == ["app"]) + } +} From 0cf35705fd091c758a521bde6bec8aae5535eaa7 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 10 Jul 2026 10:42:06 -0400 Subject: [PATCH 03/10] feat(project): resolve services to candidate container names, not one guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComposeProject mapped each service to a single container name (container_name ?? -), which misses the dotted . names 'up' uses when the project's DNS domain is registered (#97) — the same bug class fixed for ctrl-c in #127. Replace ServiceTarget.containerName with candidateContainerNames (explicit name first, then both conventions, mirroring ComposeDown.stopOldStuff) and add existingContainer(using:) to resolve which candidate actually exists, so every subcommand built on ComposeProject shares one correct lookup instead of re-deriving names. --- .../Container-Compose/ComposeProject.swift | 46 ++++++++++++++++--- .../ComposeProjectTests.swift | 34 ++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/Sources/Container-Compose/ComposeProject.swift b/Sources/Container-Compose/ComposeProject.swift index 09e6c6a..253c38e 100644 --- a/Sources/Container-Compose/ComposeProject.swift +++ b/Sources/Container-Compose/ComposeProject.swift @@ -26,6 +26,7 @@ import ArgumentParser import ContainerCommands import ContainerAPIClient +import ContainerResource import Foundation import Yams @@ -141,7 +142,7 @@ public struct ComposeProjectOptions: ParsableArguments { ComposeProject.ServiceTarget( serviceName: serviceName, service: service, - containerName: ComposeProject.containerName( + candidateContainerNames: ComposeProject.candidateContainerNames( serviceName: serviceName, service: service, projectName: projectName) ) } @@ -157,16 +158,47 @@ public struct ComposeProject { public let services: [ServiceTarget] public let cwd: String - /// A service paired with the container name it maps to. + /// A service paired with the container names it may map to. public struct ServiceTarget { public let serviceName: String public let service: Service - public let containerName: String + /// Names the service's container may exist under, in the order they + /// should be tried (see `candidateContainerNames(serviceName:service:projectName:)`). + public let candidateContainerNames: [String] + + /// The container this service currently maps to, resolved by trying + /// each candidate name against the daemon. `nil` when none exist. + public func existingContainer(using client: ContainerClient = ContainerClient()) async -> ContainerSnapshot? { + for name in candidateContainerNames { + if let container = try? await client.get(id: name) { + return container + } + } + return nil + } } - /// Container name for a service: explicit `container_name` if set, else the - /// `"-"` convention used across the tool. - public static func containerName(serviceName: String, service: Service, projectName: String) -> String { - service.container_name ?? "\(projectName)-\(serviceName)" + /// Container names a service's container may exist under, in try order. + /// + /// There is no single authoritative name: `up` names containers + /// `.` when the project's DNS domain is registered with + /// `container system dns` at creation time (#97), `-` + /// otherwise, and an explicit `container_name` overrides both — so a + /// container from a previous run may exist under any of the three + /// (mirrors `ComposeDown.stopOldStuff`). Commands that only need running + /// containers of the project can instead match the + /// `com.docker.compose.project`/`.service` labels stamped since #126, + /// but names remain necessary for containers created before that. + public static func candidateContainerNames(serviceName: String, service: Service, projectName: String) -> [String] { + var candidates = ["\(projectName)-\(serviceName)"] + if let dnsDomain = ComposeUp.sanitizeDnsDomain(projectName) { + candidates.append("\(serviceName).\(dnsDomain)") + } + if let explicit = service.container_name, !candidates.contains(explicit) { + // First, not last: an explicit name is what `up` actually uses, so + // it's the most likely to exist. + candidates.insert(explicit, at: 0) + } + return candidates } } diff --git a/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift b/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift index e80a93a..37f0aa9 100644 --- a/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift +++ b/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift @@ -26,6 +26,40 @@ struct ComposeProjectTests { try YAMLDecoder().decode(DockerCompose.self, from: yaml) } + // MARK: - candidateContainerNames + + @Test("Candidates try explicit container_name first, then both naming conventions") + func candidatesExplicitFirst() throws { + let service = Service(image: "nginx", container_name: "my-custom-name") + let names = ComposeProject.candidateContainerNames( + serviceName: "web", service: service, projectName: "proj") + #expect(names == ["my-custom-name", "proj-web", "web.proj"]) + } + + @Test("Candidates cover both - and dotted . (#97)") + func candidatesBothConventions() throws { + let service = Service(image: "nginx") + let names = ComposeProject.candidateContainerNames( + serviceName: "web", service: service, projectName: "proj") + #expect(names == ["proj-web", "web.proj"]) + } + + @Test("Dotted candidate uses the DNS-sanitized project name") + func candidatesSanitizedDnsDomain() throws { + let service = Service(image: "nginx") + let names = ComposeProject.candidateContainerNames( + serviceName: "web", service: service, projectName: "My_Project") + #expect(names == ["My_Project-web", "web.my-project"]) + } + + @Test("Explicit container_name matching a convention is not duplicated") + func candidatesNoDuplicateExplicit() throws { + let service = Service(image: "nginx", container_name: "proj-web") + let names = ComposeProject.candidateContainerNames( + serviceName: "web", service: service, projectName: "proj") + #expect(names == ["proj-web", "web.proj"]) + } + // MARK: - projectName(for:) @Test("Project name uses compose name: when present") From 4862843d149b62463bda172f0d6262d87706ef62 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 10 Jul 2026 10:42:20 -0400 Subject: [PATCH 04/10] feat(project): delegate service selection to Service.selectServices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orderedServices hand-rolled its own name filter, which ignored Compose profiles gating and didn't expand depends_on — diverging from the shared selection algorithm up/build/down adopted in #126. Route it through Service.selectServices with the ComposeFileOptions active profiles so commands built on ComposeProject select identically to the existing ones: profile-eligible services plus dependencies by default, requested services plus transitive dependencies (bypassing the profile gate) when named explicitly. --- .../Container-Compose/ComposeProject.swift | 21 +++++-- .../ComposeProjectTests.swift | 62 +++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/Sources/Container-Compose/ComposeProject.swift b/Sources/Container-Compose/ComposeProject.swift index 253c38e..4eee6eb 100644 --- a/Sources/Container-Compose/ComposeProject.swift +++ b/Sources/Container-Compose/ComposeProject.swift @@ -102,10 +102,18 @@ public struct ComposeProjectOptions: ParsableArguments { compose.name ?? deriveProjectName(cwd: cwd) } - /// Resolves the project's services in dependency (topological) order, - /// optionally narrowed to `requested` service names. + /// Resolves the services the command should act on, in dependency + /// (topological) order. /// - /// - Parameter requested: Service names to keep; empty means all services. + /// Selection delegates to `Service.selectServices` — the algorithm + /// `up`/`build`/`down` share since #126 — so Compose `profiles` gating and + /// dependency expansion behave identically here: with `requested` empty, + /// every profile-eligible service plus its dependencies; otherwise the + /// requested services plus their transitive dependencies (which bypass the + /// profile gate). + /// + /// - Parameter requested: Explicitly requested service names; empty means + /// the project's default selection. /// - Returns: `(serviceName, service)` tuples in start order. public func orderedServices( of compose: DockerCompose, @@ -116,9 +124,10 @@ public struct ComposeProjectOptions: ParsableArguments { return (serviceName, service) } services = try Service.topoSortConfiguredServices(services) - - guard !requested.isEmpty else { return services } - return services.filter { requested.contains($0.serviceName) } + return Service.selectServices( + from: services, + requestedServices: requested, + activeProfiles: composeFileOptions.activeProfiles) } /// One-shot convenience: load the compose file and resolve everything a diff --git a/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift b/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift index 37f0aa9..b9d2524 100644 --- a/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift +++ b/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift @@ -185,4 +185,66 @@ struct ComposeProjectTests { let ordered = try options.orderedServices(of: compose, filteringBy: ["app"]) #expect(ordered.map(\.serviceName) == ["app"]) } + + @Test("orderedServices pulls in transitive dependencies of requested services") + func orderedServicesRequestExpandsDependencies() throws { + let options = try ComposeProjectOptions.parse(["--cwd", "/tmp"]) + let compose = try decodeCompose( + """ + services: + web: + image: nginx + depends_on: + - app + app: + image: myapp + depends_on: + - db + db: + image: postgres + other: + image: alpine + """) + + let ordered = try options.orderedServices(of: compose, filteringBy: ["web"]) + #expect(ordered.map(\.serviceName) == ["db", "app", "web"]) + } + + @Test("orderedServices gates default selection by active Compose profiles") + func orderedServicesProfileGating() throws { + let compose = try decodeCompose( + """ + services: + web: + image: nginx + debug: + image: busybox + profiles: ["debug"] + """) + + let defaultOptions = try ComposeProjectOptions.parse(["--cwd", "/tmp"]) + let defaultNames = try defaultOptions.orderedServices(of: compose).map(\.serviceName) + #expect(defaultNames == ["web"]) + + let debugOptions = try ComposeProjectOptions.parse(["--cwd", "/tmp", "--profile", "debug"]) + let debugNames = try debugOptions.orderedServices(of: compose).map(\.serviceName) + #expect(Set(debugNames) == ["web", "debug"]) + } + + @Test("orderedServices bypasses profile gating for explicitly requested services") + func orderedServicesExplicitRequestBypassesProfiles() throws { + let options = try ComposeProjectOptions.parse(["--cwd", "/tmp"]) + let compose = try decodeCompose( + """ + services: + web: + image: nginx + debug: + image: busybox + profiles: ["debug"] + """) + + let ordered = try options.orderedServices(of: compose, filteringBy: ["debug"]) + #expect(ordered.map(\.serviceName) == ["debug"]) + } } From eecfa60db6328e124b74786ba5f5be119b3ea37f Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 10 Jul 2026 10:42:20 -0400 Subject: [PATCH 05/10] refactor(down): resolve project through shared ComposeProjectOptions First consumer of ComposeProject: drop ComposeDown's own copies of compose-file discovery, project-name derivation, topo-sort/selection, and candidate-container-name construction in favor of the shared API. Behavior is preserved (profile skip note, stop-all-candidates loop for mixed naming-mode leftovers, extra_hosts cleanup) with one refinement: explicit 'down ' now expands dependencies via Service.selectServices instead of the topo-sort dependedBy side effect, which only recorded each service's first visitor and so stopped an explicit request's dependencies only when visit order happened to align. --- .../Commands/ComposeDown.swift | 111 ++++-------------- .../ComposeDownTests.swift | 4 +- .../ProfilesTests.swift | 2 +- 3 files changed, 25 insertions(+), 92 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index 97647f0..ce64da6 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -39,116 +39,49 @@ public struct ComposeDown: AsyncParsableCommand { var services: [String] = [] @OptionGroup - var process: Flags.Process - - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - - @OptionGroup - var composeFileOptions: ComposeFileOptions - - private static let supportedComposeFilenames = [ - "compose.yml", - "compose.yaml", - "docker-compose.yml", - "docker-compose.yaml", - ] - - private var cwdURL: URL { - URL(fileURLWithPath: cwd) - } - - private var composePath: String { - if let composeFilename = composeFileOptions.composeFilename { - return resolvedPath(for: composeFilename, relativeTo: cwdURL) - } - - for filename in Self.supportedComposeFilenames { - let candidate = cwdURL.appending(path: filename).path - if fileManager.fileExists(atPath: candidate) { - return candidate - } - } - - return cwdURL.appending(path: Self.supportedComposeFilenames[0]).path - } + var projectOptions: ComposeProjectOptions private var fileManager: FileManager { FileManager.default } - private var projectName: String? - - public mutating func run() async throws { - - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: composePath) else { - let path = URL(fileURLWithPath: composePath) - .deletingLastPathComponent() - .path - throw YamlError.composeFileNotFound(path) - } - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + public func run() async throws { + let compose = try projectOptions.loadCompose() - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name + if let name = compose.name { print("Info: Docker Compose project name parsed as: \(name)") print( "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." ) } else { - projectName = deriveProjectName(cwd: cwd) - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectOptions.projectName(for: compose))") } - var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ serviceName, service in - guard let service else { return nil } - return (serviceName, service) - }) - services = try Service.topoSortConfiguredServices(services) + let project = try projectOptions.resolve(filteringBy: 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 - // 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) } + // A service excluded by an inactive profile may still be running from a + // previous `up --profile ...` that isn't repeated on this `down` — warn + // instead of silently skipping it. + if services.isEmpty { + let selectedNames = Set(project.services.map(\.serviceName)) + let skipped = compose.services.keys.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) + try await stop(project, remove: false) } - private func stopOldStuff(_ services: [(serviceName: String, service: Service)], remove: Bool) async throws { - guard let projectName else { return } - // The DNS path uses a dotted name `.`. Compute the - // domain the same way ComposeUp does so we can stop containers from - // either mode (and from a previous run that used a different mode). - let dnsDomain = ComposeUp.sanitizeDnsDomain(projectName) - - for (serviceName, service) in services { - var candidates: [String] = ["\(projectName)-\(serviceName)"] - if let dnsDomain { candidates.append("\(serviceName).\(dnsDomain)") } - if let explicit = service.container_name, !candidates.contains(explicit) { - candidates.append(explicit) - } - - let client = ContainerClient() + private func stop(_ project: ComposeProject, remove: Bool) async throws { + let client = ContainerClient() + for target in project.services { + // Stop every candidate name that exists — not just the first hit — + // so a container left behind by a previous run in the other naming + // mode is cleaned up too. var stoppedAny = false - for name in candidates { + for name in target.candidateContainerNames { guard let container = try? await client.get(id: name) else { continue } stoppedAny = true print("Stopping container: \(name)") @@ -168,12 +101,12 @@ public struct ComposeDown: AsyncParsableCommand { } } if !stoppedAny { - print("Warning: No container found for service '\(serviceName)' (tried: \(candidates.joined(separator: ", "))).") + print("Warning: No container found for service '\(target.serviceName)' (tried: \(target.candidateContainerNames.joined(separator: ", "))).") } // Best-effort: the extra_hosts bind-mount source (if any) is only safe // to remove once the container that had it mounted is stopped. - try? fileManager.removeItem(atPath: ComposeUp.extraHostsFilePath(projectName: projectName, serviceName: serviceName)) + try? fileManager.removeItem(atPath: ComposeUp.extraHostsFilePath(projectName: project.projectName, serviceName: target.serviceName)) } } } diff --git a/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift b/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift index 0da8b9a..3e95d57 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift @@ -46,7 +46,7 @@ struct ComposeDownTests { #expect(containers.filter({ $0.status == .running }).count == 2, "Expected 2 running containers for \(project.name), found \(containers.filter({ $0.status == .running }).count)") - var composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)]) + let composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)]) try await composeDown.run() containers = try await ContainerClient().list() @@ -88,7 +88,7 @@ struct ComposeDownTests { "Expected container \(containerName) to be running, found status: \(containers.map(\.status))" ) - var composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)]) + let composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)]) try await composeDown.run() containers = try await ContainerClient().list() diff --git a/Tests/Container-Compose-StaticTests/ProfilesTests.swift b/Tests/Container-Compose-StaticTests/ProfilesTests.swift index 2f9ce3d..0f13879 100644 --- a/Tests/Container-Compose-StaticTests/ProfilesTests.swift +++ b/Tests/Container-Compose-StaticTests/ProfilesTests.swift @@ -176,7 +176,7 @@ struct ProfilesTests { @Test("ComposeDown command accepts --profile flag") func composeDownCommandAcceptsProfileFlag() throws { let cmd = try ComposeDown.parse(["--profile", "debug"]) - #expect(cmd.composeFileOptions.profile == ["debug"]) + #expect(cmd.projectOptions.composeFileOptions.profile == ["debug"]) } // MARK: - COMPOSE_PROFILES environment variable From 1d312f878945e7b58646ad5234e00fb9578be0f0 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 10 Jul 2026 11:13:28 -0400 Subject: [PATCH 06/10] refactor(build): resolve project through shared ComposeProjectOptions Drop ComposeBuild's copies of compose-file discovery, project-name derivation, and service selection in favor of ComposeProjectOptions. Selection semantics are unchanged (Service.selectServices with active profiles, then keep services with a build config); ordering improves from dictionary order to topological, so dependencies build before dependents. --- .../Commands/ComposeBuild.swift | 84 ++++--------------- .../ComposeBuildParsingTests.swift | 2 +- .../ProfilesTests.swift | 2 +- 3 files changed, 19 insertions(+), 69 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeBuild.swift b/Sources/Container-Compose/Commands/ComposeBuild.swift index 2d3e5de..491eff4 100644 --- a/Sources/Container-Compose/Commands/ComposeBuild.swift +++ b/Sources/Container-Compose/Commands/ComposeBuild.swift @@ -40,85 +40,33 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable { var services: [String] = [] @OptionGroup - var composeFileOptions: ComposeFileOptions + var projectOptions: ComposeProjectOptions @Flag(name: .long, help: "Do not use cache when building") var noCache: Bool = false - @OptionGroup - var process: Flags.Process - @OptionGroup var logging: Flags.Logging - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - - private var cwdURL: URL { URL(fileURLWithPath: cwd) } - - private static let supportedComposeFilenames = [ - "compose.yml", - "compose.yaml", - "docker-compose.yml", - "docker-compose.yaml", - ] - - private var composePath: String { - if let composeFilename = composeFileOptions.composeFilename { - return resolvedPath(for: composeFilename, relativeTo: cwdURL) - } - for filename in Self.supportedComposeFilenames { - let candidate = cwdURL.appending(path: filename).path - if FileManager.default.fileExists(atPath: candidate) { - return candidate - } - } - return cwdURL.appending(path: Self.supportedComposeFilenames[0]).path - } - - private var composeDirectory: String { - URL(fileURLWithPath: composePath).deletingLastPathComponent().path - } + private var composeDirectory: String { projectOptions.composeDirectory } private var envFilePath: String { - let envFile = process.envFile.first ?? ".env" - return resolvedPath(for: envFile, relativeTo: cwdURL) + let envFile = projectOptions.process.envFile.first ?? ".env" + return resolvedPath(for: envFile, relativeTo: URL(fileURLWithPath: projectOptions.cwd)) } public mutating func run() async throws { - guard let yamlData = FileManager.default.contents(atPath: composePath) else { - let dir = URL(fileURLWithPath: composePath).deletingLastPathComponent().path - throw YamlError.composeFileNotFound(dir) - } - - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + // Shared resolution routes 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 project = try projectOptions.resolve(filteringBy: services) let environmentVariables = loadEnvFile(path: envFilePath) - let projectName: String - if let name = dockerCompose.name { - projectName = name - } else { - projectName = deriveProjectName(cwd: cwd) - } - - // 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) - } - 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) - } + let servicesToBuild = project.services.filter { $0.service.build != nil } if servicesToBuild.isEmpty { print("No services with a 'build' configuration found.") @@ -126,8 +74,10 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable { } print("Building services") - for (serviceName, service) in servicesToBuild { - try await buildService(service.build!, for: service, serviceName: serviceName, projectName: projectName, environmentVariables: environmentVariables) + for target in servicesToBuild { + try await buildService( + target.service.build!, for: target.service, serviceName: target.serviceName, + projectName: project.projectName, environmentVariables: environmentVariables) } print("Build complete") } diff --git a/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift b/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift index 1355fde..e269c3f 100644 --- a/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift +++ b/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift @@ -163,6 +163,6 @@ struct ComposeBuildParsingTests { @Test("ComposeBuild command accepts -f flag for compose file") func composeBuildCommandAcceptsFileFlag() throws { let cmd = try ComposeBuild.parse(["-f", "my-compose.yaml"]) - #expect(cmd.composeFileOptions.composeFilename == "my-compose.yaml") + #expect(cmd.projectOptions.composeFileOptions.composeFilename == "my-compose.yaml") } } diff --git a/Tests/Container-Compose-StaticTests/ProfilesTests.swift b/Tests/Container-Compose-StaticTests/ProfilesTests.swift index 0f13879..e1e5087 100644 --- a/Tests/Container-Compose-StaticTests/ProfilesTests.swift +++ b/Tests/Container-Compose-StaticTests/ProfilesTests.swift @@ -170,7 +170,7 @@ struct ProfilesTests { @Test("ComposeBuild command accepts --profile flag") func composeBuildCommandAcceptsProfileFlag() throws { let cmd = try ComposeBuild.parse(["--profile", "debug"]) - #expect(cmd.composeFileOptions.profile == ["debug"]) + #expect(cmd.projectOptions.composeFileOptions.profile == ["debug"]) } @Test("ComposeDown command accepts --profile flag") From a7753f59f45c92dcebd6645f3d9520e76463eaa4 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 10 Jul 2026 11:13:44 -0400 Subject: [PATCH 07/10] refactor(up): resolve project through shared ComposeProjectOptions Drop ComposeUp's copies of compose-file discovery/decoding, project-name derivation, topo-sort/selection, and the hand-built stop-candidates list in favor of ComposeProjectOptions and ComposeProject.candidateContainerNames. Behavior is unchanged: 'up' keeps deciding the single creation-time name itself (explicit container_name, dotted DNS name when the domain is registered, else -) since that depends on runtime DNS availability, and the pre-start cleanup still stops every name shape a previous run might have used. --- .../Commands/ComposeUp.swift | 92 ++++--------------- .../ComposeCommandParsingTests.swift | 2 +- .../ProfilesTests.swift | 8 +- 3 files changed, 24 insertions(+), 78 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 719ff21..1aad97e 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -74,41 +74,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { var detach: Bool = false @OptionGroup - var composeFileOptions: ComposeFileOptions + var projectOptions: ComposeProjectOptions - private static let supportedComposeFilenames = [ - "compose.yml", - "compose.yaml", - "docker-compose.yml", - "docker-compose.yaml", - ] - - private var cwdURL: URL { - URL(fileURLWithPath: cwd) - } - - private var composePath: String { - if let composeFilename = composeFileOptions.composeFilename { - return resolvedPath(for: composeFilename, relativeTo: cwdURL) - } - - for filename in Self.supportedComposeFilenames { - let candidate = cwdURL.appending(path: filename).path - if fileManager.fileExists(atPath: candidate) { - return candidate - } - } - - return cwdURL.appending(path: Self.supportedComposeFilenames[0]).path - } + private var composePath: String { projectOptions.composePath } + private var composeDirectory: String { projectOptions.composeDirectory } + private var cwd: String { projectOptions.cwd } private var envFilePath: String { - let envFile = process.envFile.first ?? ".env" - return resolvedPath(for: envFile, relativeTo: cwdURL) - } - - private var composeDirectory: String { - URL(fileURLWithPath: composePath).deletingLastPathComponent().path + let envFile = projectOptions.process.envFile.first ?? ".env" + return resolvedPath(for: envFile, relativeTo: URL(fileURLWithPath: cwd)) } @Flag(name: [.customShort("b"), .customLong("build")]) @@ -117,14 +91,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { @Flag(name: .long, help: "Do not use cache") var noCache: Bool = false - @OptionGroup - var process: Flags.Process - @OptionGroup var logging: Flags.Logging - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - private var fileManager: FileManager { FileManager.default } private var projectName: String? /// Apple `container` DNS domain to use for inter-container resolution. Derived @@ -151,17 +120,8 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ] public mutating func run() async throws { - // Read compose.yml content - guard let yamlData = fileManager.contents(atPath: composePath) else { - let path = URL(fileURLWithPath: composePath) - .deletingLastPathComponent() - .path - throw YamlError.composeFileNotFound(path) - } - - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + // Read and decode the compose file + let dockerCompose = try projectOptions.loadCompose() // Load environment variables from .env file environmentVariables = loadEnvFile(path: envFilePath) @@ -173,22 +133,22 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") + let projectName = projectOptions.projectName(for: dockerCompose) + self.projectName = projectName + if dockerCompose.name != nil { + print("Info: Docker Compose project name parsed as: \(projectName)") print( "Note: The 'name' field affects generated container names and default named-volume names. Full project-level isolation for other resources is not implemented by this tool." ) } else { - projectName = deriveProjectName(cwd: cwd) - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") } // Determine whether real DNS is available for this project. If so, we'll // give every container a dotted name (`.`) and pass // `--dns-domain` so libc inside the container resolves peers via the // daemon's DNS server. If not, fall back to /etc/hosts patching. - if let derived = Self.sanitizeDnsDomain(projectName ?? "") { + if let derived = Self.sanitizeDnsDomain(projectName) { dnsDomain = derived dnsAvailable = await checkDnsDomainRegistered(derived) if dnsAvailable { @@ -202,29 +162,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - // Get Services to use - var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ serviceName, service in - guard let service else { return nil } - return (serviceName, service) - }) - services = try Service.topoSortConfiguredServices(services) - - // Filter for specified services and active Compose profiles - services = Service.selectServices( - from: services, - requestedServices: self.services, - activeProfiles: composeFileOptions.activeProfiles - ) + // Resolve the services to start: topologically ordered, filtered by + // explicit names and active Compose profiles (shared selection). + let services = try projectOptions.orderedServices(of: dockerCompose, filteringBy: self.services) // Stop Services. Pass every name a previous run might have used (legacy // dashed, dotted DNS-mode, and explicit container_name) so the cleanup // catches whichever shape exists on disk. - let containerNamesToStop: [String] = services.flatMap { (serviceName, service) -> [String] in - var names: [String] = [] - if let projectName { names.append("\(projectName)-\(serviceName)") } - if let dnsDomain { names.append("\(serviceName).\(dnsDomain)") } - if let explicit = service.container_name, !names.contains(explicit) { names.append(explicit) } - return names + let containerNamesToStop: [String] = services.flatMap { (serviceName, service) in + ComposeProject.candidateContainerNames(serviceName: serviceName, service: service, projectName: projectName) } try await stopExistingContainers(containerNamesToStop, remove: true) diff --git a/Tests/Container-Compose-StaticTests/ComposeCommandParsingTests.swift b/Tests/Container-Compose-StaticTests/ComposeCommandParsingTests.swift index 121e1b0..556f6c0 100644 --- a/Tests/Container-Compose-StaticTests/ComposeCommandParsingTests.swift +++ b/Tests/Container-Compose-StaticTests/ComposeCommandParsingTests.swift @@ -22,6 +22,6 @@ struct ComposeCommandParsingTests { @Test("Main+ComposeUp command accepts -f flag for compose file from root") func composeUpCommandAcceptsFileFlag() throws { let cmd = try Main.parseAsRoot(["-f", "my-compose.yaml", "up"]) as! ComposeUp - #expect(cmd.composeFileOptions.composeFilename == "my-compose.yaml") + #expect(cmd.projectOptions.composeFileOptions.composeFilename == "my-compose.yaml") } } diff --git a/Tests/Container-Compose-StaticTests/ProfilesTests.swift b/Tests/Container-Compose-StaticTests/ProfilesTests.swift index e1e5087..3c33b2d 100644 --- a/Tests/Container-Compose-StaticTests/ProfilesTests.swift +++ b/Tests/Container-Compose-StaticTests/ProfilesTests.swift @@ -158,13 +158,13 @@ struct ProfilesTests { @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"]) + #expect(cmd.projectOptions.composeFileOptions.profile == ["debug", "frontend"]) } @Test("ComposeUp command defaults profile to empty") func composeUpCommandDefaultsProfileToEmpty() throws { let cmd = try ComposeUp.parse([]) - #expect(cmd.composeFileOptions.profile.isEmpty) + #expect(cmd.projectOptions.composeFileOptions.profile.isEmpty) } @Test("ComposeBuild command accepts --profile flag") @@ -187,7 +187,7 @@ struct ProfilesTests { defer { unsetenv("COMPOSE_PROFILES") } let cmd = try ComposeUp.parse(["--profile", "frontend"]) - #expect(cmd.composeFileOptions.activeProfiles == ["frontend", "backend", "debug"]) + #expect(cmd.projectOptions.composeFileOptions.activeProfiles == ["frontend", "backend", "debug"]) } @Test("activeProfiles is empty when neither --profile nor COMPOSE_PROFILES is set") @@ -195,6 +195,6 @@ struct ProfilesTests { unsetenv("COMPOSE_PROFILES") let cmd = try ComposeUp.parse([]) - #expect(cmd.composeFileOptions.activeProfiles.isEmpty) + #expect(cmd.projectOptions.composeFileOptions.activeProfiles.isEmpty) } } From d55c5aebb48683cdc81cbc563c9f0aced6d9560d Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 10 Jul 2026 11:59:54 -0400 Subject: [PATCH 08/10] refactor(project): simplify shared resolution layer and its consumers Cleanups from a reuse/simplification/efficiency/altitude review of the branch: - down: resolve once instead of loadCompose()+resolve() (the compose file was read and YAML-decoded twice per run); drop the stop loop's remove parameter, which was always false - up: consume resolve() instead of re-composing loadCompose + projectName + orderedServices + candidate names piecewise; this also gives up the same 'No such service' warning down/build already had - move the 'No such service' warning from resolve() into orderedServices() so every selection path warns, not just resolve() - hoist the envFilePath derivation duplicated verbatim in up and build onto ComposeProjectOptions - move sanitizeDnsDomain from ComposeUp to ComposeProject: it is project naming policy, and the shared layer should not reach into a command for it - delete dead code: ServiceTarget.existingContainer (no callers, and its first-match semantics is the exact behavior down's stop-every- candidate loop exists to avoid), ComposeProject.cwd (never read), ComposeError.invalidProjectName (no throwers left), and ComposeUp's vacuous projectName optionality (guards that could never fire once resolution guarantees a name) - narrow supportedComposeFilenames to private --- .../Commands/ComposeBuild.swift | 7 +- .../Commands/ComposeDown.swift | 26 ++----- .../Commands/ComposeUp.swift | 69 ++++------------ .../Container-Compose/ComposeProject.swift | 78 +++++++++++-------- Sources/Container-Compose/Errors.swift | 3 - .../DnsDomainTests.swift | 26 +++---- 6 files changed, 85 insertions(+), 124 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeBuild.swift b/Sources/Container-Compose/Commands/ComposeBuild.swift index 491eff4..56d5881 100644 --- a/Sources/Container-Compose/Commands/ComposeBuild.swift +++ b/Sources/Container-Compose/Commands/ComposeBuild.swift @@ -50,11 +50,6 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable { private var composeDirectory: String { projectOptions.composeDirectory } - private var envFilePath: String { - let envFile = projectOptions.process.envFile.first ?? ".env" - return resolvedPath(for: envFile, relativeTo: URL(fileURLWithPath: projectOptions.cwd)) - } - public mutating func run() async throws { // Shared resolution routes both the explicit-service-name and default // cases through the same selection `up`/`down` use: an explicit name @@ -64,7 +59,7 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable { // profile-gated or just not named explicitly — would be started by // `up` but never get built here. let project = try projectOptions.resolve(filteringBy: services) - let environmentVariables = loadEnvFile(path: envFilePath) + let environmentVariables = loadEnvFile(path: projectOptions.envFilePath) let servicesToBuild = project.services.filter { $0.service.build != nil } diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index ce64da6..de25845 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -44,25 +44,23 @@ public struct ComposeDown: AsyncParsableCommand { private var fileManager: FileManager { FileManager.default } public func run() async throws { - let compose = try projectOptions.loadCompose() + let project = try projectOptions.resolve(filteringBy: services) - if let name = compose.name { - print("Info: Docker Compose project name parsed as: \(name)") + if project.compose.name != nil { + print("Info: Docker Compose project name parsed as: \(project.projectName)") print( - "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." + "Note: The 'name' field currently only affects container naming (e.g., '\(project.projectName)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." ) } else { - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectOptions.projectName(for: compose))") + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(project.projectName)") } - let project = try projectOptions.resolve(filteringBy: services) - // A service excluded by an inactive profile may still be running from a // previous `up --profile ...` that isn't repeated on this `down` — warn // instead of silently skipping it. if services.isEmpty { let selectedNames = Set(project.services.map(\.serviceName)) - let skipped = compose.services.keys.filter { !selectedNames.contains($0) } + let skipped = project.compose.services.keys.filter { !selectedNames.contains($0) } if !skipped.isEmpty { print( "Note: not stopping '\(skipped.sorted().joined(separator: "', '"))' — gated by an inactive Compose profile. " @@ -71,10 +69,10 @@ public struct ComposeDown: AsyncParsableCommand { } } - try await stop(project, remove: false) + try await stop(project) } - private func stop(_ project: ComposeProject, remove: Bool) async throws { + private func stop(_ project: ComposeProject) async throws { let client = ContainerClient() for target in project.services { // Stop every candidate name that exists — not just the first hit — @@ -91,14 +89,6 @@ public struct ComposeDown: AsyncParsableCommand { } catch { print("Error Stopping Container: \(error)") } - if remove { - do { - try await client.delete(id: container.id) - print("Successfully removed container: \(name)") - } catch { - print("Error Removing Container: \(error)") - } - } } if !stoppedAny { print("Warning: No container found for service '\(target.serviceName)' (tried: \(target.candidateContainerNames.joined(separator: ", "))).") diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 1aad97e..443582c 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -80,11 +80,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { private var composeDirectory: String { projectOptions.composeDirectory } private var cwd: String { projectOptions.cwd } - private var envFilePath: String { - let envFile = projectOptions.process.envFile.first ?? ".env" - return resolvedPath(for: envFile, relativeTo: URL(fileURLWithPath: cwd)) - } - @Flag(name: [.customShort("b"), .customLong("build")]) var rebuild: Bool = false @@ -95,7 +90,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { var logging: Flags.Logging private var fileManager: FileManager { FileManager.default } - private var projectName: String? + /// Project name namespacing this run's containers; set once at the top of + /// `run()` from the shared resolution, before anything reads it. + private var projectName = "" /// Apple `container` DNS domain to use for inter-container resolution. Derived /// from `projectName` (sanitized to a valid DNS label). `nil` if the project /// name produces no usable label. @@ -120,11 +117,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ] public mutating func run() async throws { - // Read and decode the compose file - let dockerCompose = try projectOptions.loadCompose() + // Load, decode, and resolve the compose project (shared resolution: + // topologically ordered services, filtered by explicit names and + // active Compose profiles). + let project = try projectOptions.resolve(filteringBy: services) + let dockerCompose = project.compose + projectName = project.projectName // Load environment variables from .env file - environmentVariables = loadEnvFile(path: envFilePath) + environmentVariables = loadEnvFile(path: projectOptions.envFilePath) // Handle 'version' field if let version = dockerCompose.version { @@ -132,9 +133,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") } - // Determine project name for container naming - let projectName = projectOptions.projectName(for: dockerCompose) - self.projectName = projectName if dockerCompose.name != nil { print("Info: Docker Compose project name parsed as: \(projectName)") print( @@ -148,7 +146,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // give every container a dotted name (`.`) and pass // `--dns-domain` so libc inside the container resolves peers via the // daemon's DNS server. If not, fall back to /etc/hosts patching. - if let derived = Self.sanitizeDnsDomain(projectName) { + if let derived = ComposeProject.sanitizeDnsDomain(projectName) { dnsDomain = derived dnsAvailable = await checkDnsDomainRegistered(derived) if dnsAvailable { @@ -162,17 +160,10 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - // Resolve the services to start: topologically ordered, filtered by - // explicit names and active Compose profiles (shared selection). - let services = try projectOptions.orderedServices(of: dockerCompose, filteringBy: self.services) - // Stop Services. Pass every name a previous run might have used (legacy // dashed, dotted DNS-mode, and explicit container_name) so the cleanup // catches whichever shape exists on disk. - let containerNamesToStop: [String] = services.flatMap { (serviceName, service) in - ComposeProject.candidateContainerNames(serviceName: serviceName, service: service, projectName: projectName) - } - try await stopExistingContainers(containerNamesToStop, remove: true) + try await stopExistingContainers(project.services.flatMap(\.candidateContainerNames), remove: true) // Process top-level networks // This creates named networks defined in the docker-compose.yml @@ -198,13 +189,13 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Process each service defined in the docker-compose.yml print("\n--- Processing Services ---") - print(services.map(\.serviceName)) - for (serviceName, service) in services { - try await configService(service, serviceName: serviceName, from: dockerCompose) + print(project.services.map(\.serviceName)) + for target in project.services { + try await configService(target.service, serviceName: target.serviceName, from: dockerCompose) } if !detach { - await runForegroundUntilStopped(containerNames: services.map({ containerName(for: $0.serviceName) })) + await runForegroundUntilStopped(containerNames: project.services.map({ containerName(for: $0.serviceName) })) } } @@ -408,30 +399,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } private func containerName(for serviceName: String) -> String { - if let explicit = serviceContainerNames[serviceName] { return explicit } - if let projectName { return "\(projectName)-\(serviceName)" } - return serviceName - } - - /// Coerce an arbitrary project name into a single DNS label: lowercase, only - /// `[a-z0-9-]`, no leading/trailing/repeated hyphens, max 63 chars. Returns - /// `nil` when nothing usable remains (e.g. a name made entirely of separators). - static func sanitizeDnsDomain(_ name: String) -> String? { - let allowed: Set = Set("abcdefghijklmnopqrstuvwxyz0123456789-") - var out = "" - for ch in name.lowercased() { - out.append(allowed.contains(ch) ? ch : "-") - } - while out.contains("--") { - out = out.replacingOccurrences(of: "--", with: "-") - } - while out.hasPrefix("-") { out.removeFirst() } - while out.hasSuffix("-") { out.removeLast() } - if out.count > 63 { - out = String(out.prefix(63)) - while out.hasSuffix("-") { out.removeLast() } - } - return out.isEmpty ? nil : out + serviceContainerNames[serviceName] ?? "\(projectName)-\(serviceName)" } /// Pure parser for `container system dns list` output. Output looks like: @@ -725,8 +693,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // MARK: Compose Service Level Functions private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { - guard let projectName else { throw ComposeError.invalidProjectName } - try waitForDependencyConditions(serviceName: serviceName, service: service) var imageToRun: String @@ -1147,7 +1113,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } private func waitUntilServiceIsHealthy(serviceName: String, healthcheck: Healthcheck) async throws { - guard let projectName else { throw ComposeError.invalidProjectName } guard let execArguments = healthcheck.execArguments else { return } diff --git a/Sources/Container-Compose/ComposeProject.swift b/Sources/Container-Compose/ComposeProject.swift index 4eee6eb..4f83642 100644 --- a/Sources/Container-Compose/ComposeProject.swift +++ b/Sources/Container-Compose/ComposeProject.swift @@ -24,9 +24,8 @@ // import ArgumentParser -import ContainerCommands import ContainerAPIClient -import ContainerResource +import ContainerCommands import Foundation import Yams @@ -47,7 +46,7 @@ public struct ComposeProjectOptions: ParsableArguments { public var process: Flags.Process /// Compose filenames searched for, in order, when `--file` is not given. - public static let supportedComposeFilenames = [ + private static let supportedComposeFilenames = [ "compose.yml", "compose.yaml", "docker-compose.yml", @@ -85,6 +84,13 @@ public struct ComposeProjectOptions: ParsableArguments { URL(fileURLWithPath: composePath).deletingLastPathComponent().path } + /// Path to the environment file: the first `--env-file` if given, otherwise + /// `.env` in `cwd`. + public var envFilePath: String { + let envFile = process.envFile.first ?? ".env" + return resolvedPath(for: envFile, relativeTo: cwdURL) + } + /// Reads and decodes the compose file at `composePath`. /// - Throws: `YamlError.composeFileNotFound` if the file is missing. public func loadCompose() throws -> DockerCompose { @@ -110,7 +116,8 @@ public struct ComposeProjectOptions: ParsableArguments { /// dependency expansion behave identically here: with `requested` empty, /// every profile-eligible service plus its dependencies; otherwise the /// requested services plus their transitive dependencies (which bypass the - /// profile gate). + /// profile gate). Requested names that match no service are warned about, + /// like compose. /// /// - Parameter requested: Explicitly requested service names; empty means /// the project's default selection. @@ -123,6 +130,12 @@ public struct ComposeProjectOptions: ParsableArguments { guard let service else { return nil } return (serviceName, service) } + + let known = Set(services.map(\.serviceName)) + for name in requested where !known.contains(name) { + print("Warning: No such service: \(name)") + } + services = try Service.topoSortConfiguredServices(services) return Service.selectServices( from: services, @@ -133,20 +146,13 @@ public struct ComposeProjectOptions: ParsableArguments { /// One-shot convenience: load the compose file and resolve everything a /// command typically needs. /// - /// - Parameter requested: Service names to keep; empty means all services. + /// - Parameter requested: Explicitly requested service names; empty means + /// the project's default selection. public func resolve(filteringBy requested: [String] = []) throws -> ComposeProject { let compose = try loadCompose() let projectName = projectName(for: compose) let services = try orderedServices(of: compose, filteringBy: requested) - // Surface requested names that don't match any service, like compose. - if !requested.isEmpty { - let known = Set(services.map(\.serviceName)) - for name in requested where !known.contains(name) { - print("Warning: No such service: \(name)") - } - } - let containers = services.map { serviceName, service in ComposeProject.ServiceTarget( serviceName: serviceName, @@ -155,7 +161,7 @@ public struct ComposeProjectOptions: ParsableArguments { serviceName: serviceName, service: service, projectName: projectName) ) } - return ComposeProject(compose: compose, projectName: projectName, services: containers, cwd: cwd) + return ComposeProject(compose: compose, projectName: projectName, services: containers) } } @@ -165,7 +171,6 @@ public struct ComposeProject { public let compose: DockerCompose public let projectName: String public let services: [ServiceTarget] - public let cwd: String /// A service paired with the container names it may map to. public struct ServiceTarget { @@ -174,17 +179,6 @@ public struct ComposeProject { /// Names the service's container may exist under, in the order they /// should be tried (see `candidateContainerNames(serviceName:service:projectName:)`). public let candidateContainerNames: [String] - - /// The container this service currently maps to, resolved by trying - /// each candidate name against the daemon. `nil` when none exist. - public func existingContainer(using client: ContainerClient = ContainerClient()) async -> ContainerSnapshot? { - for name in candidateContainerNames { - if let container = try? await client.get(id: name) { - return container - } - } - return nil - } } /// Container names a service's container may exist under, in try order. @@ -193,14 +187,13 @@ public struct ComposeProject { /// `.` when the project's DNS domain is registered with /// `container system dns` at creation time (#97), `-` /// otherwise, and an explicit `container_name` overrides both — so a - /// container from a previous run may exist under any of the three - /// (mirrors `ComposeDown.stopOldStuff`). Commands that only need running - /// containers of the project can instead match the - /// `com.docker.compose.project`/`.service` labels stamped since #126, - /// but names remain necessary for containers created before that. + /// container from a previous run may exist under any of the three. + /// Commands that only need running containers of the project can instead + /// match the `com.docker.compose.project`/`.service` labels stamped since + /// #126, but names remain necessary for containers created before that. public static func candidateContainerNames(serviceName: String, service: Service, projectName: String) -> [String] { var candidates = ["\(projectName)-\(serviceName)"] - if let dnsDomain = ComposeUp.sanitizeDnsDomain(projectName) { + if let dnsDomain = sanitizeDnsDomain(projectName) { candidates.append("\(serviceName).\(dnsDomain)") } if let explicit = service.container_name, !candidates.contains(explicit) { @@ -210,4 +203,25 @@ public struct ComposeProject { } return candidates } + + /// Coerce an arbitrary project name into a single DNS label: lowercase, only + /// `[a-z0-9-]`, no leading/trailing/repeated hyphens, max 63 chars. Returns + /// `nil` when nothing usable remains (e.g. a name made entirely of separators). + static func sanitizeDnsDomain(_ name: String) -> String? { + let allowed: Set = Set("abcdefghijklmnopqrstuvwxyz0123456789-") + var out = "" + for ch in name.lowercased() { + out.append(allowed.contains(ch) ? ch : "-") + } + while out.contains("--") { + out = out.replacingOccurrences(of: "--", with: "-") + } + while out.hasPrefix("-") { out.removeFirst() } + while out.hasSuffix("-") { out.removeLast() } + if out.count > 63 { + out = String(out.prefix(63)) + while out.hasSuffix("-") { out.removeLast() } + } + return out.isEmpty ? nil : out + } } diff --git a/Sources/Container-Compose/Errors.swift b/Sources/Container-Compose/Errors.swift index 374dfac..c3aacfb 100644 --- a/Sources/Container-Compose/Errors.swift +++ b/Sources/Container-Compose/Errors.swift @@ -38,7 +38,6 @@ public enum YamlError: Error, LocalizedError { public enum ComposeError: Error, LocalizedError { case imageNotFound(String) - case invalidProjectName case containerRunFailed(String, Int32) case dependencyNotStarted(String, String) case dependencyNotHealthy(String, String) @@ -51,8 +50,6 @@ public enum ComposeError: Error, LocalizedError { switch self { case .imageNotFound(let name): return "Service \(name) must define either 'image' or 'build'." - case .invalidProjectName: - return "Could not find project name." case .containerRunFailed(let service, let exitCode): return "Service '\(service)' failed to start (container run exited with status \(exitCode))." case .dependencyNotStarted(let service, let dependency): diff --git a/Tests/Container-Compose-StaticTests/DnsDomainTests.swift b/Tests/Container-Compose-StaticTests/DnsDomainTests.swift index 77ad152..02094a8 100644 --- a/Tests/Container-Compose-StaticTests/DnsDomainTests.swift +++ b/Tests/Container-Compose-StaticTests/DnsDomainTests.swift @@ -23,44 +23,44 @@ struct DnsDomainTests { @Test("sanitize - already valid label is unchanged") func sanitizeIdentity() { - #expect(ComposeUp.sanitizeDnsDomain("dnstest") == "dnstest") - #expect(ComposeUp.sanitizeDnsDomain("my-app-1") == "my-app-1") + #expect(ComposeProject.sanitizeDnsDomain("dnstest") == "dnstest") + #expect(ComposeProject.sanitizeDnsDomain("my-app-1") == "my-app-1") } @Test("sanitize - lowercases mixed case") func sanitizeLowercases() { - #expect(ComposeUp.sanitizeDnsDomain("Container-Compose") == "container-compose") + #expect(ComposeProject.sanitizeDnsDomain("Container-Compose") == "container-compose") } @Test("sanitize - replaces underscores, dots, spaces with hyphens") func sanitizeReplacesSeparators() { - #expect(ComposeUp.sanitizeDnsDomain("my_app") == "my-app") - #expect(ComposeUp.sanitizeDnsDomain("my.app") == "my-app") - #expect(ComposeUp.sanitizeDnsDomain("my app") == "my-app") + #expect(ComposeProject.sanitizeDnsDomain("my_app") == "my-app") + #expect(ComposeProject.sanitizeDnsDomain("my.app") == "my-app") + #expect(ComposeProject.sanitizeDnsDomain("my app") == "my-app") } @Test("sanitize - collapses runs of separators") func sanitizeCollapsesRuns() { - #expect(ComposeUp.sanitizeDnsDomain("a__b..c d") == "a-b-c-d") + #expect(ComposeProject.sanitizeDnsDomain("a__b..c d") == "a-b-c-d") } @Test("sanitize - trims leading and trailing hyphens") func sanitizeTrims() { - #expect(ComposeUp.sanitizeDnsDomain("--foo--") == "foo") - #expect(ComposeUp.sanitizeDnsDomain(".devcontainers") == "devcontainers") + #expect(ComposeProject.sanitizeDnsDomain("--foo--") == "foo") + #expect(ComposeProject.sanitizeDnsDomain(".devcontainers") == "devcontainers") } @Test("sanitize - returns nil for unusable input") func sanitizeReturnsNil() { - #expect(ComposeUp.sanitizeDnsDomain("") == nil) - #expect(ComposeUp.sanitizeDnsDomain("___") == nil) - #expect(ComposeUp.sanitizeDnsDomain("...") == nil) + #expect(ComposeProject.sanitizeDnsDomain("") == nil) + #expect(ComposeProject.sanitizeDnsDomain("___") == nil) + #expect(ComposeProject.sanitizeDnsDomain("...") == nil) } @Test("sanitize - clamps to 63 chars and re-trims") func sanitizeClampsLength() { let long = String(repeating: "a", count: 70) + "-" - let out = ComposeUp.sanitizeDnsDomain(long) + let out = ComposeProject.sanitizeDnsDomain(long) #expect(out?.count == 63) #expect(out?.last != "-") } From 44c7ce3784ef9c36b819b5322e5ee8bb37333ac8 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 17 Jul 2026 18:32:06 -0400 Subject: [PATCH 09/10] fix(up): resolve healthcheck container name instead of assuming - MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitUntilServiceIsHealthy rebuilt the container name by hand, so healthchecks for services created under an explicit container_name or the dotted . DNS-mode name (#97) exec'd against a nonexistent container and burned every retry before failing. Use containerName(for:), which reads the resolved name configService records before any healthcheck runs — the same fix #127 applied to the foreground stop paths. --- Sources/Container-Compose/Commands/ComposeUp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 0938307..3721d6a 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -1240,7 +1240,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return } - let containerName = "\(projectName)-\(serviceName)" + let containerName = containerName(for: serviceName) let retries = max(healthcheck.retries ?? 3, 1) let interval = Healthcheck.parseDuration(healthcheck.interval, default: 30) let startPeriod = Healthcheck.parseDuration(healthcheck.start_period, default: 0) From 84088c9793b402672f319868a787c4f0bdfea77b Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 18 Jul 2026 11:51:03 -0400 Subject: [PATCH 10/10] test: cover loadCompose missing-file throw and healthcheck name resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two meaningful coverage gaps over this branch's diff: the composeFileNotFound path in ComposeProjectOptions.loadCompose, and a dynamic regression test proving healthchecks exec against the resolved container name — a service created under an explicit container_name previously health-checked the nonexistent - and failed up after exhausting retries. --- .../ComposeUpTests.swift | 30 +++++++++++++++++++ .../ComposeProjectTests.swift | 18 +++++++++++ 2 files changed, 48 insertions(+) diff --git a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift index cbef767..6ed708b 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift @@ -412,6 +412,36 @@ struct ComposeUpTests { try? await stopInstance(location: project.base) } + // Regression: `waitUntilServiceIsHealthy` used to rebuild its target as + // `-`, so a service created under an explicit + // `container_name` (or the dotted DNS-mode name) health-checked a + // nonexistent container, burned every retry, and `up` threw + // `healthcheckFailed` even though the container was healthy. + @Test("healthcheck execs against the resolved container name, not -") + func testHealthcheckUsesResolvedContainerName() async throws { + let explicitName = "container-compose-test-\(makeContainerName())" + let yaml = """ + services: + web: + image: nginx:alpine + container_name: \(explicitName) + healthcheck: + test: ["CMD-SHELL", "true"] + interval: "1" + retries: 2 + """ + let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml) + + var composeUp = try ComposeUp.parse(["-d", "--cwd", project.base.path(percentEncoded: false)]) + // On the bug this throws healthcheckFailed("web") after exhausting retries. + try await composeUp.run() + + let container = try await ContainerClient().get(id: explicitName) + #expect(container.status == .running) + + try? await stopInstance(location: project.base) + } + enum Errors: Error { case containerNotFound } diff --git a/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift b/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift index b9d2524..1cda08b 100644 --- a/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift +++ b/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift @@ -137,6 +137,24 @@ struct ComposeProjectTests { #expect(!FileManager.default.fileExists(atPath: expected)) } + // MARK: - loadCompose + + @Test("loadCompose throws composeFileNotFound when no compose file exists", .tempDir) + func loadComposeThrowsWhenFileMissing() throws { + let tmp = TempDirTrait.current + let options = try ComposeProjectOptions.parse(["--cwd", tmp.path]) + do { + _ = try options.loadCompose() + Issue.record("Expected loadCompose to throw composeFileNotFound") + } catch let error as ContainerComposeCore.YamlError { + guard case .composeFileNotFound(let path) = error else { + Issue.record("Unexpected YamlError: \(error)") + return + } + #expect(path == tmp.path) + } + } + // MARK: - orderedServices(of:filteringBy:) @Test("orderedServices returns dependencies before dependents")