diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index e678419..78fcd73 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -422,6 +422,26 @@ public struct Service: Codable, Hashable { return sorted } + /// Validates that every explicitly requested service name exists among the + /// services defined in the compose file. Mirrors `docker compose up `, + /// which fails with "no such service: " rather than silently selecting + /// nothing — the latter previously left a foreground `up` hanging forever + /// (see `ComposeUp.runForegroundUntilStopped`). + /// - Parameters: + /// - requested: Service names passed on the command line (may be empty). + /// - defined: All services declared in the compose file. + /// - Throws: `ComposeError.noSuchService` for the first requested name that + /// is not defined. + static func validateRequestedServices( + _ requested: [String], + against defined: [(serviceName: String, service: Service)] + ) throws { + let definedNames = Set(defined.map(\.serviceName)) + for name in requested where !definedNames.contains(name) { + throw ComposeError.noSuchService(name) + } + } + /// Selects the services `up`, `build`, and `down` should act on by default, /// applying both explicit service-name filtering and Compose `profiles` gating. /// diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 3721d6a..d42efd7 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -177,6 +177,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } + // Fail fast on a typo'd/unknown service name, matching `docker compose up + // ` ("no such service: "). Without this an unknown name selected + // nothing and a foreground `up` then blocked forever in + // `runForegroundUntilStopped` instead of returning. + let allServices: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in + guard let service else { return nil } + return (name, service) + } + try Service.validateRequestedServices(self.services, against: allServices) // 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. diff --git a/Sources/Container-Compose/ComposeProject.swift b/Sources/Container-Compose/ComposeProject.swift index 4f83642..5fb8196 100644 --- a/Sources/Container-Compose/ComposeProject.swift +++ b/Sources/Container-Compose/ComposeProject.swift @@ -116,8 +116,11 @@ 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). Requested names that match no service are warned about, - /// like compose. + /// profile gate). + /// + /// Note: callers that want a hard error on unknown service names (e.g. + /// `ComposeUp`) should call `Service.validateRequestedServices` before + /// or after `resolve()`. /// /// - Parameter requested: Explicitly requested service names; empty means /// the project's default selection. @@ -131,11 +134,6 @@ public struct ComposeProjectOptions: ParsableArguments { 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, diff --git a/Sources/Container-Compose/Errors.swift b/Sources/Container-Compose/Errors.swift index c3aacfb..6e10116 100644 --- a/Sources/Container-Compose/Errors.swift +++ b/Sources/Container-Compose/Errors.swift @@ -45,6 +45,7 @@ public enum ComposeError: Error, LocalizedError { case unsupportedDependencyCondition(String, String, String) case healthcheckUnavailable(String) case healthcheckFailed(String) + case noSuchService(String) public var errorDescription: String? { switch self { @@ -64,6 +65,8 @@ public enum ComposeError: Error, LocalizedError { return "Service '\(service)' defines a healthcheck but completed before the healthcheck could run." case .healthcheckFailed(let service): return "Service '\(service)' failed its healthcheck." + case .noSuchService(let name): + return "no such service: \(name)" } } } diff --git a/Tests/Container-Compose-StaticTests/RequestedServiceValidationTests.swift b/Tests/Container-Compose-StaticTests/RequestedServiceValidationTests.swift new file mode 100644 index 0000000..49a8635 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/RequestedServiceValidationTests.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Testing +import Foundation +@testable import ContainerComposeCore + +@Suite("Requested service validation") +struct RequestedServiceValidationTests { + + private func defined(_ names: String...) -> [(serviceName: String, service: Service)] { + names.map { ($0, Service(image: "alpine")) } + } + + @Test("passes when every requested service exists") + func passesWhenAllExist() throws { + try Service.validateRequestedServices(["web", "db"], against: defined("web", "db", "cache")) + } + + @Test("passes when no services are requested") + func passesWhenNoneRequested() throws { + try Service.validateRequestedServices([], against: defined("web", "db")) + } + + @Test("throws for an unknown requested service") + func throwsForUnknown() { + #expect(throws: ComposeError.self) { + try Service.validateRequestedServices(["nope"], against: defined("web", "db")) + } + } + + @Test("error message matches docker compose wording and names the service") + func errorMessageWording() { + do { + try Service.validateRequestedServices(["typo"], against: defined("web")) + Issue.record("expected validateRequestedServices to throw") + } catch let error as ComposeError { + #expect(error.errorDescription == "no such service: typo") + } catch { + Issue.record("unexpected error type: \(error)") + } + } + + @Test("reports the first unknown service when several are requested") + func reportsFirstUnknown() { + do { + try Service.validateRequestedServices(["web", "ghost"], against: defined("web")) + Issue.record("expected validateRequestedServices to throw") + } catch let error as ComposeError { + #expect(error.errorDescription == "no such service: ghost") + } catch { + Issue.record("unexpected error type: \(error)") + } + } +}