diff --git a/Sources/Container-Compose/Commands/ComposeBuild.swift b/Sources/Container-Compose/Commands/ComposeBuild.swift index 2d3e5de..56d5881 100644 --- a/Sources/Container-Compose/Commands/ComposeBuild.swift +++ b/Sources/Container-Compose/Commands/ComposeBuild.swift @@ -40,85 +40,28 @@ 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 envFilePath: String { - let envFile = process.envFile.first ?? ".env" - return resolvedPath(for: envFile, relativeTo: cwdURL) - } + private var composeDirectory: String { projectOptions.composeDirectory } 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) - 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) - } + // 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: projectOptions.envFilePath) + + let servicesToBuild = project.services.filter { $0.service.build != nil } if servicesToBuild.isEmpty { print("No services with a 'build' configuration found.") @@ -126,8 +69,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/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index 97647f0..de25845 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -39,116 +39,47 @@ 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 project = try projectOptions.resolve(filteringBy: services) - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = 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 { - 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: \(project.projectName)") } - 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 - 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 = 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. " + "Pass --profile (or name the service explicitly) to also stop them." ) } - services = selected } - try await stopOldStuff(services, remove: false) + try await stop(project) } - 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) 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)") @@ -158,22 +89,14 @@ 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 '\(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/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index e878245..3721d6a 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -91,42 +91,11 @@ 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 envFilePath: String { - let envFile = process.envFile.first ?? ".env" - return resolvedPath(for: envFile, relativeTo: cwdURL) - } - - private var composeDirectory: String { - URL(fileURLWithPath: composePath).deletingLastPathComponent().path - } + private var composePath: String { projectOptions.composePath } + private var composeDirectory: String { projectOptions.composeDirectory } + private var cwd: String { projectOptions.cwd } @Flag(name: [.customShort("b"), .customLong("build")]) var rebuild: Bool = false @@ -134,16 +103,13 @@ 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? + /// 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. @@ -168,20 +134,15 @@ 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) + // 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 { @@ -189,23 +150,20 @@ 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 - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") + 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 = ComposeProject.sanitizeDnsDomain(projectName) { dnsDomain = derived dnsAvailable = await checkDnsDomainRegistered(derived) if dnsAvailable { @@ -219,31 +177,10 @@ 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 - ) - // 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 - } - 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 @@ -269,13 +206,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) })) } } @@ -547,30 +484,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: @@ -888,8 +802,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 @@ -1324,12 +1236,11 @@ 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 } - 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) diff --git a/Sources/Container-Compose/ComposeProject.swift b/Sources/Container-Compose/ComposeProject.swift new file mode 100644 index 0000000..4f83642 --- /dev/null +++ b/Sources/Container-Compose/ComposeProject.swift @@ -0,0 +1,227 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerAPIClient +import ContainerCommands +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. + private 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 + } + + /// 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 { + 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 services the command should act on, in dependency + /// (topological) order. + /// + /// 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). Requested names that match no service are warned about, + /// like compose. + /// + /// - 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, + 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) + } + + 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, + requestedServices: requested, + activeProfiles: composeFileOptions.activeProfiles) + } + + /// One-shot convenience: load the compose file and resolve everything a + /// command typically needs. + /// + /// - 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) + + let containers = services.map { serviceName, service in + ComposeProject.ServiceTarget( + serviceName: serviceName, + service: service, + candidateContainerNames: ComposeProject.candidateContainerNames( + serviceName: serviceName, service: service, projectName: projectName) + ) + } + return ComposeProject(compose: compose, projectName: projectName, services: containers) + } +} + +/// 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] + + /// A service paired with the container names it may map to. + public struct ServiceTarget { + public let serviceName: String + public let service: Service + /// Names the service's container may exist under, in the order they + /// should be tried (see `candidateContainerNames(serviceName:service:projectName:)`). + public let candidateContainerNames: [String] + } + + /// 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. + /// 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 = 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 + } + + /// 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-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-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/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/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/ComposeProjectTests.swift b/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift new file mode 100644 index 0000000..1cda08b --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ComposeProjectTests.swift @@ -0,0 +1,268 @@ +//===----------------------------------------------------------------------===// +// 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: - 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") + 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: - 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") + 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"]) + } + + @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"]) + } +} 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 != "-") } diff --git a/Tests/Container-Compose-StaticTests/ProfilesTests.swift b/Tests/Container-Compose-StaticTests/ProfilesTests.swift index 2f9ce3d..3c33b2d 100644 --- a/Tests/Container-Compose-StaticTests/ProfilesTests.swift +++ b/Tests/Container-Compose-StaticTests/ProfilesTests.swift @@ -158,25 +158,25 @@ 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") 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") 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 @@ -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) } }