Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Sources/Container-Compose/Codable Structs/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <svc>`,
/// which fails with "no such service: <svc>" 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.
///
Expand Down
9 changes: 9 additions & 0 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}
}

// Fail fast on a typo'd/unknown service name, matching `docker compose up
// <svc>` ("no such service: <svc>"). 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.
Expand Down
12 changes: 5 additions & 7 deletions Sources/Container-Compose/ComposeProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions Sources/Container-Compose/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
Loading