Skip to content
Open
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
57 changes: 49 additions & 8 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,43 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
fatalError("unreachable")
}

/// Translates Compose's `entrypoint` + `command` into args for `container run`.
///
/// Compose semantics:
/// - `entrypoint` (when set) replaces the image's ENTRYPOINT.
/// - `command` (when set) replaces the image's CMD and is passed to the
/// resolved entrypoint as its argv tail.
/// - Both can be set together; they are NOT mutually exclusive.
///
/// Mapping to `container run`:
/// - First element of `entrypoint` → `--entrypoint <bin>` flag (must
/// precede the image — `container run` only accepts a single executable
/// for `--entrypoint`, not a full argv).
/// - Remaining `entrypoint` elements + every `command` element → positional
/// args after the image.
///
/// Notable case from issue #77: `entrypoint: ["/bin/bash", "-c"]` +
/// `command: ["<multi-line script>"]` becomes
/// `--entrypoint /bin/bash <image> -c <script>`, so bash receives both its
/// `-c` flag and the script as a single argument.
static func entrypointAndCommandArgs(
entrypoint: [String]?,
command: [String]?
) -> (entrypointFlag: String?, positional: [String]) {
var positional: [String] = []
let entrypointFlag: String?
if let entrypoint, !entrypoint.isEmpty {
entrypointFlag = entrypoint.first
positional.append(contentsOf: entrypoint.dropFirst())
} else {
entrypointFlag = nil
}
if let command {
positional.append(contentsOf: command)
}
return (entrypointFlag, positional)
}

private func getIPForRunningService(_ serviceName: String) async throws -> String? {
guard let projectName else { return nil }

Expand Down Expand Up @@ -552,15 +589,19 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
runCommandArgs.append("-t") // --tty
}

runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint

// Add entrypoint or command
if let entrypointParts = service.entrypoint {
runCommandArgs.append("--entrypoint")
runCommandArgs.append(contentsOf: entrypointParts)
} else if let commandParts = service.command {
runCommandArgs.append(contentsOf: commandParts)
// Translate `entrypoint` + `command` into the right shape for
// `container run`. Both can be set together — they are NOT mutually
// exclusive in Compose semantics — and `--entrypoint` must precede
// the image. See the helper below for the full mapping.
let argv = Self.entrypointAndCommandArgs(
entrypoint: service.entrypoint,
command: service.command
)
if let entrypointFlag = argv.entrypointFlag {
runCommandArgs.append(contentsOf: ["--entrypoint", entrypointFlag])
}
runCommandArgs.append(imageToRun)
runCommandArgs.append(contentsOf: argv.positional)

var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()!

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//===----------------------------------------------------------------------===//
// 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 ContainerCommands
import ContainerAPIClient
import TestHelpers
@testable import ContainerComposeCore

/// End-to-end check for the entrypoint+command translation. The exact pattern
/// from issue #77: `entrypoint: ["/bin/sh", "-c"]` plus a multi-line command
/// that depends on the script reaching `sh` as a single argument.
@Suite("Compose Up Tests - Entrypoint + Command", .containerDependent, .serialized)
struct ComposeUpEntrypointTests {

func stopInstance(location: URL) async throws {
var composeDown = try ComposeDown.parse(["--cwd", location.path(percentEncoded: false)])
try await composeDown.run()
}

@Test("sh -c + multi-line command runs the script (regression for #77)")
func shHeredocCommandRuns() async throws {
// The multi-line command exits 0 only when the script is delivered intact.
// On the broken code path, `command:` is silently dropped (mutually
// exclusive with `entrypoint:`), so the container would either fail to
// start or sit idle.
let yaml = """
name: cc-entrypoint-test
services:
probe:
image: alpine:latest
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo first-line
echo second-line
sleep 30
"""
let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml)

var composeUp = try ComposeUp.parse([
"-d", "--cwd", project.base.path(percentEncoded: false),
])
try await composeUp.run()

let containerID = "cc-entrypoint-test-probe"
let client = ContainerClient()
let container = try? await client.get(id: containerID)

// On the buggy code, `command:` is dropped and the container's only
// arg is `--entrypoint /bin/sh -c` (positional, malformed) — sh exits
// immediately. So a container that's `running` after `up` proves the
// command actually got through.
#expect(container != nil, "expected container '\(containerID)' to exist")
#expect(container?.status == .running,
"container should be running — broken code drops `command:` and sh exits with no script")

try? await stopInstance(location: project.base)
}
}
110 changes: 110 additions & 0 deletions Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//===----------------------------------------------------------------------===//
// 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("Entrypoint + Command Translation")
struct EntrypointCommandTests {

@Test("nil entrypoint and nil command → no flag, no positional args")
func bothNil() {
let r = ComposeUp.entrypointAndCommandArgs(entrypoint: nil, command: nil)
#expect(r.entrypointFlag == nil)
#expect(r.positional == [])
}

@Test("command only → no flag, command as positional")
func commandOnly() {
let r = ComposeUp.entrypointAndCommandArgs(
entrypoint: nil,
command: ["nginx", "-g", "daemon off;"]
)
#expect(r.entrypointFlag == nil)
#expect(r.positional == ["nginx", "-g", "daemon off;"])
}

@Test("single-element entrypoint, no command → flag set, no positional")
func singleEntrypointNoCommand() {
let r = ComposeUp.entrypointAndCommandArgs(
entrypoint: ["/usr/local/bin/start.sh"],
command: nil
)
#expect(r.entrypointFlag == "/usr/local/bin/start.sh")
#expect(r.positional == [])
}

@Test("multi-element entrypoint, no command → first goes to flag, rest positional")
func multiEntrypointNoCommand() {
let r = ComposeUp.entrypointAndCommandArgs(
entrypoint: ["/bin/sh", "-c", "echo hi"],
command: nil
)
#expect(r.entrypointFlag == "/bin/sh")
#expect(r.positional == ["-c", "echo hi"])
}

@Test("entrypoint AND command both set → combined (regression for issue #77)")
func bothEntrypointAndCommand() {
let r = ComposeUp.entrypointAndCommandArgs(
entrypoint: ["/bin/sh", "-c"],
command: ["echo hello && echo world"]
)
#expect(r.entrypointFlag == "/bin/sh")
#expect(r.positional == ["-c", "echo hello && echo world"])
}

@Test("issue #77: bash -c + multi-line heredoc command")
func issue77HeredocCase() {
// YAML this models:
// entrypoint: ["/bin/bash", "-c"]
// command:
// - |
// sed -i "s|Listen 80|Listen 8080|" /etc/httpd.conf
// exec httpd-foreground
let heredoc = """
sed -i "s|Listen 80|Listen 8080|" /etc/httpd.conf
exec httpd-foreground

"""
let r = ComposeUp.entrypointAndCommandArgs(
entrypoint: ["/bin/bash", "-c"],
command: [heredoc]
)
#expect(r.entrypointFlag == "/bin/bash")
#expect(r.positional.count == 2)
#expect(r.positional.first == "-c")
#expect(r.positional.last == heredoc)
}

@Test("empty entrypoint array → treated as nil")
func emptyEntrypoint() {
let r = ComposeUp.entrypointAndCommandArgs(entrypoint: [], command: ["echo"])
#expect(r.entrypointFlag == nil)
#expect(r.positional == ["echo"])
}

@Test("empty command array → just empty positional")
func emptyCommand() {
let r = ComposeUp.entrypointAndCommandArgs(
entrypoint: ["/bin/sh"],
command: []
)
#expect(r.entrypointFlag == "/bin/sh")
#expect(r.positional == [])
}
}
Loading